diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 132c61f3..10b96cac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,6 +66,11 @@ android { isIncludeAndroidResources = true } } + + sourceSets { + getByName("test").kotlin.srcDir("src/testShared/java") + getByName("androidTest").kotlin.srcDir("src/testShared/java") + } } roborazzi { @@ -82,6 +87,8 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.navigation.runtime.ktx) diff --git a/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt b/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt index 96e63696..a82b6424 100644 --- a/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt +++ b/app/src/androidTest/java/com/example/cahier/CahierListDetailTest.kt @@ -28,13 +28,19 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.example.cahier.core.data.FakeNotesRepository import com.example.cahier.features.home.HomePane import com.example.cahier.features.home.viewmodel.HomeScreenViewModel +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) +@HiltAndroidTest class CahierListDetailTest { - @get:Rule + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) val composeTestRule = createComposeRule() private val fakeViewModel = HomeScreenViewModel(FakeNotesRepository()) @@ -83,6 +89,7 @@ class CahierListDetailTest { HomePane( navigateToCanvas = { _ -> }, navigateToDrawingCanvas = { _ -> }, + navigateToBrushGraph = {}, navigateUp = {}, forceCompact = forceCompact, homeScreenViewModel = fakeViewModel diff --git a/app/src/androidTest/java/com/example/cahier/core/data/OfflineNotesRepositoryTest.kt b/app/src/androidTest/java/com/example/cahier/core/data/OfflineNotesRepositoryTest.kt index e8e16c2a..2db81961 100644 --- a/app/src/androidTest/java/com/example/cahier/core/data/OfflineNotesRepositoryTest.kt +++ b/app/src/androidTest/java/com/example/cahier/core/data/OfflineNotesRepositoryTest.kt @@ -8,6 +8,7 @@ import androidx.ink.strokes.ImmutableStrokeInputBatch import androidx.ink.strokes.Stroke import androidx.test.core.app.ApplicationProvider import com.example.cahier.core.ui.Converters +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -36,12 +37,14 @@ class OfflineNotesRepositoryTest { private val testDispatcher = UnconfinedTestDispatcher() private lateinit var repository: OfflineNotesRepository private val noteDao: NoteDao = mock() + private val customBrushDao: CustomBrushDao = mock() @Before - fun setup() { + fun setup() = runTest { Dispatchers.setMain(testDispatcher) + whenever(customBrushDao.getAllCustomBrushesSync(any())).thenReturn(emptyList()) val context = ApplicationProvider.getApplicationContext() - repository = OfflineNotesRepository(noteDao, context) + repository = OfflineNotesRepository(noteDao, context, customBrushDao, mock()) } @After @@ -87,6 +90,7 @@ class OfflineNotesRepositoryTest { verify(noteDao).updateNote(note.copy(isFavorite = true)) } + @Test fun getAllNotesStream_returns_flow_from_DAO() = runTest { val notes = listOf(Note(id = 1), Note(id = 2)) diff --git a/app/src/androidTest/java/com/example/cahier/core/utils/PointerInputUtilTest.kt b/app/src/androidTest/java/com/example/cahier/core/utils/PointerInputUtilTest.kt index 8f054df2..9c1b5b7f 100644 --- a/app/src/androidTest/java/com/example/cahier/core/utils/PointerInputUtilTest.kt +++ b/app/src/androidTest/java/com/example/cahier/core/utils/PointerInputUtilTest.kt @@ -31,6 +31,8 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule @@ -38,9 +40,13 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) +@HiltAndroidTest class PointerInputUtilTest { - @get:Rule + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) val composeTestRule = createComposeRule() @Test diff --git a/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt b/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt index e0387199..deafe93a 100644 --- a/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt +++ b/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt @@ -1,8 +1,9 @@ package com.example.cahier.developer.brushdesigner.viewmodel import android.content.Context -import android.graphics.Bitmap import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.developer.brushdesigner.data.BrushDesignerRepository import com.example.cahier.developer.brushdesigner.data.CustomBrushDao import dagger.hilt.android.testing.HiltAndroidRule @@ -16,14 +17,15 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith import javax.inject.Inject +@RunWith(AndroidJUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest class BrushDesignerViewModelTest { @@ -51,7 +53,10 @@ class BrushDesignerViewModelTest { repository.updateActiveBrushProto(defaultProto()) val context = ApplicationProvider.getApplicationContext() - viewModel = BrushDesignerViewModel(context, repository, customBrushDao) + viewModel = BrushDesignerViewModel( + context, repository, + CahierTextureBitmapStore(context), customBrushDao + ) } /** Produces a clean default [ProtoBrushFamily] matching the repository's initial state. */ @@ -141,7 +146,6 @@ class BrushDesignerViewModelTest { @Test fun saveToPalette_persists_to_dao() = runTest { val brushName = "Test Persistence Brush" - viewModel.updateClientBrushFamilyId("test-id") viewModel.saveToPalette(brushName).join() @@ -153,36 +157,14 @@ class BrushDesignerViewModelTest { fun previewBrushFamily_is_null_on_invalid_proto() = runTest { val invalidRepo = BrushDesignerRepository() invalidRepo.updateActiveBrushProto(ink.proto.BrushFamily.newBuilder().build()) - + val context = ApplicationProvider.getApplicationContext() val vm = BrushDesignerViewModel( - ApplicationProvider.getApplicationContext(), + context, invalidRepo, + CahierTextureBitmapStore(context), customBrushDao ) assertNull(vm.previewBrushFamily.value) } - - @Test - fun setTextureStore_immediately_syncs_proto_textures() = runTest { - val context = ApplicationProvider.getApplicationContext() - val testTextureId = "test-texture" - val testBitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888) - - val baos = java.io.ByteArrayOutputStream() - testBitmap.compress(Bitmap.CompressFormat.PNG, 100, baos) - val textureBytes = com.google.protobuf.ByteString.copyFrom(baos.toByteArray()) - - val protoWithTexture = viewModel.activeBrushProto.value.toBuilder() - .putTextureIdToBitmap(testTextureId, textureBytes) - .build() - repository.updateActiveBrushProto(protoWithTexture) - - val store = com.example.cahier.core.ui.CahierTextureBitmapStore(context) - assertNull(store[testTextureId]) - - viewModel.setTextureStore(store) - - assertNotNull(store[testTextureId]) - } } diff --git a/app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt b/app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt new file mode 100644 index 00000000..073738c3 --- /dev/null +++ b/app/src/androidTest/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModelTest.kt @@ -0,0 +1,628 @@ +package com.example.cahier.developer.brushgraph.viewmodel + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import com.example.cahier.developer.brushdesigner.data.FakeCustomBrushDao +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.DefaultBrushGraphRepository +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import java.io.ByteArrayOutputStream +import ink.proto.BrushTip as ProtoBrushTip + +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +class BrushGraphViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var fakeDao: FakeCustomBrushDao + private lateinit var mockTextureStore: CahierTextureBitmapStore + private lateinit var repository: DefaultBrushGraphRepository + private lateinit var viewModel: BrushGraphViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + fakeDao = FakeCustomBrushDao() + mockTextureStore = + CahierTextureBitmapStore(InstrumentationRegistry.getInstrumentation().targetContext) + + val repoScope = + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + testDispatcher) + repository = DefaultBrushGraphRepository(fakeDao, mockTextureStore, repoScope) + + viewModel = BrushGraphViewModel(fakeDao, mockTextureStore, repository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun initialState_isCorrect() = testScope.runTest { + val state = viewModel.uiState.first() + val defaultGraph = repository.createDefaultGraph() + assertEquals(defaultGraph.nodes.size, state.graph.nodes.size) + assertEquals(defaultGraph.edges.size, state.graph.edges.size) + + val expectedTypes = defaultGraph.nodes.map { it.data::class }.toSet() + val actualTypes = state.graph.nodes.map { it.data::class }.toSet() + assertEquals(expectedTypes, actualTypes) + + assertFalse(state.isSelectionMode) + assertTrue(state.selectedNodeIds.isEmpty()) + assertNull(state.selectedNodeId) + assertNull(state.selectedEdge) + assertNull(state.activeEdgeSourceId) + assertNull(state.detachedEdge) + + assertFalse(state.isErrorPaneOpen) + assertFalse(state.textFieldsLocked) + assertFalse(state.isDarkCanvas) + assertTrue(state.isPreviewExpanded) + assertTrue(state.testAutoUpdateStrokes) + + assertTrue(state.graphIssues.isEmpty()) + } + + @Test + fun addNode_updatesSelectedNodeIdAndCallsRepo() = testScope.runTest { + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + + val nodeId = viewModel.addNode(nodeData) + + testScope.advanceUntilIdle() + + val state = viewModel.uiState.first() + assertEquals(nodeId, state.selectedNodeId) + assertTrue(state.graph.nodes.any { it.id == nodeId }) + } + + @Test + fun enterSelectionMode_updatesState() = testScope.runTest { + val nodeId = "node1" + repository.setGraph( + BrushGraph( + nodes = listOf( + GraphNode( + id = nodeId, + data = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + ) + ) + ) + ) + + testScope.advanceUntilIdle() + + viewModel.enterSelectionMode(nodeId) + + val state = viewModel.uiState.first() + assertTrue(state.isSelectionMode) + assertEquals(setOf(nodeId), state.selectedNodeIds) + } + + @Test + fun toggleNodeSelection_updatesState() = testScope.runTest { + val nodeId = "node1" + repository.setGraph( + BrushGraph( + nodes = listOf( + GraphNode( + id = nodeId, + data = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + ) + ) + ) + ) + + testScope.advanceUntilIdle() + + viewModel.enterSelectionMode(nodeId) + + viewModel.toggleNodeSelection(nodeId) + + val state = viewModel.uiState.first() + assertFalse(state.isSelectionMode) + assertTrue(state.selectedNodeIds.isEmpty()) + } + + @Test + fun onNodeClick_togglesSelection() = testScope.runTest { + val nodeId = "node1" + repository.setGraph( + BrushGraph( + nodes = listOf( + GraphNode( + id = nodeId, + data = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + ) + ) + ) + ) + + testScope.advanceUntilIdle() + + viewModel.onNodeClick(nodeId) + + var state = viewModel.uiState.first() + assertEquals(nodeId, state.selectedNodeId) + + viewModel.onNodeClick(nodeId) + + state = viewModel.uiState.first() + assertNull(state.selectedNodeId) + } + + @Test + fun dismissPanes_clearsSelections() = testScope.runTest { + val nodeId = "node1" + repository.setGraph( + BrushGraph( + nodes = listOf( + GraphNode( + id = nodeId, + data = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + ) + ) + ) + ) + + testScope.advanceUntilIdle() + + viewModel.onNodeClick(nodeId) + + viewModel.dismissPanes() + + val state = viewModel.uiState.first() + assertNull(state.selectedNodeId) + assertNull(state.selectedEdge) + assertFalse(state.isErrorPaneOpen) + } + + @Test + fun selectAllNodes_updatesState() = testScope.runTest { + val node1 = "node1" + val node2 = "node2" + repository.setGraph( + BrushGraph( + nodes = listOf( + GraphNode(id = node1, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance())), + GraphNode(id = node2, data = NodeData.Coat()) + ) + ) + ) + + testScope.advanceUntilIdle() + + viewModel.enterSelectionMode() + viewModel.selectAllNodes() + + val state = viewModel.uiState.first() + assertEquals(setOf(node1, node2), state.selectedNodeIds) + } + + @Test + fun exitSelectionMode_clearsState() = testScope.runTest { + val nodeId = "node1" + repository.setGraph( + BrushGraph( + nodes = listOf( + GraphNode( + id = nodeId, + data = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + ) + ) + ) + ) + + testScope.advanceUntilIdle() + + viewModel.enterSelectionMode(nodeId) + assertTrue(viewModel.uiState.first().isSelectionMode) + + viewModel.exitSelectionMode() + + val state = viewModel.uiState.first() + assertFalse(state.isSelectionMode) + assertTrue(state.selectedNodeIds.isEmpty()) + } + + @Test + fun onEdgeClick_togglesEdgeSelection() = testScope.runTest { + val edge = com.example.cahier.developer.brushgraph.data.GraphEdge( + fromNodeId = "node1", + toNodeId = "node2", + toPortId = "tip" + ) + + viewModel.onEdgeClick(edge) + + var state = viewModel.uiState.first() + assertEquals(edge, state.selectedEdge) + + viewModel.onEdgeClick(edge) + + state = viewModel.uiState.first() + assertNull(state.selectedEdge) + } + + @Test + fun toggleErrorPane_togglesState() = testScope.runTest { + assertFalse(viewModel.uiState.first().isErrorPaneOpen) + + viewModel.toggleErrorPane() + assertTrue(viewModel.uiState.first().isErrorPaneOpen) + + viewModel.toggleErrorPane() + assertFalse(viewModel.uiState.first().isErrorPaneOpen) + } + + @Test + fun updateTestBrushColor_updatesState() = testScope.runTest { + val color = androidx.compose.ui.graphics.Color.Green + viewModel.updateTestBrushColor(color) + assertEquals(color, viewModel.uiState.first().testBrushColor) + } + + @Test + fun updateTestBrushSize_updatesState() = testScope.runTest { + val size = 15f + viewModel.updateTestBrushSize(size) + assertEquals(size, viewModel.uiState.first().testBrushSize, 0.01f) + } + + @Test + fun setTestAutoUpdateStrokes_updatesState() = testScope.runTest { + assertTrue(viewModel.uiState.first().testAutoUpdateStrokes) + + viewModel.setTestAutoUpdateStrokes(false) + assertFalse(viewModel.uiState.first().testAutoUpdateStrokes) + } + + @Test + fun updateZoom_updatesState() = testScope.runTest { + val zoom = 2f + viewModel.updateZoom(zoom) + assertEquals(zoom, viewModel.uiState.first().zoom, 0.01f) + } + + @Test + fun updateOffset_updatesState() = testScope.runTest { + val offset = com.example.cahier.developer.brushgraph.data.GraphPoint(10f, 20f) + viewModel.updateOffset(offset) + assertEquals(offset, viewModel.uiState.first().offset) + } + + @Test + fun toggleTextFieldsLocked_updatesState() = testScope.runTest { + assertFalse(viewModel.uiState.first().textFieldsLocked) + + viewModel.toggleTextFieldsLocked() + assertTrue(viewModel.uiState.first().textFieldsLocked) + } + + @Test + fun toggleCanvasTheme_updatesState() = testScope.runTest { + assertFalse(viewModel.uiState.first().isDarkCanvas) + + viewModel.toggleCanvasTheme() + assertTrue(viewModel.uiState.first().isDarkCanvas) + } + + @Test + fun togglePreviewExpanded_updatesState() = testScope.runTest { + assertTrue(viewModel.uiState.first().isPreviewExpanded) + + viewModel.togglePreviewExpanded() + assertFalse(viewModel.uiState.first().isPreviewExpanded) + } + + @Test + fun startTutorial_callsTutorialManager() = testScope.runTest { + viewModel.startTutorial() + assertEquals(0, viewModel.currentStepIndex) + + viewModel.advanceTutorial() + assertEquals(1, viewModel.currentStepIndex) + + viewModel.startTutorial() + assertEquals(0, viewModel.currentStepIndex) + } + + @Test + fun startTutorialSandbox_updatesState() = testScope.runTest { + assertFalse(viewModel.isTutorialSandboxMode) + + viewModel.startTutorialSandbox() + assertTrue(viewModel.isTutorialSandboxMode) + } + + @Test + fun advanceTutorial_updatesStep() = testScope.runTest { + viewModel.startTutorial() + val initialStep = viewModel.currentStepIndex + + val advanced = viewModel.advanceTutorial() + assertTrue(advanced) + assertEquals(initialStep + 1, viewModel.currentStepIndex) + } + + @Test + fun regressTutorial_updatesStep() = testScope.runTest { + viewModel.startTutorial() + viewModel.advanceTutorial() + val stepAfterAdvance = viewModel.currentStepIndex + + viewModel.regressTutorial() + assertEquals(stepAfterAdvance - 1, viewModel.currentStepIndex) + } + + @Test + fun endTutorialSandbox_updatesState() = testScope.runTest { + viewModel.startTutorialSandbox() + assertTrue(viewModel.isTutorialSandboxMode) + + viewModel.endTutorialSandbox(keepChanges = false) + assertFalse(viewModel.isTutorialSandboxMode) + } + + @Test + fun saveToPalette_callsDao() = testScope.runTest { + val brushName = "testBrush" + + viewModel.saveToPalette(brushName) + + // Wait for the IO coroutine to finish on the device + Thread.sleep(500) + + val savedBrushes = fakeDao.getAllCustomBrushes().first() + assertTrue(savedBrushes.any { it.name == brushName }) + } + + @Test + fun deleteFromPalette_callsDao() = testScope.runTest { + val brushName = "testBrush" + val entity = CustomBrushEntity(name = brushName, brushBytes = byteArrayOf()) + fakeDao.saveCustomBrush(entity) + + viewModel.deleteFromPalette(brushName) + + // Wait for the IO coroutine to finish on the device + Thread.sleep(500) + + val savedBrushes = fakeDao.getAllCustomBrushes().first() + assertFalse(savedBrushes.any { it.name == brushName }) + } + + @Test + fun loadFromPalette_callsRepo() = testScope.runTest { + val brushName = "testBrush" + val family = androidx.ink.brush.Brush.createWithColorIntArgb( + androidx.ink.brush.StockBrushes.marker(), + 0, + 10f, + 0.1f + ).family + val baos = ByteArrayOutputStream() + androidx.ink.storage.AndroidBrushFamilySerialization.encode(family, baos, mockTextureStore) + val entity = CustomBrushEntity(name = brushName, brushBytes = baos.toByteArray()) + + viewModel.loadFromPalette(entity) + + testScope.advanceUntilIdle() + + // Verification is hard without a spy, but we ensure no crash. + } + + @Test + fun addFamilyNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addFamilyNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.Family) + } + + @Test + fun addCoatNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addCoatNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.Coat) + } + + @Test + fun addPaintNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addPaintNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.Paint) + } + + @Test + fun addTipNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addTipNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.Tip) + } + + @Test + fun addColorFunctionNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addColorFunctionNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.ColorFunction) + } + + @Test + fun addTextureLayerNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addTextureLayerNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.TextureLayer) + } + + @Test + fun addBehaviorNode_addsNode() = testScope.runTest { + val nodeId = viewModel.addBehaviorNode() + testScope.advanceUntilIdle() + val node = viewModel.uiState.first().graph.nodes.find { it.id == nodeId }!! + assertTrue(node.data is NodeData.Behavior) + } + + @Test + fun deleteEdge_updatesState() = testScope.runTest { + val node1 = "node1" + val node2 = "node2" + val edge = com.example.cahier.developer.brushgraph.data.GraphEdge( + fromNodeId = node1, + toNodeId = node2, + toPortId = "tip" + ) + repository.setGraph( + BrushGraph( + nodes = listOf( + GraphNode(id = node1, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance())), + GraphNode(id = node2, data = NodeData.Coat()) + ), + edges = listOf(edge) + ) + ) + + testScope.advanceUntilIdle() + + viewModel.onEdgeClick(edge) + assertEquals(edge, viewModel.uiState.first().selectedEdge) + + viewModel.deleteEdge(edge) + testScope.advanceUntilIdle() + + val state = viewModel.uiState.first() + assertNull(state.selectedEdge) + assertFalse(state.graph.edges.contains(edge)) + } + + @Test + fun finalizeEdgeEdit_updatesState() = testScope.runTest { + val node1 = "node1" + val node2 = "node2" + val node3 = "node3" + val oldEdge = com.example.cahier.developer.brushgraph.data.GraphEdge( + fromNodeId = node1, + toNodeId = node2, + toPortId = "tip" + ) + repository.setGraph( + BrushGraph( + nodes = listOf( + GraphNode(id = node1, data = NodeData.Tip(ProtoBrushTip.getDefaultInstance())), + GraphNode(id = node2, data = NodeData.Coat()), + GraphNode(id = node3, data = NodeData.Coat(paintPortIds = listOf("color"))) + ), + edges = listOf(oldEdge) + ) + ) + + testScope.advanceUntilIdle() + + viewModel.detachEdge(oldEdge) + assertEquals(oldEdge, viewModel.uiState.first().detachedEdge) + + viewModel.finalizeEdgeEdit(oldEdge, node1, node3, "color") + testScope.advanceUntilIdle() + + val state = viewModel.uiState.first() + assertNull(state.detachedEdge) + assertTrue(state.graph.edges.any { it.fromNodeId == node1 && it.toNodeId == node3 && it.toPortId == "color" }) + } + + @Test + fun detachEdge_updatesState() = testScope.runTest { + val edge = com.example.cahier.developer.brushgraph.data.GraphEdge( + fromNodeId = "node1", + toNodeId = "node2", + toPortId = "tip" + ) + + viewModel.detachEdge(edge) + + val state = viewModel.uiState.first() + assertEquals(edge, state.detachedEdge) + } + + @Test + fun addNodeAndConnect_addsNodeAndEdge() = testScope.runTest { + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + val targetNodeId = "node2" + val targetPortId = "tip" + + repository.setGraph( + BrushGraph( + nodes = listOf( + GraphNode( + id = targetNodeId, + data = NodeData.Coat(tipPortId = targetPortId) + ) + ) + ) + ) + testScope.advanceUntilIdle() + + val newNodeId = viewModel.addNodeAndConnect(nodeData, targetNodeId, targetPortId) + testScope.advanceUntilIdle() + + val state = viewModel.uiState.first() + assertTrue(state.graph.nodes.any { it.id == newNodeId }) + assertTrue(state.graph.edges.any { it.fromNodeId == newNodeId && it.toNodeId == targetNodeId && it.toPortId == targetPortId }) + } + + @Test + fun clearStrokes_clearsList() = testScope.runTest { + viewModel.strokeList.add(mock(androidx.ink.strokes.Stroke::class.java)) + assertFalse(viewModel.strokeList.isEmpty()) + + viewModel.clearStrokes() + assertTrue(viewModel.strokeList.isEmpty()) + } + + @Test + fun getBrushColor_returnsColor() = testScope.runTest { + val color = androidx.compose.ui.graphics.Color.Green + viewModel.updateTestBrushColor(color) + + testScope.advanceUntilIdle() + + assertEquals(color, viewModel.getBrushColor()) + } + + @Test + fun updateAllTextureIds_updatesState() = testScope.runTest { + viewModel.updateAllTextureIds() + + val state = viewModel.uiState.first() + assertEquals(mockTextureStore.getAllIds(), state.allTextureIds) + } +} diff --git a/app/src/androidTest/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModelTest.kt b/app/src/androidTest/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModelTest.kt index ba5f3ebb..a3af6996 100644 --- a/app/src/androidTest/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModelTest.kt +++ b/app/src/androidTest/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModelTest.kt @@ -31,12 +31,13 @@ import androidx.ink.strokes.ImmutableStrokeInputBatch import androidx.ink.strokes.Stroke import androidx.lifecycle.SavedStateHandle import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 import coil3.ImageLoader import com.example.cahier.core.data.FakeNotesRepository import com.example.cahier.core.navigation.DrawingCanvasDestination +import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.core.utils.FileHelper -import com.example.cahier.developer.brushdesigner.data.CustomBrushDao -import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import com.example.cahier.developer.brushdesigner.data.FakeCustomBrushDao import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.Dispatchers @@ -51,13 +52,13 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith import javax.inject.Inject +@RunWith(AndroidJUnit4::class) @ExperimentalCoroutinesApi @HiltAndroidTest class DrawingCanvasViewModelTest { @@ -95,7 +96,8 @@ class DrawingCanvasViewModelTest { val context = ApplicationProvider.getApplicationContext() viewModel = DrawingCanvasViewModel( context, savedStateHandle, notesRepository, fileHelper, imageLoader, - customBrushDao = FakeCustomBrushDao() + customBrushDao = FakeCustomBrushDao(), + CahierTextureBitmapStore(context) ) } @@ -218,9 +220,3 @@ class DrawingCanvasViewModelTest { ) } } - -private class FakeCustomBrushDao : CustomBrushDao { - override fun getAllCustomBrushes(): Flow> = flowOf(emptyList()) - override suspend fun saveCustomBrush(brush: CustomBrushEntity) {} - override suspend fun deleteCustomBrush(name: String) {} -} \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/MainActivity.kt b/app/src/main/java/com/example/cahier/MainActivity.kt index 4a8428e3..a8499a2f 100644 --- a/app/src/main/java/com/example/cahier/MainActivity.kt +++ b/app/src/main/java/com/example/cahier/MainActivity.kt @@ -30,10 +30,15 @@ import androidx.core.content.IntentCompat import com.example.cahier.core.data.NoteType import com.example.cahier.features.home.CahierApp import com.example.cahier.core.ui.theme.CahierAppTheme +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.core.ui.LocalTextureStore import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { + + @javax.inject.Inject lateinit var textureStore: CahierTextureBitmapStore + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -47,11 +52,13 @@ class MainActivity : ComponentActivity() { setContent { CahierAppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - CahierApp(noteId = noteId, noteType = noteType) + androidx.compose.runtime.CompositionLocalProvider(LocalTextureStore provides textureStore) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + CahierApp(noteId = noteId, noteType = noteType, textureStore = textureStore) + } } } } diff --git a/app/src/main/java/com/example/cahier/core/data/OfflineNotesRepository.kt b/app/src/main/java/com/example/cahier/core/data/OfflineNotesRepository.kt index ad25e363..6fe9b72e 100644 --- a/app/src/main/java/com/example/cahier/core/data/OfflineNotesRepository.kt +++ b/app/src/main/java/com/example/cahier/core/data/OfflineNotesRepository.kt @@ -19,15 +19,23 @@ package com.example.cahier.core.data import android.content.Context +import androidx.ink.brush.Version +import androidx.ink.storage.AndroidBrushFamilySerialization +import androidx.ink.storage.BrushFamilyDecodeCallback import androidx.ink.strokes.Stroke +import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.core.ui.Converters +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao import com.example.cahier.features.drawing.CustomBrushes import kotlinx.coroutines.flow.Flow import kotlinx.serialization.json.Json +import java.io.ByteArrayInputStream class OfflineNotesRepository( private val notesDao: NoteDao, - private val context: Context + private val context: Context, + private val customBrushDao: CustomBrushDao, + private val textureStore: CahierTextureBitmapStore, ) : NotesRepository { private val converters = Converters() @@ -44,12 +52,39 @@ class OfflineNotesRepository( override suspend fun updateNote(note: Note) = notesDao.updateNote(note) + private suspend fun getAllCustomBrushes(): List { + val builtIn = CustomBrushes.getBrushes(context, textureStore) + val dbEntities = customBrushDao.getAllCustomBrushesSync() + + val dbBrushes = dbEntities.mapNotNull { entity -> + try { + ByteArrayInputStream(entity.brushBytes).use { inputStream -> + val family = AndroidBrushFamilySerialization.decode( + inputStream, + maxVersion = Version.DEVELOPMENT, + BrushFamilyDecodeCallback { id, bitmap -> + if (bitmap != null && textureStore[id] == null) textureStore.loadTexture( + id, + bitmap + ) + id + } + ) + CustomBrush(entity.name, com.example.cahier.R.drawable.edit_24px, family, true) + } + } catch (e: Exception) { + null + } + } + return builtIn + dbBrushes + } + override suspend fun updateNoteStrokes( noteId: Long, strokes: List, - clientBrushFamilyId: String? + clientBrushFamilyId: String?, ) { - val customBrushes = CustomBrushes.getBrushes(context) + val customBrushes = getAllCustomBrushes() val strokesData = strokes.map { converters.serializeStroke(it, customBrushes) } val strokesJson = Json.encodeToString(strokesData) @@ -64,7 +99,7 @@ class OfflineNotesRepository( override suspend fun getNoteStrokes(noteId: Long): List { val note = notesDao.getNoteById(noteId) val strokesJson = note?.strokesData ?: return emptyList() - val customBrushes = CustomBrushes.getBrushes(context) + val customBrushes = getAllCustomBrushes() val strokesData = Json.decodeFromString>(strokesJson) return strokesData.mapNotNull { converters.deserializeStrokeFromString(it, customBrushes) } diff --git a/app/src/main/java/com/example/cahier/core/di/AppModule.kt b/app/src/main/java/com/example/cahier/core/di/AppModule.kt index 5c735f31..6ffd8612 100644 --- a/app/src/main/java/com/example/cahier/core/di/AppModule.kt +++ b/app/src/main/java/com/example/cahier/core/di/AppModule.kt @@ -26,19 +26,37 @@ import com.example.cahier.core.data.MIGRATION_8_9 import com.example.cahier.core.data.NoteDatabase import com.example.cahier.core.data.NotesRepository import com.example.cahier.core.data.OfflineNotesRepository +import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.core.utils.FileHelper import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import com.example.cahier.developer.brushgraph.data.BrushGraphRepository +import com.example.cahier.developer.brushgraph.data.DefaultBrushGraphRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier import javax.inject.Singleton +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope + @Module @InstallIn(SingletonComponent::class) object AppModule { + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope { + return CoroutineScope(SupervisorJob() + Dispatchers.Default) + } + @Provides @Singleton fun provideNoteDatabase(@ApplicationContext context: Context): NoteDatabase { @@ -61,9 +79,11 @@ object AppModule { @Singleton fun provideNoteRepository( database: NoteDatabase, - @ApplicationContext context: Context + @ApplicationContext context: Context, + customBrushDao: CustomBrushDao, + textureStore: CahierTextureBitmapStore, ): NotesRepository { - return OfflineNotesRepository(database.noteDao(), context) + return OfflineNotesRepository(database.noteDao(), context, customBrushDao, textureStore) } @Provides @@ -77,4 +97,12 @@ object AppModule { fun provideFileHelper(@ApplicationContext context: Context): FileHelper { return FileHelper(context) } + + @Provides + @Singleton + fun provideBrushGraphRepository( + impl: DefaultBrushGraphRepository, + ): BrushGraphRepository { + return impl + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt b/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt index 1bfe61a5..05eec5f6 100644 --- a/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt +++ b/app/src/main/java/com/example/cahier/core/navigation/CahierNavGraph.kt @@ -24,17 +24,20 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument +import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.developer.brushdesigner.ui.BrushDesignerScreen import com.example.cahier.features.drawing.DrawingCanvas import com.example.cahier.features.home.HomeDestination import com.example.cahier.features.home.HomePane import com.example.cahier.features.text.TextNoteCanvasScreen +import com.example.cahier.developer.brushgraph.ui.BrushGraphScreen @OptIn(ExperimentalComposeApi::class) @Composable fun CahierNavHost( navController: NavHostController, + textureStore: CahierTextureBitmapStore, modifier: Modifier = Modifier ) { NavHost( @@ -56,6 +59,9 @@ fun CahierNavHost( navigateToBrushDesigner = { navController.navigate(BrushDesignerDestination.route) }, + navigateToBrushGraph = { + navController.navigate(BrushGraphDestination.route) + }, ) } @@ -77,10 +83,16 @@ fun CahierNavHost( ) { navBackStackEntry -> DrawingCanvas( navigateUp = { navController.navigateUp() }, + navigateToBrushGraph = { navController.navigate(BrushGraphDestination.route) } ) } composable(route = BrushDesignerDestination.route) { BrushDesignerScreen( + onNavigateUp = { navController.navigateUp() }, + ) + } + composable(route = BrushGraphDestination.route) { + BrushGraphScreen( onNavigateUp = { navController.navigateUp() } ) } @@ -104,3 +116,7 @@ object DrawingCanvasDestination : NavigationDestination { object BrushDesignerDestination : NavigationDestination { override val route = "brush_designer" } + +object BrushGraphDestination : NavigationDestination { + override val route = "brush_graph" +} \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/core/ui/CahierTextureBitmapStore.kt b/app/src/main/java/com/example/cahier/core/ui/CahierTextureBitmapStore.kt index 998fc362..1f9c1eab 100644 --- a/app/src/main/java/com/example/cahier/core/ui/CahierTextureBitmapStore.kt +++ b/app/src/main/java/com/example/cahier/core/ui/CahierTextureBitmapStore.kt @@ -24,9 +24,17 @@ import androidx.annotation.DrawableRes import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.ink.brush.TextureBitmapStore import com.example.cahier.R +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton @OptIn(ExperimentalInkCustomBrushApi::class) -class CahierTextureBitmapStore(context: Context) : TextureBitmapStore { +@Singleton +class CahierTextureBitmapStore @Inject constructor(@ApplicationContext context: Context) : + TextureBitmapStore { private val resources = context.resources private val textureResources: Map = mapOf( @@ -36,6 +44,9 @@ class CahierTextureBitmapStore(context: Context) : TextureBitmapStore { private val loadedBitmaps = mutableMapOf() + private val _generation = MutableStateFlow(0) + val generation = _generation.asStateFlow() + override operator fun get(clientTextureId: String): Bitmap? { val id = getShortName(clientTextureId) return loadedBitmaps.getOrPut(id) { @@ -43,6 +54,11 @@ class CahierTextureBitmapStore(context: Context) : TextureBitmapStore { } } + /** Returns all available texture IDs. */ + fun getAllIds(): Set { + return textureResources.keys + loadedBitmaps.keys + } + private fun getShortName(clientTextureId: String): String = clientTextureId.removePrefix("ink://ink").removePrefix("/texture:") @@ -54,5 +70,6 @@ class CahierTextureBitmapStore(context: Context) : TextureBitmapStore { fun loadTexture(textureId: String, bitmap: Bitmap) { val id = getShortName(textureId) loadedBitmaps[id] = bitmap + _generation.update { it + 1 } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/core/ui/ColorPickerDialog.kt b/app/src/main/java/com/example/cahier/core/ui/ColorPickerDialog.kt index 801c1be1..50c6fc32 100644 --- a/app/src/main/java/com/example/cahier/core/ui/ColorPickerDialog.kt +++ b/app/src/main/java/com/example/cahier/core/ui/ColorPickerDialog.kt @@ -147,4 +147,5 @@ private fun ColorPickerContentPreview() { onColorSelected = {}, onDismissRequest = {} ) -} \ No newline at end of file +} + 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 be9355d0..6c32eca2 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 @@ -26,7 +26,9 @@ import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState 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 @@ -42,7 +44,6 @@ import androidx.core.graphics.withSave import androidx.ink.authoring.compose.InProgressStrokes import androidx.ink.brush.Brush import androidx.ink.brush.StockBrushes -import androidx.ink.brush.TextureBitmapStore import androidx.ink.brush.compose.createWithComposeColor import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer import androidx.ink.strokes.Stroke @@ -54,7 +55,6 @@ import com.example.cahier.core.utils.pointerInputWithSiblingFallthrough fun DrawingSurface( strokes: List, canvasStrokeRenderer: CanvasStrokeRenderer, - textureStore: TextureBitmapStore? = null, onStrokesFinished: (List) -> Unit, onErase: (offsetX: Float, offsetY: Float) -> Unit, onEraseStart: () -> Unit, @@ -64,8 +64,9 @@ fun DrawingSurface( isEraserMode: Boolean, backgroundImageUri: String?, onStartDrag: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { + val textureStore = LocalTextureStore.current Box(modifier = modifier) { backgroundImageUri?.let { AsyncImage( @@ -132,26 +133,26 @@ fun DrawingSurface( } ) } else { - textureStore?.let { + val cacheGen by textureStore.generation.collectAsState() + key(cacheGen) { InProgressStrokes( defaultBrush = currentBrush, nextBrush = onGetNextBrush, onStrokesFinished = onStrokesFinished, - textureBitmapStore = it + textureBitmapStore = textureStore ) - } ?: InProgressStrokes( - defaultBrush = currentBrush, - nextBrush = onGetNextBrush, - onStrokesFinished = onStrokesFinished, - ) + } } + } } @Preview @Composable fun DrawingSurfacePreview() { - val canvasStrokeRenderer = remember { CanvasStrokeRenderer.create() } + val textureStore = LocalTextureStore.current + val cacheGen by textureStore.generation.collectAsState() + val canvasStrokeRenderer = remember(cacheGen) { CanvasStrokeRenderer.create(textureStore) } var currentBrush by remember { mutableStateOf( Brush.createWithComposeColor( diff --git a/app/src/main/java/com/example/cahier/core/ui/LocalTextureStore.kt b/app/src/main/java/com/example/cahier/core/ui/LocalTextureStore.kt new file mode 100644 index 00000000..7c5e004e --- /dev/null +++ b/app/src/main/java/com/example/cahier/core/ui/LocalTextureStore.kt @@ -0,0 +1,23 @@ +/* + * 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.core.ui + +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalTextureStore = staticCompositionLocalOf { + error("No TextureStore provided") +} diff --git a/app/src/main/java/com/example/cahier/core/ui/theme/Color.kt b/app/src/main/java/com/example/cahier/core/ui/theme/Color.kt index 16a6fe41..bad2080a 100644 --- a/app/src/main/java/com/example/cahier/core/ui/theme/Color.kt +++ b/app/src/main/java/com/example/cahier/core/ui/theme/Color.kt @@ -26,6 +26,18 @@ val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) val Pink40 = Color(0xFF7D5260) +// Warning Colors - Light Mode +val LightWarning = Color(0xFFF57C00) +val LightOnWarning = Color(0xFFFFFFFF) +val LightWarningContainer = Color(0xFFFFF3E0) +val LightOnWarningContainer = Color(0xFFE65100) + +// Warning Colors - Dark Mode +val DarkWarning = Color(0xFFFFB74D) +val DarkOnWarning = Color(0xFF4E342E) +val DarkWarningContainer = Color(0xFFE65100) +val DarkOnWarningContainer = Color(0xFFFFCC80) + // Brush Designer: color picker presets val BrushBlack = Color(0xFF000000) val BrushRed = Color(0xFFFF0000) diff --git a/app/src/main/java/com/example/cahier/core/ui/theme/Theme.kt b/app/src/main/java/com/example/cahier/core/ui/theme/Theme.kt index 116cdaec..8356dbb6 100644 --- a/app/src/main/java/com/example/cahier/core/ui/theme/Theme.kt +++ b/app/src/main/java/com/example/cahier/core/ui/theme/Theme.kt @@ -27,11 +27,51 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +@Immutable +data class ExtendedColorScheme( + val warning: Color, + val onWarning: Color, + val warningContainer: Color, + val onWarningContainer: Color, +) + +val LocalExtendedColorScheme = staticCompositionLocalOf { + ExtendedColorScheme( + warning = Color.Unspecified, + onWarning = Color.Unspecified, + warningContainer = Color.Unspecified, + onWarningContainer = Color.Unspecified, + ) +} + +val MaterialTheme.extendedColorScheme: ExtendedColorScheme + @Composable get() = LocalExtendedColorScheme.current + +private val LightExtendedColorScheme = + ExtendedColorScheme( + warning = LightWarning, + onWarning = LightOnWarning, + warningContainer = LightWarningContainer, + onWarningContainer = LightOnWarningContainer, + ) + +private val DarkExtendedColorScheme = + ExtendedColorScheme( + warning = DarkWarning, + onWarning = DarkOnWarning, + warningContainer = DarkWarningContainer, + onWarningContainer = DarkOnWarningContainer, + ) + private val DarkColorScheme = darkColorScheme( primary = Purple80, secondary = PurpleGrey80, @@ -59,6 +99,9 @@ fun CahierAppTheme( darkTheme -> DarkColorScheme else -> LightColorScheme } + + val extendedColorScheme = if (darkTheme) DarkExtendedColorScheme else LightExtendedColorScheme + val view = LocalView.current if (!view.isInEditMode) { SideEffect { @@ -67,9 +110,11 @@ fun CahierAppTheme( } } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + CompositionLocalProvider(LocalExtendedColorScheme provides extendedColorScheme) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/data/CustomBrushDao.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/data/CustomBrushDao.kt index 303b5faf..c34a9de9 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/data/CustomBrushDao.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/data/CustomBrushDao.kt @@ -24,10 +24,18 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow +public const val AUTOSAVE_KEY = "__autosave__" + @Dao interface CustomBrushDao { - @Query("SELECT * FROM custom_brushes") - fun getAllCustomBrushes(): Flow> + @Query("SELECT * FROM custom_brushes WHERE name != :autosaveKey") + fun getAllCustomBrushes(autosaveKey: String = AUTOSAVE_KEY): Flow> + + @Query("SELECT * FROM custom_brushes WHERE name != :autosaveKey") + suspend fun getAllCustomBrushesSync(autosaveKey: String = AUTOSAVE_KEY): List + + @Query("SELECT * FROM custom_brushes WHERE name = :autosaveKey") + fun getAutoSaveBrush(autosaveKey: String = AUTOSAVE_KEY): Flow @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveCustomBrush(brush: CustomBrushEntity) diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerComponents.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerComponents.kt index d7c8ec72..b9bd0bf7 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerComponents.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerComponents.kt @@ -24,7 +24,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -51,7 +56,7 @@ internal fun BrushSliderControl( label: String, value: Float, valueRange: ClosedFloatingPointRange, - onValueChange: (Float) -> Unit + onValueChange: (Float) -> Unit, ) { Column(modifier = Modifier.padding(vertical = 8.dp)) { Row( @@ -59,7 +64,10 @@ internal fun BrushSliderControl( horizontalArrangement = Arrangement.SpaceBetween ) { Text(text = label, style = MaterialTheme.typography.bodyMedium) - Text(text = String.format(java.util.Locale.US, "%.2f", value), style = MaterialTheme.typography.bodySmall) + Text( + text = String.format(java.util.Locale.US, "%.2f", value), + style = MaterialTheme.typography.bodySmall + ) } Slider( value = value, @@ -76,7 +84,7 @@ internal fun BrushSliderControl( fun CustomColorPickerDialog( initialColor: Color, onColorSelected: (Color) -> Unit, - onDismissRequest: () -> Unit + onDismissRequest: () -> Unit, ) { var currentColor by remember { mutableStateOf(HsvColor.from(initialColor)) } @@ -99,7 +107,8 @@ fun CustomColorPickerDialog( ) Spacer(modifier = Modifier.height(16.dp)) - val hexString = String.format(java.util.Locale.US, "%08x", currentColor.toColor().toArgb()) + val hexString = + String.format(java.util.Locale.US, "%08x", currentColor.toColor().toArgb()) Text( text = hexString, style = MaterialTheme.typography.bodyMedium, @@ -124,3 +133,52 @@ fun CustomColorPickerDialog( } ) } + +/** + * A generic [ExposedDropdownMenuBox] for selecting from enum-like value lists. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EnumDropdown( + label: String, + currentValue: T, + values: List, + displayName: @Composable (T) -> String, + onSelected: (T) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier + ) { + OutlinedTextField( + value = displayName(currentValue), + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + values.forEach { value -> + DropdownMenuItem( + text = { Text(displayName(value)) }, + onClick = { + onSelected(value) + expanded = false + } + ) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt index bd906e3e..7fcca08a 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt @@ -191,7 +191,6 @@ fun BrushDesignerScreen( strokes = testStrokes, brushColor = brushColor, brushSize = brushSize, - onSetTextureStore = { viewModel.setTextureStore(it) }, onReplaceStrokes = { viewModel.replaceStrokes(it) }, onStrokesFinished = { viewModel.onStrokesFinished(it) }, onGetNextBrush = { @@ -240,7 +239,6 @@ fun BrushDesignerScreen( strokes = testStrokes, brushColor = brushColor, brushSize = brushSize, - onSetTextureStore = { viewModel.setTextureStore(it) }, onReplaceStrokes = { viewModel.replaceStrokes(it) }, onStrokesFinished = { viewModel.onStrokesFinished(it) }, onGetNextBrush = { @@ -275,7 +273,7 @@ private fun ControlsPane( modifier: Modifier = Modifier, activeProto: ink.proto.BrushFamily, selectedCoatIndex: Int, - viewModel: BrushDesignerViewModel + viewModel: BrushDesignerViewModel, ) { var textFieldsLocked by remember { mutableStateOf(false) } var selectedTab by remember { mutableStateOf(BrushDesignerTab.TipShape) } @@ -331,11 +329,9 @@ private fun ControlsPane( Spacer(modifier = Modifier.height(8.dp)) MetadataSection( - clientId = activeProto.clientBrushFamilyId, developerComment = activeProto.developerComment, textFieldsLocked = textFieldsLocked, onToggleLock = { textFieldsLocked = it }, - onClientIdChange = { viewModel.updateClientBrushFamilyId(it) }, onCommentChange = { viewModel.updateDeveloperComment(it) } ) @@ -344,8 +340,10 @@ private fun ControlsPane( InputModelSection( inputModel = inputModel, onUpdateInputModelToPassthrough = { viewModel.updateInputModelToPassthrough() }, - onUpdateSlidingWindowModel = { ms, - hz -> + onUpdateSlidingWindowModel = { + ms, + hz, + -> viewModel.updateSlidingWindowModel(ms, hz) } ) @@ -366,7 +364,6 @@ private fun ControlsPane( BrushDesignerTab.TipShape -> TipShapeTabContent( currentTip = currentTip, activeBrush = viewModel.getActiveBrush(), - textureStore = viewModel.getTextureStore(), onUpdateTip = { block -> viewModel.updateTip(block) } ) @@ -395,7 +392,7 @@ internal fun CoatLayersSection( selectedCoatIndex: Int, onSelectCoat: (Int) -> Unit, onAddCoat: () -> Unit, - onDeleteCoat: () -> Unit + onDeleteCoat: () -> Unit, ) { Text( stringResource(R.string.brush_designer_brush_layers), @@ -438,12 +435,10 @@ internal fun CoatLayersSection( @Composable internal fun MetadataSection( - clientId: String, developerComment: String, textFieldsLocked: Boolean, onToggleLock: (Boolean) -> Unit, - onClientIdChange: (String) -> Unit, - onCommentChange: (String) -> Unit + onCommentChange: (String) -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -459,15 +454,6 @@ internal fun MetadataSection( ) } - OutlinedTextField( - value = clientId, - onValueChange = onClientIdChange, - label = { Text(stringResource(R.string.brush_designer_client_id)) }, - enabled = !textFieldsLocked, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - OutlinedTextField( value = developerComment, onValueChange = onCommentChange, @@ -484,7 +470,7 @@ internal fun InputModelSection( inputModel: ink.proto.BrushFamily.InputModel, onUpdateInputModelToPassthrough: () -> Unit, onUpdateSlidingWindowModel: (Long, Int) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Text( stringResource(R.string.brush_designer_input_model), @@ -551,7 +537,7 @@ internal fun InputModelSection( @Composable internal fun SlidingWindowControls( inputModel: ink.proto.BrushFamily.InputModel, - onUpdateSlidingWindowModel: (Long, Int) -> Unit + onUpdateSlidingWindowModel: (Long, Int) -> Unit, ) { val swModel = inputModel.slidingWindowModel val windowMs = @@ -594,7 +580,7 @@ internal fun TextureNameDialog( textureIdInput: String, onTextureIdChange: (String) -> Unit, onConfirm: () -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { AlertDialog( onDismissRequest = onDismiss, @@ -646,7 +632,6 @@ private fun TipShapeTabPreview() { TipShapeTabContent( currentTip = ProtoBrushTip.getDefaultInstance(), activeBrush = null, - textureStore = null, onUpdateTip = {} ) } diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt index 2136e5eb..ee9b1890 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp import androidx.ink.brush.BrushFamily import androidx.ink.brush.StockBrushes import com.example.cahier.R +import com.example.cahier.core.ui.LocalTextureStore import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity import com.example.cahier.features.drawing.CustomBrushes @@ -73,7 +74,7 @@ internal fun BrushDesignerTopBar( onClearCanvas: () -> Unit, onShowSaveDialog: () -> Unit, onImport: () -> Unit, - onExport: () -> Unit + onExport: () -> Unit, ) { TopAppBar( title = { @@ -128,11 +129,12 @@ internal fun BrushDesignerTopBar( @Composable private fun BrushLibraryMenu( - onLoadBrush: (BrushFamily) -> Unit + onLoadBrush: (BrushFamily) -> Unit, ) { + val textureStore = LocalTextureStore.current var expanded by remember { mutableStateOf(false) } val context = LocalContext.current - val cahierBrushes = remember { CustomBrushes.getBrushes(context) } + val cahierBrushes = remember { CustomBrushes.getBrushes(context, textureStore) } val stockBrushes = remember { listOf( @@ -199,7 +201,7 @@ private fun BrushLibraryMenu( private fun PaletteMenu( savedBrushes: List, onLoadFromPalette: (CustomBrushEntity) -> Unit, - onDeleteFromPalette: (String) -> Unit + onDeleteFromPalette: (String) -> Unit, ) { var expanded by remember { mutableStateOf(false) } Box { @@ -250,7 +252,7 @@ private fun OverflowMenu( onShowSaveDialog: () -> Unit, onClearCanvas: () -> Unit, onImport: () -> Unit, - onExport: () -> Unit + onExport: () -> Unit, ) { var expanded by remember { mutableStateOf(false) } Box { @@ -290,7 +292,7 @@ private fun OverflowMenu( @Composable internal fun SaveToPaletteDialog( onSave: (String) -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { var brushNameInput by remember { mutableStateOf("") } diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt index 2bf4dbb1..3edc5996 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.example.cahier.R import ink.proto.BrushBehavior +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction /** * Dispatches to the correct editor based on the [BrushBehavior.Node.NodeCase]. @@ -177,7 +178,7 @@ internal fun ResponseNodeEditor( when (selected) { ResponseCurveType.Predefined -> newResponseBuilder.setPredefinedResponseCurve( - ink.proto.PredefinedEasingFunction.PREDEFINED_EASING_EASE + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE ) ResponseCurveType.CubicBezier -> newResponseBuilder.setCubicBezierResponseCurve( @@ -261,7 +262,7 @@ internal fun ResponseNodeEditor( EnumDropdown( label = stringResource(R.string.brush_designer_node_predefined_curve), currentValue = response.predefinedResponseCurve, - values = ink.proto.PredefinedEasingFunction.entries.toList(), + values = ProtoPredefinedEasingFunction.entries.toList(), displayName = { it.name.replace("PREDEFINED_EASING_FUNCTION_", "") }, diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt index 20f99b5e..60a43876 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt @@ -122,16 +122,18 @@ class NumericLimits( @OptIn(ExperimentalFoundationApi::class) @Composable internal fun NumericField( + modifier: Modifier = Modifier, title: String, value: Float, limits: NumericLimits, + onValueChangeFinished: (() -> Unit)? = null, onValueChanged: (Float) -> Unit ) { val displayValue = limits.fromRealValue(value) var showTextInput by remember { mutableStateOf(false) } var textInputValue by remember { mutableStateOf("") } - Column(modifier = Modifier.padding(vertical = 4.dp)) { + Column(modifier = modifier.padding(vertical = 4.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -191,7 +193,8 @@ internal fun NumericField( value = displayValue.coerceIn(limits.min, limits.max), onValueChange = { onValueChanged(limits.toRealValue(it)) }, valueRange = limits.min..limits.max, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + onValueChangeFinished = onValueChangeFinished ) IconButton(onClick = { diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt index c3b8d8c1..65a8c978 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt @@ -19,15 +19,12 @@ package com.example.cahier.developer.brushdesigner.ui import android.graphics.Bitmap import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox @@ -46,8 +43,10 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.example.cahier.R +import com.example.cahier.core.ui.LocalTextureStore import ink.proto.BrushFamily as ProtoBrushFamily import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.Color as ProtoColor import ink.proto.ColorFunction as ProtoColorFunction /** @@ -68,13 +67,14 @@ internal fun PaintTabContent( onUpdateSelfOverlap: (ProtoBrushPaint.SelfOverlap) -> Unit, texturePickerLauncher: androidx.activity.result.ActivityResultLauncher< androidx.activity.result.PickVisualMediaRequest>, - getTextureBitmap: (String) -> Bitmap? + getTextureBitmap: (String) -> Bitmap?, ) { val paintPrefs = activeProto.coatsList.getOrNull(selectedCoatIndex)?.paintPreferencesList ?: emptyList() - val availableTextures = activeProto.textureIdToBitmapMap.keys.toList() + val textureStore = LocalTextureStore.current + val availableTextures = remember { textureStore.getAllIds().toList() } Text( text = stringResource(R.string.brush_designer_paint_texture), @@ -135,7 +135,7 @@ private fun PaintPreferenceEditor( paint: ProtoBrushPaint, availableTextures: List, onPaintChanged: (ProtoBrushPaint) -> Unit, - getTextureBitmap: (String) -> Bitmap? + getTextureBitmap: (String) -> Bitmap?, ) { EditableListWidget( title = stringResource(R.string.brush_designer_texture_layers), @@ -226,7 +226,7 @@ private fun TextureLayerEditor( layer: ProtoBrushPaint.TextureLayer, availableTextures: List, onLayerChanged: (ProtoBrushPaint.TextureLayer) -> Unit, - getTextureBitmap: (String) -> Bitmap? + getTextureBitmap: (String) -> Bitmap?, ) { val bitmap = if (layer.clientTextureId.isNotEmpty()) { getTextureBitmap(layer.clientTextureId) @@ -355,7 +355,7 @@ private fun TextureLayerEditor( @Composable private fun ColorFunctionEditor( colorFunction: ProtoColorFunction, - onFunctionChanged: (ProtoColorFunction) -> Unit + onFunctionChanged: (ProtoColorFunction) -> Unit, ) { val functionTypes = listOf( stringResource(R.string.brush_designer_opacity_multiplier_label), @@ -380,7 +380,7 @@ private fun ColorFunctionEditor( 1 -> onFunctionChanged( ProtoColorFunction.newBuilder() .setReplaceColor( - ink.proto.Color.getDefaultInstance() + ProtoColor.getDefaultInstance() ) .build() ) @@ -419,60 +419,13 @@ private fun ColorFunctionEditor( } } -/** - * A generic [ExposedDropdownMenuBox] for selecting from enum-like value lists. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun EnumDropdown( - label: String, - currentValue: T, - values: List, - displayName: (T) -> String, - onSelected: (T) -> Unit, - modifier: Modifier = Modifier -) { - var expanded by remember { mutableStateOf(false) } - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it } - ) { - OutlinedTextField( - value = displayName(currentValue), - onValueChange = {}, - readOnly = true, - label = { Text(label) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - values.forEach { value -> - DropdownMenuItem( - text = { Text(displayName(value)) }, - onClick = { - onSelected(value) - expanded = false - } - ) - } - } - } -} @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TextureIdSelector( currentId: String, availableTextures: List, - onTextureSelected: (String) -> Unit + onTextureSelected: (String) -> Unit, ) { var expanded by remember { mutableStateOf(false) } @@ -513,7 +466,7 @@ private fun TextureIdSelector( @Composable private fun SelfOverlapSelector( currentPaint: ProtoBrushPaint, - onOverlapSelected: (ProtoBrushPaint.SelfOverlap) -> Unit + onOverlapSelected: (ProtoBrushPaint.SelfOverlap) -> Unit, ) { var overlapExpanded by remember { mutableStateOf(false) } @@ -556,7 +509,7 @@ private fun SelfOverlapSelector( private fun TextureImportSection( textureCount: Int, texturePickerLauncher: androidx.activity.result.ActivityResultLauncher< - androidx.activity.result.PickVisualMediaRequest> + androidx.activity.result.PickVisualMediaRequest>, ) { Text( text = stringResource(R.string.brush_designer_textures), diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt index 97133227..44884bd2 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -46,7 +47,6 @@ 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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -57,8 +57,8 @@ import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer import androidx.ink.strokes.Stroke import com.example.cahier.R -import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.core.ui.DrawingSurface +import com.example.cahier.core.ui.LocalTextureStore import com.example.cahier.core.ui.theme.BrushBlack import com.example.cahier.core.ui.theme.BrushBlue import com.example.cahier.core.ui.theme.BrushGreen @@ -81,21 +81,16 @@ internal fun PreviewPane( strokes: List, brushColor: Color, brushSize: Float, - onSetTextureStore: (CahierTextureBitmapStore) -> Unit, onReplaceStrokes: (List) -> Unit, onStrokesFinished: (List) -> Unit, onGetNextBrush: () -> Brush, onSetBrushColor: (Color) -> Unit, - onSetBrushSize: (Float) -> Unit + onSetBrushSize: (Float) -> Unit, ) { - val context = LocalContext.current - val textureStore = remember { CahierTextureBitmapStore(context) } + val textureStore = LocalTextureStore.current + val cacheGen by textureStore.generation.collectAsState() - LaunchedEffect(textureStore) { - onSetTextureStore(textureStore) - } - - val canvasStrokeRenderer = remember(textureStore) { + val canvasStrokeRenderer = remember(cacheGen) { CanvasStrokeRenderer.create(textureStore = textureStore) } val localStrokes = remember { mutableStateListOf() } @@ -142,7 +137,6 @@ internal fun PreviewPane( DrawingSurface( strokes = localStrokes, canvasStrokeRenderer = canvasStrokeRenderer, - textureStore = textureStore, onStrokesFinished = { newStrokes -> localStrokes.addAll(newStrokes) onStrokesFinished(newStrokes) @@ -197,7 +191,7 @@ private fun PreviewToolbar( onSizeSelected: (Float) -> Unit, onColorSelected: (Color) -> Unit, onShowCustomColorPicker: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Row( modifier = modifier @@ -226,7 +220,7 @@ private fun PreviewToolbar( @Composable private fun SizeSelector( brushSize: Float, - onSizeSelected: (Float) -> Unit + onSizeSelected: (Float) -> Unit, ) { var sizeMenuExpanded by remember { mutableStateOf(false) } Box { @@ -259,7 +253,7 @@ private fun SizeSelector( private fun ColorSelector( brushColor: Color, onColorSelected: (Color) -> Unit, - onShowCustomColorPicker: () -> Unit + onShowCustomColorPicker: () -> Unit, ) { var colorMenuExpanded by remember { mutableStateOf(false) } Box { diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt index 51a78e34..9884040c 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt @@ -16,7 +16,6 @@ package com.example.cahier.developer.brushdesigner.ui -import android.annotation.SuppressLint import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -26,6 +25,8 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -33,12 +34,11 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.unit.dp import androidx.core.graphics.withSave import androidx.ink.brush.Brush -import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.ink.brush.InputToolType import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer import androidx.ink.strokes.MutableStrokeInputBatch import androidx.ink.strokes.Stroke -import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.core.ui.LocalTextureStore /** * Renders a live stroke preview of the current brush — a Z-shaped squiggle @@ -46,20 +46,15 @@ import com.example.cahier.core.ui.CahierTextureBitmapStore * * Re-renders whenever the [brush] or [textureStore] changes. */ -@SuppressLint("RestrictedApi") -@OptIn(ExperimentalInkCustomBrushApi::class) @Composable internal fun TipPreview( brush: Brush?, - textureStore: CahierTextureBitmapStore?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { - val strokeRenderer = remember(textureStore) { - if (textureStore != null) { - CanvasStrokeRenderer.create(textureStore = textureStore) - } else { - CanvasStrokeRenderer.create() - } + val textureStore = LocalTextureStore.current + val cacheGen by textureStore.generation.collectAsState() + val strokeRenderer = remember(cacheGen) { + CanvasStrokeRenderer.create(textureStore) } val gridColor = MaterialTheme.colorScheme.outlineVariant diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt index 8b460ae9..6a74ac2c 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.unit.dp import androidx.ink.brush.Brush import androidx.ink.brush.ExperimentalInkCustomBrushApi import com.example.cahier.R -import com.example.cahier.core.ui.CahierTextureBitmapStore import ink.proto.BrushTip as ProtoBrushTip /** @@ -52,14 +51,12 @@ import ink.proto.BrushTip as ProtoBrushTip internal fun TipShapeTabContent( currentTip: ProtoBrushTip, activeBrush: Brush?, - textureStore: CahierTextureBitmapStore?, - onUpdateTip: (ProtoBrushTip.Builder.() -> Unit) -> Unit + onUpdateTip: (ProtoBrushTip.Builder.() -> Unit) -> Unit, ) { var isScaleLocked by remember { mutableStateOf(false) } TipPreview( brush = activeBrush, - textureStore = textureStore, modifier = Modifier.padding(vertical = 8.dp) ) diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt index 17a7d686..a37e19ae 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.Color import androidx.ink.brush.Brush import androidx.ink.brush.BrushFamily import androidx.ink.brush.ExperimentalInkCustomBrushApi +import androidx.ink.brush.Version import androidx.ink.brush.compose.createWithComposeColor import androidx.ink.storage.decode import androidx.ink.storage.encode @@ -69,7 +70,8 @@ import ink.proto.BrushTip as ProtoBrushTip class BrushDesignerViewModel @Inject constructor( @param:ApplicationContext private val context: Context, private val repository: BrushDesignerRepository, - private val customBrushDao: CustomBrushDao + private val textureStore: CahierTextureBitmapStore, + private val customBrushDao: CustomBrushDao, ) : ViewModel() { val activeBrushProto: StateFlow = repository.activeBrushProto @@ -89,8 +91,11 @@ class BrushDesignerViewModel @Inject constructor( GZIPOutputStream(baos).use { it.write(rawBytes) } ByteArrayInputStream(baos.toByteArray()).use { inputStream -> - BrushFamily.decode(inputStream) { textureId, bitmap -> - bitmap?.let { textureStore?.loadTexture(textureId, it) } + BrushFamily.decode( + inputStream, + maxVersion = Version.DEVELOPMENT + ) { textureId, bitmap -> + bitmap?.let { textureStore.loadTexture(textureId, it) } textureId } } @@ -129,8 +134,6 @@ class BrushDesignerViewModel @Inject constructor( private val autoSaveFile = File(context.cacheDir, "autosave.brush") - private var textureStore: CahierTextureBitmapStore? = null - init { if (autoSaveFile.exists()) { loadBrushFromFile(Uri.fromFile(autoSaveFile)) @@ -219,7 +222,7 @@ class BrushDesignerViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { try { val baos = ByteArrayOutputStream() - stockBrush.encode(baos, textureStore ?: CahierTextureBitmapStore(context)) + stockBrush.encode(baos, textureStore) val gzippedBytes = baos.toByteArray() ByteArrayInputStream(gzippedBytes).use { inputStream -> @@ -236,12 +239,6 @@ class BrushDesignerViewModel @Inject constructor( } } - fun updateClientBrushFamilyId(id: String) { - val builder = repository.activeBrushProto.value.toBuilder() - builder.clientBrushFamilyId = id - repository.updateActiveBrushProto(builder.build()) - } - fun updateDeveloperComment(comment: String) { val builder = repository.activeBrushProto.value.toBuilder() builder.developerComment = comment @@ -377,7 +374,7 @@ class BrushDesignerViewModel @Inject constructor( BitmapFactory.decodeStream(it) } ?: return@launch - textureStore?.loadTexture(textureId, bitmap) + textureStore.loadTexture(textureId, bitmap) val builder = repository.activeBrushProto.value.toBuilder() @@ -486,26 +483,7 @@ class BrushDesignerViewModel @Inject constructor( } /** Returns the loaded bitmap for a texture ID, or null if not loaded. */ - fun getTextureBitmap(textureId: String): Bitmap? = textureStore?.get(textureId) - - /** Returns the current texture store, or null if not yet set. */ - internal fun getTextureStore(): CahierTextureBitmapStore? = textureStore - - fun setTextureStore(store: CahierTextureBitmapStore) { - this.textureStore = store - // Immediately populate the store from any textures already in the proto, - // since previewBrushFamily's decode callback has a 150ms debounce. - val bitmapMap = repository.activeBrushProto.value.textureIdToBitmapMap - bitmapMap.forEach { (id, byteString) -> - if (store.get(id) == null) { - try { - val bytes = byteString.toByteArray() - BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - ?.let { store.loadTexture(id, it) } - } catch (_: Exception) { /* logged during decode */ } - } - } - } + fun getTextureBitmap(textureId: String): Bitmap? = textureStore.get(textureId) fun setSelectedCoat(index: Int) { _selectedCoatIndex.value = index diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt new file mode 100644 index 00000000..825d23e5 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt @@ -0,0 +1,460 @@ +/* + * 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.data + +import androidx.ink.brush.BrushFamily +import com.example.cahier.R +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushCoat as ProtoBrushCoat +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.BrushTip as ProtoBrushTip +import ink.proto.ColorFunction as ProtoColorFunction + +private class ConversionContext( + val graph: BrushGraph, + val nodesById: Map, + val edgesByToNode: Map>, + val behaviorCache: MutableMap>> = mutableMapOf(), +) + +/** Utility to convert a [BrushGraph] data model into a functional [BrushFamily] object. */ +object BrushFamilyConverter { + + /** + * Converts a [BrushGraph] into a [BrushFamily]. + * + * @throws IllegalStateException if the graph is invalid. + */ + fun convert(graph: BrushGraph): BrushFamily { + return convertIntoProto(graph).toBrushFamily() + } + + /** Converts a [BrushGraph] into a [ProtoBrushFamily]. */ + fun convertIntoProto(graph: BrushGraph): ProtoBrushFamily { + val issues = GraphValidator.validateAll(graph) + val criticalErrors = issues.filter { it.severity == ValidationSeverity.ERROR } + if (criticalErrors.isNotEmpty()) { + throw criticalErrors.first() + } + + val nodesById = graph.nodes.associateBy { it.id } + val edgesByToNode = graph.edges.filter { !it.isDisabled }.groupBy { it.toNodeId } + val context = ConversionContext(graph, nodesById, edgesByToNode) + + val familyNode = graph.nodes.first { it.data is NodeData.Family } + val familyData = familyNode.data as NodeData.Family + + val coatEdges = context.edgesByToNode[familyNode.id] ?: emptyList() + val sortedCoatEdges = familyData.coatPortIds.mapNotNull { portId -> + coatEdges.find { it.toPortId == portId } + } + if (sortedCoatEdges.isEmpty()) { + throw GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_family_no_coat), + nodeId = familyNode.id, + ) + } + + val coats = sortedCoatEdges.mapNotNull { edge -> + val coatNode = context.nodesById[edge.fromNodeId] + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_coat_node_not_found, + listOf(edge.fromNodeId) + ) + ) + if (coatNode.isDisabled) null + else createCoat(coatNode, context) + } + + return ProtoBrushFamily.newBuilder() + .addAllCoats(coats) + .setInputModel(familyData.inputModel) + .setClientBrushFamilyId(familyData.clientBrushFamilyId) + .setDeveloperComment(familyData.developerComment) + .build() + } + + fun createCoat( + coatNode: GraphNode, + graph: BrushGraph, + behaviorCache: MutableMap>> = mutableMapOf(), + ): ProtoBrushCoat { + val nodesById = graph.nodes.associateBy { it.id } + val edgesByToNode = graph.edges.filter { !it.isDisabled }.groupBy { it.toNodeId } + val context = ConversionContext(graph, nodesById, edgesByToNode, behaviorCache) + return createCoat(coatNode, context) + } + + private fun createCoat( + coatNode: GraphNode, + context: ConversionContext, + ): ProtoBrushCoat { + val inputs = context.edgesByToNode[coatNode.id] ?: emptyList() + val coatData = coatNode.data as NodeData.Coat + + val tipEdge = + inputs.find { it.toPortId == coatData.tipPortId } + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_coat_missing_tip_input, + listOf(coatNode.id) + ), + nodeId = coatNode.id, + ) + + val paintEdges = coatData.paintPortIds.mapNotNull { portId -> + inputs.find { it.toPortId == portId } + } + if (paintEdges.isEmpty()) { + throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_coat_missing_paint_input, + listOf(coatNode.id) + ), + nodeId = coatNode.id, + ) + } + + val tip = createTip(tipEdge.fromNodeId, context, mutableSetOf()) + + val builder = ProtoBrushCoat.newBuilder() + .setTip(tip) + + for (edge in paintEdges) { + val paint = createPaint(edge.fromNodeId, context) + builder.addPaintPreferences(paint) + } + + return builder.build() + } + + private fun createTip( + nodeId: String, + context: ConversionContext, + path: MutableSet, + ): ProtoBrushTip { + val graphNode = context.nodesById[nodeId] + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_node_not_found, + listOf(nodeId) + ) + ) + val data = + graphNode.data as? NodeData.Tip + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_expected_node_type, + listOf("Tip", graphNode.data::class.simpleName ?: "Unknown") + ), + nodeId = nodeId, + ) + + val builder = data.tip.toBuilder() + builder.clearBehaviors() + + val behaviorEdges = data.behaviorPortIds.mapNotNull { portId -> + context.edgesByToNode[nodeId]?.find { it.toPortId == portId } + } + for (edge in behaviorEdges) { + val targetNode = context.nodesById[edge.fromNodeId] + val comment = (targetNode?.data as? NodeData.Behavior)?.developerComment ?: "" + val actualSources = GraphValidator.findActualSourceNode(context.graph, edge.fromNodeId) + for (actualSourceNode in actualSources) { + val behaviorLists = collectBehaviorNodes(actualSourceNode.id, context, path) + for (nodeList in behaviorLists) { + builder.addBehaviors( + ProtoBrushBehavior.newBuilder() + .addAllNodes(nodeList) + .setDeveloperComment(comment) + .build() + ) + } + } + } + + return builder.build() + } + + private fun collectBehaviorNodes( + nodeId: String, + context: ConversionContext, + path: MutableSet, + ): List> { + if (path.contains(nodeId)) { + throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_cycle_detected, + listOf(nodeId) + ), nodeId = nodeId + ) + } + context.behaviorCache[nodeId]?.let { return it } + + val graphNode = context.nodesById[nodeId] ?: return emptyList() + val data = graphNode.data as? NodeData.Behavior ?: return emptyList() + val inputEdges = context.edgesByToNode[nodeId] ?: emptyList() + + path.add(nodeId) + val resultLists = mutableListOf>() + + fun createDefaultNode(): ProtoBrushBehavior.Node { + return ProtoBrushBehavior.Node.newBuilder() + .setSourceNode( + ProtoBrushBehavior.SourceNode.newBuilder() + .setSource(ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + ) + .build() + } + + val labels = data.inputLabels() + val nodeCase = data.node.nodeCase + + val ids = if (data.inputPortIds.isEmpty()) { + when (nodeCase) { + ink.proto.BrushBehavior.Node.NodeCase.BINARY_OP_NODE -> listOf("input_0", "input_1") + ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> listOf( + "angle_0", + "mag_0" + ) + + ink.proto.BrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf( + "Value", + "Start", + "End" + ) + + else -> if (labels.size == 1) listOf("Input") else emptyList() + } + } else data.inputPortIds + + val sortedEdges = ids.map { portId -> + inputEdges.find { it.toPortId == portId } + } + + if (nodeCase == ink.proto.BrushBehavior.Node.NodeCase.BINARY_OP_NODE) { + val setLists = mutableListOf>>() + + for (edge in sortedEdges) { + val sources = + edge?.let { GraphValidator.findActualSourceNode(context.graph, it.fromNodeId) } + ?: emptyList() + val lists = mutableListOf>() + if (sources.isEmpty()) { + lists.add(listOf(createDefaultNode())) + } else { + for (source in sources) { + lists.addAll(collectBehaviorNodes(source.id, context, path)) + } + } + setLists.add(lists) + } + + if (setLists.size >= 2) { + var currentCombinedLists = setLists[0] + + for (i in 1 until setLists.size) { + val nextLists = setLists[i] + val numInstances = maxOf(currentCombinedLists.size, nextLists.size) + val newCombinedLists = mutableListOf>() + + for (j in 0 until numInstances) { + val list1 = currentCombinedLists.getOrNull(j) ?: currentCombinedLists.last() + val list2 = nextLists.getOrNull(j) ?: nextLists.last() + + val combinedList = mutableListOf() + combinedList.addAll(list1) + combinedList.addAll(list2) + combinedList.add(data.node) + + newCombinedLists.add(combinedList) + } + currentCombinedLists = newCombinedLists + } + resultLists.addAll(currentCombinedLists) + } else { + // Fallback if less than 2 inputs + resultLists.add(listOf(data.node)) + } + } else if (labels.size > 1) { + // Multi-input behavior node (e.g. PolarTarget) + val chunkedEdges = sortedEdges.chunked(labels.size) + + for (set in chunkedEdges) { + val setLists = mutableListOf>>() + for (edge in set) { + val sources = edge?.let { + GraphValidator.findActualSourceNode( + context.graph, + it.fromNodeId + ) + } ?: emptyList() + val lists = mutableListOf>() + if (sources.isEmpty()) { + lists.add(listOf(createDefaultNode())) + } else { + for (src in sources) { + lists.addAll(collectBehaviorNodes(src.id, context, path)) + } + } + setLists.add(lists) + } + + // Parallel mapping (zip) across all inputs in the set + val numInstances = setLists.map { it.size }.maxOrNull() ?: 0 + for (j in 0 until numInstances) { + val combinedList = mutableListOf() + for (lists in setLists) { + val list = lists.getOrNull(j) ?: lists.last() + combinedList.addAll(list) + } + combinedList.add(data.node) // Add Op node at the end (post-order) + resultLists.add(combinedList) + } + } + } else { + // Single input node or Source node + if (sortedEdges.isEmpty()) { + // Source node + resultLists.add(listOf(data.node)) + } else { + for (edge in sortedEdges) { + val sources = edge?.let { + GraphValidator.findActualSourceNode( + context.graph, + it.fromNodeId + ) + } ?: emptyList() + if (sources.isNotEmpty()) { + for (source in sources) { + val childLists = collectBehaviorNodes(source.id, context, path) + for (childList in childLists) { + val newList = mutableListOf() + newList.addAll(childList) + newList.add(data.node) // Add current node at the end + resultLists.add(newList) + } + } + } else { + // Pass-through or invalid source + resultLists.add(listOf(data.node)) + } + } + } + } + + path.remove(nodeId) + context.behaviorCache[nodeId] = resultLists + return resultLists + } + + private fun createPaint(nodeId: String, context: ConversionContext): ProtoBrushPaint { + val graphNode = context.nodesById[nodeId] + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_node_not_found, + listOf(nodeId) + ) + ) + val data = + graphNode.data as? NodeData.Paint + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_expected_node_type, + listOf("Paint", graphNode.data::class.simpleName ?: "Unknown") + ), + nodeId = nodeId, + ) + + val textureEdges = data.texturePortIds.mapNotNull { portId -> + context.edgesByToNode[nodeId]?.find { edge -> + if (edge.toPortId != portId) return@find false + val fromNode = context.nodesById[edge.fromNodeId] + fromNode != null && !fromNode.isDisabled && fromNode.data is NodeData.TextureLayer + } + } + + val colorEdges = data.colorPortIds.mapNotNull { portId -> + context.edgesByToNode[nodeId]?.find { edge -> + if (edge.toPortId != portId) return@find false + val fromNode = context.nodesById[edge.fromNodeId] + fromNode != null && !fromNode.isDisabled && fromNode.data is NodeData.ColorFunction + } + } + + val builder = data.paint.toBuilder() + builder.clearTextureLayers() + builder.clearColorFunctions() + + for (edge in textureEdges) { + builder.addTextureLayers(createTextureLayer(edge.fromNodeId, context)) + } + for (edge in colorEdges) { + builder.addColorFunctions(createColorFunction(edge.fromNodeId, context)) + } + + return builder.build() + } + + private fun createTextureLayer( + nodeId: String, + context: ConversionContext, + ): ProtoBrushPaint.TextureLayer { + val graphNode = context.nodesById[nodeId] + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_node_not_found, + listOf(nodeId) + ) + ) + val data = + graphNode.data as? NodeData.TextureLayer + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_expected_node_type, + listOf("TextureLayer", graphNode.data::class.simpleName ?: "Unknown") + ), + nodeId = nodeId, + ) + return data.layer + } + + private fun createColorFunction( + nodeId: String, + context: ConversionContext, + ): ProtoColorFunction { + val graphNode = context.nodesById[nodeId] + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_node_not_found, + listOf(nodeId) + ) + ) + val data = + graphNode.data as? NodeData.ColorFunction + ?: throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_expected_node_type, + listOf("ColorFunction", graphNode.data::class.simpleName ?: "Unknown") + ), + nodeId = nodeId, + ) + return data.function + } + +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt new file mode 100644 index 00000000..28669395 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt @@ -0,0 +1,454 @@ +/* + * 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.data + +import androidx.ink.brush.BrushFamily +import androidx.ink.storage.encode +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.zip.GZIPInputStream +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.BrushTip as ProtoBrushTip +import ink.proto.ColorFunction as ProtoColorFunction + +/** Utility to convert a functional [BrushFamily] into a [BrushGraph] data model. */ +object BrushGraphConverter { + + /** Converts a [BrushFamily] into a [BrushGraph]. */ + fun fromBrushFamily(family: BrushFamily): BrushGraph { + val baos = ByteArrayOutputStream() + family.encode(baos) + val compressedBytes = baos.toByteArray() + val bais = ByteArrayInputStream(compressedBytes) + val proto = GZIPInputStream(bais).use { ProtoBrushFamily.parseFrom(it) } + return fromProtoBrushFamily(proto) + } + + /** Converts a [ProtoBrushFamily] into a [BrushGraph]. */ + fun fromProtoBrushFamily(family: ProtoBrushFamily): BrushGraph { + val nodes = mutableListOf() + val edges = mutableListOf() + + val familyNodeId = UUID.randomUUID().toString() + val coatPortIds = (0 until family.coatsCount).map { UUID.randomUUID().toString() } + val familyData = + NodeData.Family( + clientBrushFamilyId = family.clientBrushFamilyId, + developerComment = family.developerComment, + inputModel = family.inputModel, + coatPortIds = coatPortIds, + ) + nodes.add(GraphNode(id = familyNodeId, data = familyData)) + + val behaviorDeduplicationMap = + mutableMapOf>, InternalNodeInfo>() + val assignedNodeIds = mutableSetOf() + val textureDeduplicationMap = mutableMapOf() + val colorDeduplicationMap = mutableMapOf() + + for (index in 0 until family.coatsCount) { + val coat = family.getCoats(index) + val coatId = UUID.randomUUID().toString() + val paintPortIds = + (0 until coat.paintPreferencesCount).map { UUID.randomUUID().toString() } + val coatData = NodeData.Coat(paintPortIds = paintPortIds) + val coatNode = GraphNode(id = coatId, data = coatData) + nodes.add(coatNode) + edges.add( + GraphEdge( + fromNodeId = coatId, + toNodeId = familyNodeId, + toPortId = coatPortIds[index] + ) + ) + + val (tipId, tipOutputPortId) = convertTip( + coat.tip, + nodes, + edges, + behaviorDeduplicationMap, + assignedNodeIds + ) + edges.add( + GraphEdge( + fromNodeId = tipId, + toNodeId = coatId, + toPortId = coatData.tipPortId + ) + ) + + var paintIndex = 0 + for (paint in coat.paintPreferencesList) { + val paintData = NodeData.Paint(paint) + val (paintId, paintOutputPortId) = convertPaint( + paint, + nodes, + edges, + textureDeduplicationMap, + colorDeduplicationMap + ) + edges.add( + GraphEdge( + fromNodeId = paintId, + toNodeId = coatId, + toPortId = paintPortIds[paintIndex++] + ) + ) + } + } + + val initialGraph = BrushGraph(nodes = nodes, edges = edges) + return deduplicateDownstream(initialGraph) + } + + private fun convertTip( + tip: ProtoBrushTip, + nodes: MutableList, + edges: MutableList, + deduplicationMap: MutableMap>, InternalNodeInfo>, + assignedNodeIds: MutableSet, + ): Pair { + val tipId = UUID.randomUUID().toString() + val usedPortIds = mutableListOf() + + for (behavior in tip.behaviorsList) { + val terminalNodes = + convertBehaviorGraph(behavior, nodes, edges, deduplicationMap, assignedNodeIds) + for ((terminalId, _) in terminalNodes) { + val alreadyConnected = + edges.any { it.toNodeId == tipId && it.fromNodeId == terminalId } + if (!alreadyConnected) { + val portId = UUID.randomUUID().toString() + edges.add( + GraphEdge( + fromNodeId = terminalId, + toNodeId = tipId, + toPortId = portId + ) + ) + usedPortIds.add(portId) + } + } + } + + val tipData = NodeData.Tip(tip, behaviorPortIds = usedPortIds) + nodes.add(GraphNode(id = tipId, data = tipData)) + + return Pair(tipId, "output") + } + + private fun convertPaint( + paint: ProtoBrushPaint, + nodes: MutableList, + edges: MutableList, + textureDeduplicationMap: MutableMap, + colorDeduplicationMap: MutableMap, + ): Pair { + val paintId = UUID.randomUUID().toString() + val texturePortIds = (0 until paint.textureLayersCount).map { UUID.randomUUID().toString() } + val colorPortIds = (0 until paint.colorFunctionsCount).map { UUID.randomUUID().toString() } + val paintData = + NodeData.Paint(paint, texturePortIds = texturePortIds, colorPortIds = colorPortIds) + nodes.add(GraphNode(id = paintId, data = paintData)) + + val tempTexturePortIds = texturePortIds + val tempColorPortIds = colorPortIds + val usedTexturePortIds = mutableListOf() + val usedColorPortIds = mutableListOf() + + var layerIndex = 0 + for (layer in paint.textureLayersList) { + val isNew = !textureDeduplicationMap.containsKey(layer) + val layerId = textureDeduplicationMap.getOrPut(layer) { UUID.randomUUID().toString() } + val layerData = NodeData.TextureLayer(layer) + + val alreadyConnected = edges.any { it.toNodeId == paintId && it.fromNodeId == layerId } + if (!alreadyConnected) { + val portId = tempTexturePortIds[layerIndex] + edges.add( + GraphEdge( + fromNodeId = layerId, + toNodeId = paintId, + toPortId = portId + ) + ) + usedTexturePortIds.add(portId) + } + + if (isNew) { + nodes.add( + GraphNode( + id = layerId, + data = layerData + ) + ) + } + layerIndex++ + } + + var colorIndex = 0 + for (cf in paint.colorFunctionsList) { + val isNew = !colorDeduplicationMap.containsKey(cf) + val cfId = colorDeduplicationMap.getOrPut(cf) { UUID.randomUUID().toString() } + val cfData = NodeData.ColorFunction(cf) + + val alreadyConnected = edges.any { it.toNodeId == paintId && it.fromNodeId == cfId } + if (!alreadyConnected) { + val portId = tempColorPortIds[colorIndex] + edges.add( + GraphEdge( + fromNodeId = cfId, + toNodeId = paintId, + toPortId = portId + ) + ) + usedColorPortIds.add(portId) + } + + if (isNew) { + nodes.add( + GraphNode( + id = cfId, + data = cfData + ) + ) + } + colorIndex++ + } + + val finalPaintData = NodeData.Paint( + paint, + texturePortIds = usedTexturePortIds, + colorPortIds = usedColorPortIds + ) + nodes.removeIf { it.id == paintId } + nodes.add(GraphNode(id = paintId, data = finalPaintData)) + + return Pair(paintId, "output") + } + + private fun convertBehaviorGraph( + behavior: ProtoBrushBehavior, + nodes: MutableList, + edges: MutableList, + deduplicationMap: MutableMap>, InternalNodeInfo>, + assignedNodeIds: MutableSet, + ): List> { + val behaviorId = UUID.randomUUID().toString() + val nodeStack = mutableListOf() + val behaviorNodes = mutableListOf() + + for (protoNode in behavior.nodesList) { + val isTarget = protoNode.nodeCase == ProtoBrushBehavior.Node.NodeCase.TARGET_NODE || + protoNode.nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE + val comment = if (isTarget) behavior.developerComment else "" + + val tempNodeData = NodeData.Behavior( + node = protoNode, + developerComment = comment, + behaviorId = behaviorId + ) + val inputCount = tempNodeData.inputLabels().size + + val children = mutableListOf() + for (i in 0 until inputCount) { + if (nodeStack.isNotEmpty()) { + children.add(0, nodeStack.removeAt(nodeStack.size - 1)) + } + } + + val childrenIds = children.map { it.id } + val key = Triple(protoNode, comment, childrenIds) + + val existingInfo = deduplicationMap[key] + if (existingInfo != null) { + behaviorNodes.add(existingInfo) + if (protoNode.nodeCase != ProtoBrushBehavior.Node.NodeCase.TARGET_NODE && + protoNode.nodeCase != ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE + ) { + nodeStack.add(existingInfo) + } + continue + } + + val nodeId = UUID.randomUUID().toString() + val inputPortIds = (0 until children.size).map { UUID.randomUUID().toString() } + val nodeData = NodeData.Behavior( + node = protoNode, + developerComment = comment, + behaviorId = behaviorId, + inputPortIds = inputPortIds + ) + + val info = InternalNodeInfo(nodeId, nodeData, children) + behaviorNodes.add(info) + + deduplicationMap[key] = info + + if (protoNode.nodeCase != ProtoBrushBehavior.Node.NodeCase.TARGET_NODE && + protoNode.nodeCase != ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE + ) { + nodeStack.add(info) + } + } + + val childIds = behaviorNodes.flatMap { it.children.map { child -> child.id } }.toSet() + val terminalNodeInfos = behaviorNodes.filter { it.id !in childIds } + + fun buildGraphNode(info: InternalNodeInfo, depth: Int) { + if (assignedNodeIds.contains(info.id)) { + return + } + + nodes.add(GraphNode(id = info.id, data = info.data)) + + info.children.forEachIndexed { index, child -> + edges.add( + GraphEdge( + fromNodeId = child.id, + toNodeId = info.id, + toPortId = info.data.inputPortIds[index] + ) + ) + } + assignedNodeIds.add(info.id) + + info.children.forEach { buildGraphNode(it, depth + 1) } + } + + for (root in terminalNodeInfos) { + buildGraphNode(root, 0) + } + + return terminalNodeInfos.map { it.id to "output" } + } + + /** + * Performs a top-down deduplication pass on behavior nodes. + * + * NOTE: This method assumes that the `nodes` list is ordered bottom-up (sources first, + * then operators, then targets) as a result of the post-order traversal during construction. + * By processing the reversed list, we achieve top-down processing in a single pass. + * If the graph construction order changes in the future, this may need to be updated + * to perform a full topological sort first. + */ + private fun deduplicateDownstream(graph: BrushGraph): BrushGraph { + val nodes = graph.nodes.toMutableList() + val edges = graph.edges.toMutableList() + val nodesById = nodes.associateBy { it.id }.toMutableMap() + + // Filter and reverse behavior nodes to process top-down + val behaviorNodes = nodes.filter { it.data is NodeData.Behavior }.reversed() + + val removedNodeIds = mutableSetOf() + val processedNodes = mutableMapOf>, GraphNode>() + + for (node in behaviorNodes) { + if (removedNodeIds.contains(node.id)) continue + + val nodeData = node.data as NodeData.Behavior + val isInterpolation = + nodeData.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE + val isBinaryOp = + nodeData.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE + if (isInterpolation || isBinaryOp) continue + + val nodeOutputSet = edges.filter { it.fromNodeId == node.id && !it.isDisabled } + .map { it.toNodeId } + .sorted() + + if (nodeOutputSet.isEmpty()) continue + + val key = Triple(nodeData.node, nodeData.developerComment, nodeOutputSet) + val existingNode = processedNodes[key] + + if (existingNode != null) { + val keptNode = existingNode + val nodeToRemove = node + + val keptData = keptNode.data as NodeData.Behavior + val newData = + keptData.copy(inputPortIds = keptData.inputPortIds + nodeData.inputPortIds) + + nodes.remove(keptNode) + val updatedKeptNode = keptNode.copy(data = newData) + nodes.add(updatedKeptNode) + nodesById[keptNode.id] = updatedKeptNode + + processedNodes[key] = updatedKeptNode + + // Redirect incoming edges + val incomingEdges = edges.filter { it.toNodeId == nodeToRemove.id } + for (edge in incomingEdges) { + edges.remove(edge) + edges.add(edge.copy(toNodeId = keptNode.id)) + } + + // Remove outgoing edges of removed node and cleanup ports! + val outgoingEdges = edges.filter { it.fromNodeId == nodeToRemove.id } + for (edge in outgoingEdges) { + val parentNode = nodesById[edge.toNodeId] + if (parentNode != null) { + val updatedParent = removePortFromNode(parentNode, edge.toPortId) + nodes.remove(parentNode) + nodes.add(updatedParent) + nodesById[parentNode.id] = updatedParent + } + } + edges.removeAll(outgoingEdges) + + nodes.remove(nodeToRemove) + nodesById.remove(nodeToRemove.id) + removedNodeIds.add(nodeToRemove.id) + } else { + processedNodes[key] = node + } + } + return BrushGraph(nodes = nodes, edges = edges) + } + + private fun removePortFromNode(node: GraphNode, portId: String): GraphNode { + val data = node.data + val newData = when (data) { + is NodeData.Behavior -> { + data.copy(inputPortIds = data.inputPortIds.filter { it != portId }) + } + + is NodeData.Tip -> { + data.copy(behaviorPortIds = data.behaviorPortIds.filter { it != portId }) + } + + is NodeData.Paint -> { + data.copy( + texturePortIds = data.texturePortIds.filter { it != portId }, + colorPortIds = data.colorPortIds.filter { it != portId } + ) + } + + else -> data + } + return node.copy(data = newData) + } + + private data class InternalNodeInfo( + val id: String, + val data: NodeData.Behavior, + val children: List, + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt new file mode 100644 index 00000000..c7ccf96e --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt @@ -0,0 +1,798 @@ +/* + * 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.data + +import android.graphics.Bitmap +import android.util.Log +import androidx.ink.brush.BrushFamily +import androidx.ink.brush.Version +import androidx.ink.storage.AndroidBrushFamilySerialization +import androidx.ink.storage.BrushFamilyDecodeCallback +import com.example.cahier.R +import com.example.cahier.core.di.ApplicationScope +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.developer.brushdesigner.data.AUTOSAVE_KEY +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction + +interface BrushGraphRepository { + val graph: StateFlow + val graphIssues: StateFlow> + + fun setGraph(newGraph: BrushGraph) + fun clearGraph() + fun getBrushFamily(): BrushFamily? + fun reorganize(): BrushFamily? + + fun postDebug(displayText: DisplayText) + fun validate(): Boolean + fun clearIssues() + + suspend fun loadAutoSaveBrush(): Boolean + fun loadBrushFamily(family: BrushFamily): Boolean + + fun createDefaultGraph(): BrushGraph + fun addNode(data: NodeData): String + fun updateNodeData(nodeId: String, newData: NodeData) + fun setNodeDisabled(nodeId: String, isDisabled: Boolean) + fun deleteNode(nodeId: String): Set + fun deleteSelectedNodes(selectedNodeIds: Set): Set + fun duplicateSelectedNodes(selectedNodeIds: Set): Map + + fun addEdge(fromNodeId: String, toNodeId: String, initialToPortId: String) + fun setEdgeDisabled(edge: GraphEdge, isDisabled: Boolean): GraphEdge + fun deleteEdge(edge: GraphEdge): Set + fun addNodeBetween(edge: GraphEdge): String? + + fun reorderPorts(nodeId: String, fromIndex: Int, toIndex: Int) +} + +@Singleton +@OptIn(FlowPreview::class) +class DefaultBrushGraphRepository @Inject constructor( + private val customBrushDao: CustomBrushDao, + val textureStore: CahierTextureBitmapStore, + @ApplicationScope private val scope: CoroutineScope, +) : BrushGraphRepository { + private val _graph = MutableStateFlow(createDefaultGraph()) + override val graph: StateFlow = _graph.asStateFlow() + + init { + scope.launch { + combine(graph, textureStore.generation) { g: BrushGraph, _: Int -> g } + .drop(1) + .debounce(1000) + .collect { graph -> + try { + val family = BrushFamilyConverter.convert(graph) + val baos = ByteArrayOutputStream() + AndroidBrushFamilySerialization.encode(family, baos, textureStore) + customBrushDao.saveCustomBrush( + com.example.cahier.developer.brushdesigner.data.CustomBrushEntity( + AUTOSAVE_KEY, + baos.toByteArray() + ) + ) + } catch (e: Exception) { + android.util.Log.e( + "DefaultBrushGraphRepository", + "Failed to auto-save brush", + e + ) + } + } + } + } + + private var _lastValidBrushFamily: BrushFamily? = null + private val _graphIssues = MutableStateFlow>(emptyList()) + override val graphIssues: StateFlow> = _graphIssues.asStateFlow() + + override fun setGraph(newGraph: BrushGraph) { + _graph.update { newGraph } + } + + override fun clearGraph() { + _graph.update { createDefaultGraph() } + validate() + postDebug(DisplayText.Resource(R.string.bg_msg_graph_cleared)) + } + + override fun postDebug(displayText: DisplayText) { + val newIssue = GraphValidationException( + displayMessage = displayText, + severity = ValidationSeverity.DEBUG + ) + _graphIssues.update { + (it + newIssue).distinctBy { issue -> + Triple( + issue.displayMessage, + issue.nodeId, + issue.severity + ) + } + } + } + + override fun validate(): Boolean { + val issues = GraphValidator.validateAll(_graph.value).toMutableList() + + val errorNodeIds = + issues.filter { it.severity == ValidationSeverity.ERROR }.mapNotNull { it.nodeId } + .toSet() + val warningNodeIds = + issues.filter { it.severity == ValidationSeverity.WARNING }.mapNotNull { it.nodeId } + .toSet() + + _graph.update { currentGraph -> + currentGraph.copy( + nodes = currentGraph.nodes.map { + it.copy( + hasError = errorNodeIds.contains(it.id), + hasWarning = warningNodeIds.contains(it.id) && !errorNodeIds.contains(it.id), + ) + } + ) + } + + _graphIssues.update { issues } + return issues.none { it.severity == ValidationSeverity.ERROR } + } + + override fun clearIssues() { + _graphIssues.value = emptyList() + } + + override suspend fun loadAutoSaveBrush(): Boolean { + val entity = customBrushDao.getAutoSaveBrush().firstOrNull() ?: return false + val decodedBytes = entity.brushBytes + return try { + val bais = ByteArrayInputStream(decodedBytes) + val family = AndroidBrushFamilySerialization.decode( + bais, + maxVersion = Version.DEVELOPMENT, + BrushFamilyDecodeCallback { id: String, bitmap: Bitmap? -> + if (bitmap != null) { + textureStore.loadTexture(id, bitmap) + } + id + } + ) + loadBrushFamily(family) + true + } catch (e: Exception) { + android.util.Log.e( + "DefaultBrushGraphRepository", + "Failed to decode auto saved brush family", + e + ) + false + } + } + + override fun getBrushFamily(): BrushFamily? { + if (!validate()) return _lastValidBrushFamily + return try { + val family = BrushFamilyConverter.convert(_graph.value) + _lastValidBrushFamily = family + family + } catch (e: Exception) { + val internalError = GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_internal_conversion, + listOf(e.message ?: e.javaClass.simpleName) + ) + ) + _graphIssues.update { currentIssues -> + (currentIssues + internalError).distinctBy { issue -> + Triple( + issue.displayMessage, + issue.nodeId, + issue.severity + ) + } + } + _lastValidBrushFamily + } + } + + override fun addNode(data: NodeData): String { + val newNode = GraphNode(id = UUID.randomUUID().toString(), data = data) + _graph.update { it.copy(nodes = it.nodes + newNode) } + validate() + return newNode.id + } + + override fun addEdge(fromNodeId: String, toNodeId: String, initialToPortId: String) { + var toPortId = initialToPortId + if (fromNodeId == toNodeId) return + + _graph.update { currentGraph -> + val nodesById = currentGraph.nodes.associateBy { it.id } + if (nodesById[fromNodeId] == null) return@update currentGraph + val toNode = nodesById[toNodeId] ?: return@update currentGraph + val existingEdge = + currentGraph.edges.find { it.toNodeId == toNodeId && it.toPortId == toPortId } + if (existingEdge != null) { + if (existingEdge.fromNodeId != fromNodeId) return@update currentGraph + if (!existingEdge.isDisabled) return@update currentGraph + } + var newGraph = currentGraph + val toData = toNode.data + val toPort = toNode.getVisiblePorts(currentGraph).find { it.id == toPortId } + when (toPort) { + is Port.AddTexture -> { + val newPortId = UUID.randomUUID().toString() + val newData = + (toData as NodeData.Paint).copy(texturePortIds = toData.texturePortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { + if (it.id == toNodeId) it.copy(data = newData) else it + }) + toPortId = newPortId + } + + is Port.AddColor -> { + val newPortId = UUID.randomUUID().toString() + val newData = + (toData as NodeData.Paint).copy(colorPortIds = toData.colorPortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { + if (it.id == toNodeId) it.copy(data = newData) else it + }) + toPortId = newPortId + } + + is Port.AddPaint -> { + val newPortId = UUID.randomUUID().toString() + val newData = + (toData as NodeData.Coat).copy(paintPortIds = toData.paintPortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { + if (it.id == toNodeId) it.copy(data = newData) else it + }) + toPortId = newPortId + } + + is Port.AddCoat -> { + val newPortId = UUID.randomUUID().toString() + val newData = + (toData as NodeData.Family).copy(coatPortIds = toData.coatPortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { + if (it.id == toNodeId) it.copy(data = newData) else it + }) + toPortId = newPortId + } + + is Port.AddBehavior -> { + val newPortId = UUID.randomUUID().toString() + val newData = + (toData as NodeData.Tip).copy(behaviorPortIds = toData.behaviorPortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { + if (it.id == toNodeId) it.copy(data = newData) else it + }) + toPortId = newPortId + } + + is Port.AddInput -> { + val data = toData as NodeData.Behavior + if (data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + val newPortId1 = UUID.randomUUID().toString() + val newPortId2 = UUID.randomUUID().toString() + val newData = data.copy( + inputPortIds = data.inputPortIds + listOf( + newPortId1, + newPortId2 + ) + ) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { + if (it.id == toNodeId) it.copy(data = newData) else it + }) + toPortId = newPortId1 + } else { + val newPortId = UUID.randomUUID().toString() + val newData = data.copy(inputPortIds = data.inputPortIds + newPortId) + newGraph = newGraph.copy(nodes = newGraph.nodes.map { + if (it.id == toNodeId) it.copy(data = newData) else it + }) + toPortId = newPortId + } + } + + else -> {} + } + val fromNode = nodesById[fromNodeId]!! + val fromPortId = if (fromNode.data.hasOutput()) "output" else return@update currentGraph + val newEdge = + GraphEdge(fromNodeId = fromNodeId, toNodeId = toNodeId, toPortId = toPortId) + newGraph.copy(edges = newGraph.edges + newEdge) + } + validate() + } + + override fun setEdgeDisabled(edge: GraphEdge, isDisabled: Boolean): GraphEdge { + val updatedEdge = edge.copy(isDisabled = isDisabled) + _graph.update { currentGraph -> + currentGraph.copy( + edges = currentGraph.edges.map { + if (it.fromNodeId == edge.fromNodeId && + it.toNodeId == edge.toNodeId && it.toPortId == edge.toPortId + ) updatedEdge else it + } + ) + } + validate() + return updatedEdge + } + + override fun deleteEdge(edge: GraphEdge): Set { + var modifiedNodeIds = emptySet() + _graph.update { currentGraph -> + val (newGraph, ids) = calculateDeleteEdge(currentGraph, edge) + modifiedNodeIds = ids + newGraph + } + validate() + return modifiedNodeIds + } + + private fun calculateDeleteEdge( + currentGraph: BrushGraph, + edge: GraphEdge, + ): Pair> { + val modifiedNodeIds = mutableSetOf() + val toNode = currentGraph.nodes.find { it.id == edge.toNodeId } + val toData = toNode?.data + + if (toData != null) { + val filteredEdges = currentGraph.edges.filter { + !(it.fromNodeId == edge.fromNodeId && + it.toNodeId == edge.toNodeId && it.toPortId == edge.toPortId) + } + val remainingEdges = filteredEdges.filter { it.toNodeId == edge.toNodeId } + + var newGraph = currentGraph.copy(edges = filteredEdges) + + when (toData) { + is NodeData.Coat -> { + if (toData.paintPortIds.contains(edge.toPortId)) { + val newData = + toData.copy(paintPortIds = toData.paintPortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + + is NodeData.Behavior -> { + val nodeCase = toData.node.nodeCase + if (nodeCase == ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + val chunkedIds = toData.inputPortIds.chunked(2) + val pair = chunkedIds.find { it.contains(edge.toPortId) } + if (pair != null && pair.size == 2) { + val hasAngle = remainingEdges.any { it.toPortId == pair[0] } + val hasMag = remainingEdges.any { it.toPortId == pair[1] } + if (!hasAngle && !hasMag) { + val newData = + toData.copy(inputPortIds = toData.inputPortIds - pair.toSet()) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { + if (it.id == edge.toNodeId) it.copy( + data = newData + ) else it + } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + } else if (nodeCase == ink.proto.BrushBehavior.Node.NodeCase.INTERPOLATION_NODE) { + // Do nothing to inputPortIds for fixed schema nodes! + } else { + if (toData.inputPortIds.contains(edge.toPortId)) { + val newData = + toData.copy(inputPortIds = toData.inputPortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { + if (it.id == edge.toNodeId) it.copy( + data = newData + ) else it + } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + } + + is NodeData.Tip -> { + if (toData.behaviorPortIds.contains(edge.toPortId)) { + val newData = + toData.copy(behaviorPortIds = toData.behaviorPortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + + is NodeData.Family -> { + if (toData.coatPortIds.contains(edge.toPortId)) { + val newData = toData.copy(coatPortIds = toData.coatPortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + + is NodeData.Paint -> { + if (toData.texturePortIds.contains(edge.toPortId)) { + val newData = + toData.copy(texturePortIds = toData.texturePortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } else if (toData.colorPortIds.contains(edge.toPortId)) { + val newData = + toData.copy(colorPortIds = toData.colorPortIds - edge.toPortId) + newGraph = newGraph.copy( + nodes = newGraph.nodes.map { if (it.id == edge.toNodeId) it.copy(data = newData) else it } + ) + modifiedNodeIds.add(edge.toNodeId) + } + } + + else -> {} + } + return Pair(newGraph, modifiedNodeIds) + } + return Pair(currentGraph, emptySet()) + } + + override fun deleteSelectedNodes(selectedNodeIds: Set): Set { + val modifiedNodeIds = mutableSetOf() + _graph.update { currentGraph -> + var g = currentGraph + val edgesLeavingSelectedSet = g.edges.filter { edge -> + selectedNodeIds.contains(edge.fromNodeId) && !selectedNodeIds.contains(edge.toNodeId) + } + + for (edge in edgesLeavingSelectedSet) { + val (newG, ids) = calculateDeleteEdge(g, edge) + g = newG + modifiedNodeIds.addAll(ids) + } + + g.copy( + edges = g.edges.filterNot { edge -> selectedNodeIds.contains(edge.toNodeId) }, + nodes = g.nodes.filterNot { node -> selectedNodeIds.contains(node.id) } + ) + } + validate() + return modifiedNodeIds + selectedNodeIds + } + + override fun updateNodeData(nodeId: String, newData: NodeData) { + _graph.update { currentGraph -> + val oldNode = currentGraph.nodes.find { it.id == nodeId } + val oldData = oldNode?.data + + val (finalNewData, finalEdges) = preserveEdgesOnTypeChange( + nodeId, + oldData, + newData, + currentGraph.edges + ) + + var newGraph = currentGraph.copy( + nodes = currentGraph.nodes.map { if (it.id == nodeId) it.copy(data = finalNewData) else it }, + edges = finalEdges + ) + + if (oldData != null) { + val updatedNode = newGraph.nodes.find { it.id == nodeId } + val visiblePortIds = + updatedNode?.getVisiblePorts(newGraph)?.map { it.id } ?: emptyList() + + newGraph = newGraph.copy( + edges = newGraph.edges.filter { edge -> + if (edge.toNodeId == nodeId) { + edge.toPortId in visiblePortIds + } else { + true + } + } + ) + } + newGraph + } + validate() + } + + override fun setNodeDisabled(nodeId: String, isDisabled: Boolean) { + _graph.update { currentGraph -> + currentGraph.copy( + nodes = currentGraph.nodes.map { if (it.id == nodeId) it.copy(isDisabled = isDisabled) else it } + ) + } + validate() + } + + override fun reorderPorts(nodeId: String, fromIndex: Int, toIndex: Int) { + val node = _graph.value.nodes.find { it.id == nodeId } ?: return + val data = node.data + + when (data) { + is NodeData.Family -> { + val newList = data.coatPortIds.toMutableList() + val item = newList.removeAt(fromIndex) + newList.add(toIndex, item) + updateNodeData(nodeId, data.copy(coatPortIds = newList)) + } + + is NodeData.Behavior -> { + if (data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + val setSize = 2 + val fromSet = fromIndex / setSize + val toSet = toIndex / setSize + if (fromSet == toSet) return + + val newList = data.inputPortIds.toMutableList() + val requiredSize = maxOf(fromSet * 2 + 2, toSet * 2 + 2) + while (newList.size < requiredSize) { + newList.add("invalid_port_${newList.size}") + } + + val temp0 = newList[fromSet * 2] + val temp1 = newList[fromSet * 2 + 1] + newList[fromSet * 2] = newList[toSet * 2] + newList[fromSet * 2 + 1] = newList[toSet * 2 + 1] + newList[toSet * 2] = temp0 + newList[toSet * 2 + 1] = temp1 + + updateNodeData(nodeId, data.copy(inputPortIds = newList)) + } else { + val newList = data.inputPortIds.toMutableList() + if (fromIndex in newList.indices && toIndex in newList.indices) { + val item = newList.removeAt(fromIndex) + newList.add(toIndex, item) + updateNodeData(nodeId, data.copy(inputPortIds = newList)) + } + } + } + + is NodeData.Paint -> { + val T = data.texturePortIds.size + + val isFromTexture = fromIndex in 0 until T + val isToTexture = toIndex in 0 until T + val isFromColor = fromIndex in (T + 1) until (T + 1 + data.colorPortIds.size) + val isToColor = toIndex in (T + 1) until (T + 1 + data.colorPortIds.size) + + if (isFromTexture && isToTexture) { + val newList = data.texturePortIds.toMutableList() + val item = newList.removeAt(fromIndex) + newList.add(toIndex, item) + updateNodeData(nodeId, data.copy(texturePortIds = newList)) + } else if (isFromColor && isToColor) { + val fromColorIndex = fromIndex - (T + 1) + val toColorIndex = toIndex - (T + 1) + val newList = data.colorPortIds.toMutableList() + val item = newList.removeAt(fromColorIndex) + newList.add(toColorIndex, item) + updateNodeData(nodeId, data.copy(colorPortIds = newList)) + } + } + + is NodeData.Tip -> { + val newList = data.behaviorPortIds.toMutableList() + val item = newList.removeAt(fromIndex) + newList.add(toIndex, item) + updateNodeData(nodeId, data.copy(behaviorPortIds = newList)) + } + + is NodeData.Coat -> { + val newList = data.paintPortIds.toMutableList() + val item = newList.removeAt(fromIndex - 1) // Tip is at index 0 + newList.add(toIndex - 1, item) + updateNodeData(nodeId, data.copy(paintPortIds = newList)) + } + + else -> {} + } + } + + override fun addNodeBetween(edge: GraphEdge): String? { + var newNodeId: String? = null + _graph.update { currentGraph -> + val fromNode = + currentGraph.nodes.find { it.id == edge.fromNodeId } ?: return@update currentGraph + val toNode = + currentGraph.nodes.find { it.id == edge.toNodeId } ?: return@update currentGraph + + if (fromNode.data !is NodeData.Behavior || toNode.data !is NodeData.Behavior) { + return@update currentGraph // Only for behavior nodes! + } + + val id = UUID.randomUUID().toString() + newNodeId = id + val newPortId = UUID.randomUUID().toString() + val newNode = GraphNode( + id = id, + data = NodeData.Behavior( + node = ink.proto.BrushBehavior.Node.newBuilder() + .setResponseNode( + ink.proto.BrushBehavior.ResponseNode.newBuilder() + .setPredefinedResponseCurve(ProtoPredefinedEasingFunction.PREDEFINED_EASING_LINEAR) + ) + .build(), + inputPortIds = listOf(newPortId) + ) + ) + + val edge1 = GraphEdge(fromNodeId = edge.fromNodeId, toNodeId = id, toPortId = newPortId) + val edge2 = + GraphEdge(fromNodeId = id, toNodeId = edge.toNodeId, toPortId = edge.toPortId) + + val newEdges = currentGraph.edges.filter { it != edge } + edge1 + edge2 + val newNodes = currentGraph.nodes + newNode + + currentGraph.copy(nodes = newNodes, edges = newEdges) + } + validate() + return newNodeId + } + + override fun reorganize(): BrushFamily? { + var family: BrushFamily? = null + var success = false + _graph.update { currentGraph -> + val clearedNodes = currentGraph.nodes.map { it.copy(hasError = false) } + val g = currentGraph.copy(nodes = clearedNodes) + + try { + val f = BrushFamilyConverter.convert(g) + family = f + success = true + BrushGraphConverter.fromBrushFamily(f) + } catch (e: Exception) { + success = false + g + } + } + validate() + if (success) { + postDebug(DisplayText.Resource(R.string.bg_msg_graph_reorganized_success)) + } else { + postDebug(DisplayText.Resource(R.string.bg_err_reorganization_failed)) + } + return family + } + + override fun loadBrushFamily(family: BrushFamily): Boolean { + return try { + _graph.update { BrushGraphConverter.fromBrushFamily(family) } + validate() + postDebug(DisplayText.Resource(R.string.bg_msg_brush_loaded_success)) + _lastValidBrushFamily = family + true + } catch (e: Exception) { + Log.e("BrushGraph", "Failed to load brush", e) + postDebug(DisplayText.Resource(R.string.bg_err_load_brush_failed)) + false + } + } + + override fun duplicateSelectedNodes(selectedNodeIds: Set): Map { + var idMap = emptyMap() + _graph.update { currentGraph -> + val nodesToDuplicate = currentGraph.nodes.filter { selectedNodeIds.contains(it.id) } + idMap = nodesToDuplicate.associate { it.id to UUID.randomUUID().toString() } + + val newNodes = nodesToDuplicate.map { node -> + node.copy( + id = idMap[node.id]!! + ) + } + + val edgesToDuplicate = currentGraph.edges.filter { edge -> + selectedNodeIds.contains(edge.fromNodeId) && selectedNodeIds.contains(edge.toNodeId) + } + + val newEdges = edgesToDuplicate.map { edge -> + edge.copy( + fromNodeId = idMap[edge.fromNodeId]!!, + toNodeId = idMap[edge.toNodeId]!! + ) + } + + currentGraph.copy( + nodes = currentGraph.nodes + newNodes, + edges = currentGraph.edges + newEdges + ) + } + validate() + return idMap + } + + override fun deleteNode(nodeId: String): Set { + val modifiedNodeIds = mutableSetOf() + val node = _graph.value.nodes.find { it.id == nodeId } ?: return modifiedNodeIds + if (node.data is NodeData.Family) { + postDebug(DisplayText.Resource(R.string.bg_err_cannot_delete_family_node)) + return modifiedNodeIds + } + + _graph.update { currentGraph -> + val edgesToRemove = + currentGraph.edges.filter { it.fromNodeId == nodeId || it.toNodeId == nodeId } + + // Remove edges going into the node being deleted. + var newGraph = + currentGraph.copy(edges = currentGraph.edges.filter { it.toNodeId != nodeId }) + + // Delete edges leaving the node being deleted via calculateDeleteEdge to trigger proper port removal in target nodes. + val edgesFromNode = edgesToRemove.filter { it.fromNodeId == nodeId } + for (edge in edgesFromNode) { + val (newG, ids) = calculateDeleteEdge(newGraph, edge) + newGraph = newG + modifiedNodeIds.addAll(ids) + } + + // Finally remove the node itself. + newGraph.copy(nodes = newGraph.nodes.filter { it.id != nodeId }) + } + + validate() + modifiedNodeIds.add(nodeId) + return modifiedNodeIds + } + + override fun createDefaultGraph(): BrushGraph { + val defaultTip = ink.proto.BrushTip.getDefaultInstance() + val defaultPaint = ink.proto.BrushPaint.getDefaultInstance() + val defaultCoat = ink.proto.BrushCoat.newBuilder() + .setTip(defaultTip) + .addPaintPreferences(defaultPaint) + .build() + val defaultProto = ink.proto.BrushFamily.newBuilder() + .setInputModel( + ink.proto.BrushFamily.InputModel.newBuilder() + .setSlidingWindowModel( + ink.proto.BrushFamily.SlidingWindowModel.newBuilder() + .setWindowSizeSeconds(0.02f) + .setExperimentalUpsamplingPeriodSeconds(0.005f) + ) + ) + .addCoats(defaultCoat) + .build() + return BrushGraphConverter.fromProtoBrushFamily(defaultProto) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayText.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayText.kt index 0d3cbe98..ea5725a3 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayText.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayText.kt @@ -1,21 +1,21 @@ /* - * * 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. + * 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.data sealed class DisplayText { - data class Resource(val resId: Int, val args: List = emptyList()) : DisplayText() - data class Literal(val text: String) : DisplayText() + data class Resource(val resId: Int, val args: List = emptyList()) : DisplayText() + data class Literal(val text: String) : DisplayText() } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayUtils.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayUtils.kt index 2fb5ef55..731d0e4a 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayUtils.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayUtils.kt @@ -1,365 +1,519 @@ /* - * * 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. + * 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.data import androidx.ink.brush.InputToolType +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.NumericLimits import ink.proto.BrushBehavior as ProtoBrushBehavior -import ink.proto.BrushPaint as ProtoBrushPaint import ink.proto.BrushFamily as ProtoBrushFamily -import com.example.cahier.R -import ink.proto.StepPosition as ProtoStepPosition +import ink.proto.BrushPaint as ProtoBrushPaint import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction -import com.example.cahier.developer.brushdesigner.ui.NumericLimits +import ink.proto.StepPosition as ProtoStepPosition fun ProtoBrushBehavior.Source.displayStringRId(): Int = - when (this) { - ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE -> R.string.bg_source_normalized_pressure - ProtoBrushBehavior.Source.SOURCE_TILT_IN_RADIANS -> R.string.bg_source_tilt - ProtoBrushBehavior.Source.SOURCE_TILT_X_IN_RADIANS -> R.string.bg_source_tilt_x - ProtoBrushBehavior.Source.SOURCE_TILT_Y_IN_RADIANS -> R.string.bg_source_tilt_y - ProtoBrushBehavior.Source.SOURCE_ORIENTATION_IN_RADIANS -> R.string.bg_source_orientation - ProtoBrushBehavior.Source.SOURCE_ORIENTATION_ABOUT_ZERO_IN_RADIANS -> R.string.bg_source_orientation_about_zero - ProtoBrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_source_speed - ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_source_velocity_x - ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_source_velocity_y - ProtoBrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS -> R.string.bg_source_direction - ProtoBrushBehavior.Source.SOURCE_DIRECTION_ABOUT_ZERO_IN_RADIANS -> R.string.bg_source_direction_about_zero - ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_X -> R.string.bg_source_normalized_direction_x - ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_Y -> R.string.bg_source_normalized_direction_y - ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_source_distance_traveled - ProtoBrushBehavior.Source.SOURCE_TIME_OF_INPUT_IN_SECONDS -> R.string.bg_source_time_of_input_s - ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> - R.string.bg_source_predicted_distance_traveled - ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS -> R.string.bg_source_predicted_time_elapsed_s - ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_source_distance_remaining - ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_SECONDS -> R.string.bg_source_time_since_input_s - ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_STROKE_END_IN_SECONDS -> R.string.bg_source_time_since_stroke_end - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> - R.string.bg_source_acceleration - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> - R.string.bg_source_acceleration_x - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> - R.string.bg_source_acceleration_y - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> - R.string.bg_source_acceleration_forward - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> - R.string.bg_source_acceleration_lateral - ProtoBrushBehavior.Source.SOURCE_SPEED_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_speed_absolute - ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_velocity_x_absolute - ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_velocity_y_absolute - ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_CENTIMETERS -> R.string.bg_source_distance_traveled_absolute - ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_CENTIMETERS -> - R.string.bg_source_predicted_distance_traveled_absolute - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_source_acceleration_absolute - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED -> - R.string.bg_source_acceleration_x_absolute - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED -> - R.string.bg_source_acceleration_y_absolute - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED -> - R.string.bg_source_acceleration_forward_absolute - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_CENTIMETERS_PER_SECOND_SQUARED -> - R.string.bg_source_acceleration_lateral_absolute - ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_AS_FRACTION_OF_STROKE_LENGTH -> - R.string.bg_source_distance_remaining_fraction - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE -> R.string.bg_source_normalized_pressure + ProtoBrushBehavior.Source.SOURCE_TILT_IN_RADIANS -> R.string.bg_source_tilt + ProtoBrushBehavior.Source.SOURCE_TILT_X_IN_RADIANS -> R.string.bg_source_tilt_x + ProtoBrushBehavior.Source.SOURCE_TILT_Y_IN_RADIANS -> R.string.bg_source_tilt_y + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_IN_RADIANS -> R.string.bg_source_orientation + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_ABOUT_ZERO_IN_RADIANS -> R.string.bg_source_orientation_about_zero + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_source_speed + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_source_velocity_x + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_source_velocity_y + ProtoBrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS -> R.string.bg_source_direction + ProtoBrushBehavior.Source.SOURCE_DIRECTION_ABOUT_ZERO_IN_RADIANS -> R.string.bg_source_direction_about_zero + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_X -> R.string.bg_source_normalized_direction_x + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_Y -> R.string.bg_source_normalized_direction_y + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_source_distance_traveled + ProtoBrushBehavior.Source.SOURCE_TIME_OF_INPUT_IN_SECONDS -> R.string.bg_source_time_of_input_s + ProtoBrushBehavior.Source.SOURCE_TIME_FROM_INPUT_TO_STROKE_END_IN_SECONDS -> R.string.bg_source_time_from_input_to_stroke_end_s + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> + R.string.bg_source_predicted_distance_traveled + + ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS -> R.string.bg_source_predicted_time_elapsed_s + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_source_distance_remaining + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_SECONDS -> R.string.bg_source_time_since_input_s + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_STROKE_END_IN_SECONDS -> R.string.bg_source_time_since_stroke_end + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_x + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_y + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_forward + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_lateral + + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_speed_absolute + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_velocity_x_absolute + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_velocity_y_absolute + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_CENTIMETERS -> R.string.bg_source_distance_traveled_absolute + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_CENTIMETERS -> + R.string.bg_source_predicted_distance_traveled_absolute + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_source_acceleration_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_x_absolute + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_y_absolute + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_forward_absolute + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_lateral_absolute + + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_AS_FRACTION_OF_STROKE_LENGTH -> + R.string.bg_source_distance_remaining_fraction + + else -> R.string.bg_node_unknown + } fun ProtoBrushBehavior.Source.getNumericLimits(): NumericLimits { - return when (this) { - ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE -> NumericLimits(0f, 1f, 0.01f) - ProtoBrushBehavior.Source.SOURCE_TILT_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(0f, 90f) - ProtoBrushBehavior.Source.SOURCE_TILT_X_IN_RADIANS, - ProtoBrushBehavior.Source.SOURCE_TILT_Y_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(-90f, 90f) - ProtoBrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS, - ProtoBrushBehavior.Source.SOURCE_ORIENTATION_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(0f, 360f) - ProtoBrushBehavior.Source.SOURCE_DIRECTION_ABOUT_ZERO_IN_RADIANS, - ProtoBrushBehavior.Source.SOURCE_ORIENTATION_ABOUT_ZERO_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(-180f, 180f) - ProtoBrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> NumericLimits(0f, 1000f, 0.01f) - ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, - ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> NumericLimits(-1000f, 1000f, 0.01f, "/s") - ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_X, - ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_Y -> NumericLimits(-1f, 1f, 0.01f) - ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE, - ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(0f, 100f, 0.01f) - ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(0f, 100f, 0.01f) - ProtoBrushBehavior.Source.SOURCE_TIME_OF_INPUT_IN_SECONDS, - ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_SECONDS, - ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_STROKE_END_IN_SECONDS, - ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS -> NumericLimits(0f, 10f, 0.001f, "s") - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> NumericLimits(0f, 100000f, 1f, "/s²") - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> NumericLimits(-100000f, 100000f, 1f, "/s²") - ProtoBrushBehavior.Source.SOURCE_SPEED_IN_CENTIMETERS_PER_SECOND -> NumericLimits(0f, 100f, 0.1f, "cm/s") - ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_CENTIMETERS_PER_SECOND, - ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND -> NumericLimits(-100f, 100f, 0.1f, "cm/s") - ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_CENTIMETERS -> NumericLimits(0f, 100f, 0.01f, "cm") - ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_CENTIMETERS -> NumericLimits(0f, 10f, 0.01f, "cm") - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED -> NumericLimits(0f, 5000f, 0.1f, "cm/s²") - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED, - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED, - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED, - ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_CENTIMETERS_PER_SECOND_SQUARED -> NumericLimits(-5000f, 5000f, 0.1f, "cm/s²") - ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_AS_FRACTION_OF_STROKE_LENGTH -> NumericLimits.floatShownAsPercent(0f, 100f) - else -> NumericLimits(-100f, 100f, 0.01f) - } + return when (this) { + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE -> NumericLimits(0f, 1f, 0.01f) + ProtoBrushBehavior.Source.SOURCE_TILT_IN_RADIANS -> NumericLimits.radiansShownAsDegrees( + 0f, + 90f + ) + + ProtoBrushBehavior.Source.SOURCE_TILT_X_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_TILT_Y_IN_RADIANS, + -> NumericLimits.radiansShownAsDegrees(-90f, 90f) + + ProtoBrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_IN_RADIANS, + -> NumericLimits.radiansShownAsDegrees(0f, 360f) + + ProtoBrushBehavior.Source.SOURCE_DIRECTION_ABOUT_ZERO_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_ABOUT_ZERO_IN_RADIANS, + -> NumericLimits.radiansShownAsDegrees(-180f, 180f) + + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> NumericLimits( + 0f, + 1000f, + 0.01f + ) + + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, + -> NumericLimits(-1000f, 1000f, 0.01f, "/s") + + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_X, + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_Y, + -> NumericLimits(-1f, 1f, 0.01f) + + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_IN_MULTIPLES_OF_BRUSH_SIZE, + -> NumericLimits(0f, 100f, 0.01f) + + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits( + 0f, + 100f, + 0.01f + ) + + ProtoBrushBehavior.Source.SOURCE_TIME_FROM_INPUT_TO_STROKE_END_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_TIME_OF_INPUT_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_STROKE_END_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS, + -> NumericLimits(0f, 10f, 0.001f, "s") + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> NumericLimits( + 0f, + 100000f, + 1f, + "/s²" + ) + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + -> NumericLimits(-100000f, 100000f, 1f, "/s²") + + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_CENTIMETERS_PER_SECOND -> NumericLimits( + 0f, + 100f, + 0.1f, + "cm/s" + ) + + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_CENTIMETERS_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND, + -> NumericLimits(-100f, 100f, 0.1f, "cm/s") + + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_CENTIMETERS -> NumericLimits( + 0f, + 100f, + 0.01f, + "cm" + ) + + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_CENTIMETERS -> NumericLimits( + 0f, + 10f, + 0.01f, + "cm" + ) + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED -> NumericLimits( + 0f, + 5000f, + 0.1f, + "cm/s²" + ) + + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_CENTIMETERS_PER_SECOND_SQUARED, + -> NumericLimits(-5000f, 5000f, 0.1f, "cm/s²") + + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_AS_FRACTION_OF_STROKE_LENGTH -> NumericLimits.floatShownAsPercent( + 0f, + 100f + ) + + else -> NumericLimits(-100f, 100f, 0.01f) + } } fun ProtoBrushBehavior.Target.getNumericLimits(): NumericLimits { - return when (this) { - ProtoBrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS, - ProtoBrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(-360f, 360f) - ProtoBrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS -> NumericLimits.radiansShownAsDegrees(-90f, 90f) - ProtoBrushBehavior.Target.TARGET_PINCH_OFFSET, - ProtoBrushBehavior.Target.TARGET_CORNER_ROUNDING_OFFSET, - ProtoBrushBehavior.Target.TARGET_LUMINOSITY_OFFSET -> NumericLimits.floatShownAsPercent(-100f, 100f) - ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER, - ProtoBrushBehavior.Target.TARGET_HEIGHT_MULTIPLIER, - ProtoBrushBehavior.Target.TARGET_SIZE_MULTIPLIER, - ProtoBrushBehavior.Target.TARGET_SATURATION_MULTIPLIER, - ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER -> NumericLimits(0f, 2f, 0.01f, "x") - ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_X_IN_MULTIPLES_OF_BRUSH_SIZE, - ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_Y_IN_MULTIPLES_OF_BRUSH_SIZE, - ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE, - ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(-10.0f, 10.0f, 0.01f) - else -> NumericLimits(-100f, 100f, 0.01f) - } + return when (this) { + ProtoBrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS, + ProtoBrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS, + -> NumericLimits.radiansShownAsDegrees(-360f, 360f) + + ProtoBrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS -> NumericLimits.radiansShownAsDegrees( + -90f, + 90f + ) + + ProtoBrushBehavior.Target.TARGET_PINCH_OFFSET, + ProtoBrushBehavior.Target.TARGET_CORNER_ROUNDING_OFFSET, + ProtoBrushBehavior.Target.TARGET_LUMINOSITY_OFFSET, + -> NumericLimits.floatShownAsPercent(-100f, 100f) + + ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_HEIGHT_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_SIZE_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_SATURATION_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER, + -> NumericLimits(0f, 2f, 0.01f, "x") + + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_X_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_Y_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE, + -> NumericLimits(-10.0f, 10.0f, 0.01f) + + else -> NumericLimits(-100f, 100f, 0.01f) + } } fun ProtoBrushBehavior.PolarTarget.getMagnitudeLimits(): NumericLimits { - return when (this) { - ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_ABSOLUTE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE, - ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_RELATIVE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE -> - NumericLimits(-10.0f, 10.0f, 0.01f) - else -> NumericLimits(0.0f, 1.0f, 0.1f) - } + return when (this) { + ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_ABSOLUTE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_RELATIVE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE, + -> + NumericLimits(-10.0f, 10.0f, 0.01f) + + else -> NumericLimits(0.0f, 1.0f, 0.1f) + } } enum class ProgressDomainContext { - DAMPING, - INTEGRAL, - NOISE + DAMPING, + INTEGRAL, + NOISE } fun ProtoBrushBehavior.ProgressDomain.getNumericLimits(context: ProgressDomainContext): NumericLimits { - return when (context) { - ProgressDomainContext.DAMPING -> when (this) { - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> NumericLimits(0f, 10f, 0.001f, "s") - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> NumericLimits(0f, 100f, 0.1f, "mm") - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(0f, 10f, 0.01f) - else -> NumericLimits(0f, 100f, 1f) - } - ProgressDomainContext.INTEGRAL -> when (this) { - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> NumericLimits(0f, 10f, 0.001f, "s ⋅ input") - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> NumericLimits(0f, 10f, 0.01f, "cm ⋅ input") - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(0f, 10f, 0.01f, "⋅ input") - else -> NumericLimits(0f, 100f, 1f) - } - ProgressDomainContext.NOISE -> when (this) { - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> NumericLimits(0f, 10f, 0.01f, "s") - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> NumericLimits(0f, 10f, 0.1f, "cm") - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits(0f, 10f, 0.01f) - else -> NumericLimits(0f, 100f, 1f) + return when (context) { + ProgressDomainContext.DAMPING -> when (this) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> NumericLimits( + 0f, + 10f, + 0.001f, + "s" + ) + + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> NumericLimits( + 0f, + 100f, + 0.1f, + "mm" + ) + + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits( + 0f, + 10f, + 0.01f + ) + + else -> NumericLimits(0f, 100f, 1f) + } + + ProgressDomainContext.INTEGRAL -> when (this) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> NumericLimits( + 0f, + 10f, + 0.001f, + "s ⋅ input" + ) + + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> NumericLimits( + 0f, + 10f, + 0.01f, + "cm ⋅ input" + ) + + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits( + 0f, + 10f, + 0.01f, + "⋅ input" + ) + + else -> NumericLimits(0f, 100f, 1f) + } + + ProgressDomainContext.NOISE -> when (this) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> NumericLimits( + 0f, + 10f, + 0.01f, + "s" + ) + + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> NumericLimits( + 0f, + 10f, + 0.1f, + "cm" + ) + + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> NumericLimits( + 0f, + 10f, + 0.01f + ) + + else -> NumericLimits(0f, 100f, 1f) + } } - } } fun ProtoBrushBehavior.Target.displayStringRId(): Int = - when (this) { - ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER -> R.string.bg_target_width_multiplier - ProtoBrushBehavior.Target.TARGET_HEIGHT_MULTIPLIER -> R.string.bg_target_height_multiplier - ProtoBrushBehavior.Target.TARGET_SIZE_MULTIPLIER -> R.string.bg_target_size_multiplier - ProtoBrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS -> R.string.bg_target_slant_offset - ProtoBrushBehavior.Target.TARGET_PINCH_OFFSET -> R.string.bg_target_pinch_offset - ProtoBrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS -> R.string.bg_target_rotation_offset - ProtoBrushBehavior.Target.TARGET_CORNER_ROUNDING_OFFSET -> R.string.bg_target_corner_rounding_offset - ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_X_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_target_position_offset_x - ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_Y_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_target_position_offset_y - ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE -> - R.string.bg_target_position_offset_forward - ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE -> - R.string.bg_target_position_offset_lateral - ProtoBrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS -> R.string.bg_target_hue_offset - ProtoBrushBehavior.Target.TARGET_SATURATION_MULTIPLIER -> R.string.bg_target_saturation_multiplier - ProtoBrushBehavior.Target.TARGET_LUMINOSITY_OFFSET -> R.string.bg_target_luminosity_offset - ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER -> R.string.bg_target_opacity_multiplier - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER -> R.string.bg_target_width_multiplier + ProtoBrushBehavior.Target.TARGET_HEIGHT_MULTIPLIER -> R.string.bg_target_height_multiplier + ProtoBrushBehavior.Target.TARGET_SIZE_MULTIPLIER -> R.string.bg_target_size_multiplier + ProtoBrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS -> R.string.bg_target_slant_offset + ProtoBrushBehavior.Target.TARGET_PINCH_OFFSET -> R.string.bg_target_pinch_offset + ProtoBrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS -> R.string.bg_target_rotation_offset + ProtoBrushBehavior.Target.TARGET_CORNER_ROUNDING_OFFSET -> R.string.bg_target_corner_rounding_offset + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_X_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_target_position_offset_x + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_Y_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_target_position_offset_y + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE -> + R.string.bg_target_position_offset_forward + + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE -> + R.string.bg_target_position_offset_lateral + + ProtoBrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS -> R.string.bg_target_hue_offset + ProtoBrushBehavior.Target.TARGET_SATURATION_MULTIPLIER -> R.string.bg_target_saturation_multiplier + ProtoBrushBehavior.Target.TARGET_LUMINOSITY_OFFSET -> R.string.bg_target_luminosity_offset + ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER -> R.string.bg_target_opacity_multiplier + else -> R.string.bg_node_unknown + } fun ProtoBrushBehavior.PolarTarget.displayStringRId(): Int = - when (this) { - ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_ABSOLUTE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE -> - R.string.bg_polar_target_position_offset_absolute - ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_RELATIVE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE -> - R.string.bg_polar_target_position_offset_relative - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_ABSOLUTE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE -> + R.string.bg_polar_target_position_offset_absolute + + ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_RELATIVE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE -> + R.string.bg_polar_target_position_offset_relative + + else -> R.string.bg_node_unknown + } fun ProtoBrushBehavior.BinaryOp.displayStringRId(): Int = - when (this) { - ProtoBrushBehavior.BinaryOp.BINARY_OP_PRODUCT -> R.string.bg_binary_op_product - ProtoBrushBehavior.BinaryOp.BINARY_OP_SUM -> R.string.bg_binary_op_sum - ProtoBrushBehavior.BinaryOp.BINARY_OP_MIN -> R.string.bg_binary_op_min - ProtoBrushBehavior.BinaryOp.BINARY_OP_MAX -> R.string.bg_binary_op_max - ProtoBrushBehavior.BinaryOp.BINARY_OP_AND_THEN -> R.string.bg_binary_op_and_then - ProtoBrushBehavior.BinaryOp.BINARY_OP_OR_ELSE -> R.string.bg_binary_op_or_else - ProtoBrushBehavior.BinaryOp.BINARY_OP_XOR_ELSE -> R.string.bg_binary_op_xor_else - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushBehavior.BinaryOp.BINARY_OP_PRODUCT -> R.string.bg_binary_op_product + ProtoBrushBehavior.BinaryOp.BINARY_OP_SUM -> R.string.bg_binary_op_sum + ProtoBrushBehavior.BinaryOp.BINARY_OP_MIN -> R.string.bg_binary_op_min + ProtoBrushBehavior.BinaryOp.BINARY_OP_MAX -> R.string.bg_binary_op_max + ProtoBrushBehavior.BinaryOp.BINARY_OP_AND_THEN -> R.string.bg_binary_op_and_then + ProtoBrushBehavior.BinaryOp.BINARY_OP_OR_ELSE -> R.string.bg_binary_op_or_else + ProtoBrushBehavior.BinaryOp.BINARY_OP_XOR_ELSE -> R.string.bg_binary_op_xor_else + else -> R.string.bg_node_unknown + } fun ProtoBrushBehavior.OutOfRange.displayStringRId(): Int = - when (this) { - ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP -> R.string.bg_out_of_range_clamp - ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_REPEAT -> R.string.bg_out_of_range_repeat - ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_MIRROR -> R.string.bg_out_of_range_mirror - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP -> R.string.bg_out_of_range_clamp + ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_REPEAT -> R.string.bg_out_of_range_repeat + ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_MIRROR -> R.string.bg_out_of_range_mirror + else -> R.string.bg_node_unknown + } fun ProtoBrushBehavior.ProgressDomain.displayStringRId(): Int = - when (this) { - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> R.string.bg_progress_domain_distance_absolute - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_progress_domain_distance_size_relative - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> R.string.bg_progress_domain_time_seconds - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> R.string.bg_progress_domain_distance_absolute + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_progress_domain_distance_size_relative + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> R.string.bg_progress_domain_time_seconds + else -> R.string.bg_node_unknown + } fun ProtoBrushBehavior.Interpolation.displayStringRId(): Int = - when (this) { - ProtoBrushBehavior.Interpolation.INTERPOLATION_LERP -> R.string.bg_interpolation_lerp - ProtoBrushBehavior.Interpolation.INTERPOLATION_INVERSE_LERP -> R.string.bg_interpolation_inverse_lerp - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushBehavior.Interpolation.INTERPOLATION_LERP -> R.string.bg_interpolation_lerp + ProtoBrushBehavior.Interpolation.INTERPOLATION_INVERSE_LERP -> R.string.bg_interpolation_inverse_lerp + else -> R.string.bg_node_unknown + } fun ProtoStepPosition.displayStringRId(): Int = - when (this) { - ProtoStepPosition.STEP_POSITION_JUMP_START -> R.string.bg_step_position_jump_start - ProtoStepPosition.STEP_POSITION_JUMP_END -> R.string.bg_step_position_jump_end - ProtoStepPosition.STEP_POSITION_JUMP_BOTH -> R.string.bg_step_position_jump_both - ProtoStepPosition.STEP_POSITION_JUMP_NONE -> R.string.bg_step_position_jump_none - else -> R.string.bg_node_unknown - } + when (this) { + ProtoStepPosition.STEP_POSITION_JUMP_START -> R.string.bg_step_position_jump_start + ProtoStepPosition.STEP_POSITION_JUMP_END -> R.string.bg_step_position_jump_end + ProtoStepPosition.STEP_POSITION_JUMP_BOTH -> R.string.bg_step_position_jump_both + ProtoStepPosition.STEP_POSITION_JUMP_NONE -> R.string.bg_step_position_jump_none + else -> R.string.bg_node_unknown + } fun ProtoPredefinedEasingFunction.displayStringRId(): Int = - when (this) { - ProtoPredefinedEasingFunction.PREDEFINED_EASING_LINEAR -> R.string.bg_easing_linear - ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE -> R.string.bg_easing_ease - ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_IN -> R.string.bg_easing_ease_in - ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_OUT -> R.string.bg_easing_ease_out - ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_IN_OUT -> R.string.bg_easing_ease_in_out - ProtoPredefinedEasingFunction.PREDEFINED_EASING_STEP_START -> R.string.bg_easing_step_start - ProtoPredefinedEasingFunction.PREDEFINED_EASING_STEP_END -> R.string.bg_easing_step_end - else -> R.string.bg_node_unknown - } + when (this) { + ProtoPredefinedEasingFunction.PREDEFINED_EASING_LINEAR -> R.string.bg_easing_linear + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE -> R.string.bg_easing_ease + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_IN -> R.string.bg_easing_ease_in + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_OUT -> R.string.bg_easing_ease_out + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_IN_OUT -> R.string.bg_easing_ease_in_out + ProtoPredefinedEasingFunction.PREDEFINED_EASING_STEP_START -> R.string.bg_easing_step_start + ProtoPredefinedEasingFunction.PREDEFINED_EASING_STEP_END -> R.string.bg_easing_step_end + else -> R.string.bg_node_unknown + } fun ProtoBrushBehavior.ResponseNode.ResponseCurveCase.displayStringRId(): Int = - when (this) { - ProtoBrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE -> R.string.bg_tab_predefined - ProtoBrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> R.string.bg_tab_cubic_bezier - ProtoBrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> R.string.bg_tab_linear - ProtoBrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> R.string.bg_tab_steps - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE -> R.string.bg_tab_predefined + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> R.string.bg_tab_cubic_bezier + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> R.string.bg_tab_linear + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> R.string.bg_tab_steps + else -> R.string.bg_node_unknown + } fun ProtoBrushBehavior.ResponseNode.displayStringRId(): Int = - this.responseCurveCase.displayStringRId() + this.responseCurveCase.displayStringRId() fun InputToolType.displayStringRId(): Int = - when (this) { - InputToolType.UNKNOWN -> R.string.bg_tool_type_unknown - InputToolType.MOUSE -> R.string.bg_tool_type_mouse - InputToolType.TOUCH -> R.string.bg_tool_type_touch - InputToolType.STYLUS -> R.string.bg_tool_type_stylus - else -> R.string.bg_node_unknown - } + when (this) { + InputToolType.UNKNOWN -> R.string.bg_tool_type_unknown + InputToolType.MOUSE -> R.string.bg_tool_type_mouse + InputToolType.TOUCH -> R.string.bg_tool_type_touch + InputToolType.STYLUS -> R.string.bg_tool_type_stylus + else -> R.string.bg_node_unknown + } fun ProtoBrushPaint.SelfOverlap.displayStringRId(): Int = - when (this) { - ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ANY -> R.string.bg_self_overlap_any - ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ACCUMULATE -> R.string.bg_self_overlap_accumulate - ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_DISCARD -> R.string.bg_self_overlap_discard - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ANY -> R.string.bg_self_overlap_any + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ACCUMULATE -> R.string.bg_self_overlap_accumulate + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_DISCARD -> R.string.bg_self_overlap_discard + else -> R.string.bg_node_unknown + } fun ProtoBrushPaint.TextureLayer.SizeUnit.displayStringRId(): Int = - when (this) { - ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE -> R.string.bg_size_unit_brush_size - ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_STROKE_COORDINATES -> R.string.bg_size_unit_stroke_coordinates - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE -> R.string.bg_size_unit_brush_size + ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_STROKE_COORDINATES -> R.string.bg_size_unit_stroke_coordinates + else -> R.string.bg_node_unknown + } fun ProtoBrushPaint.TextureLayer.Origin.displayStringRId(): Int = - when (this) { - ProtoBrushPaint.TextureLayer.Origin.ORIGIN_STROKE_SPACE_ORIGIN -> R.string.bg_origin_stroke_space_origin - ProtoBrushPaint.TextureLayer.Origin.ORIGIN_FIRST_STROKE_INPUT -> R.string.bg_origin_first_stroke_input - ProtoBrushPaint.TextureLayer.Origin.ORIGIN_LAST_STROKE_INPUT -> R.string.bg_origin_last_stroke_input - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_STROKE_SPACE_ORIGIN -> R.string.bg_origin_stroke_space_origin + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_FIRST_STROKE_INPUT -> R.string.bg_origin_first_stroke_input + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_LAST_STROKE_INPUT -> R.string.bg_origin_last_stroke_input + else -> R.string.bg_node_unknown + } fun ProtoBrushPaint.TextureLayer.Mapping.displayStringRId(): Int = - when (this) { - ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING -> R.string.bg_mapping_tiling - ProtoBrushPaint.TextureLayer.Mapping.MAPPING_STAMPING -> R.string.bg_mapping_stamping - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING -> R.string.bg_mapping_tiling + ProtoBrushPaint.TextureLayer.Mapping.MAPPING_STAMPING -> R.string.bg_mapping_stamping + else -> R.string.bg_node_unknown + } fun ProtoBrushPaint.TextureLayer.Wrap.displayStringRId(): Int = - when (this) { - ProtoBrushPaint.TextureLayer.Wrap.WRAP_REPEAT -> R.string.bg_wrap_repeat - ProtoBrushPaint.TextureLayer.Wrap.WRAP_MIRROR -> R.string.bg_wrap_mirror - ProtoBrushPaint.TextureLayer.Wrap.WRAP_CLAMP -> R.string.bg_wrap_clamp - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushPaint.TextureLayer.Wrap.WRAP_REPEAT -> R.string.bg_wrap_repeat + ProtoBrushPaint.TextureLayer.Wrap.WRAP_MIRROR -> R.string.bg_wrap_mirror + ProtoBrushPaint.TextureLayer.Wrap.WRAP_CLAMP -> R.string.bg_wrap_clamp + else -> R.string.bg_node_unknown + } fun ProtoBrushPaint.TextureLayer.BlendMode.displayStringRId(): Int = - when (this) { - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC -> R.string.bg_blend_mode_src - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER -> R.string.bg_blend_mode_src_over - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_ATOP -> R.string.bg_blend_mode_src_atop - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_IN -> R.string.bg_blend_mode_src_in - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OUT -> R.string.bg_blend_mode_src_out - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST -> R.string.bg_blend_mode_dst - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OVER -> R.string.bg_blend_mode_dst_over - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_ATOP -> R.string.bg_blend_mode_dst_atop - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_IN -> R.string.bg_blend_mode_dst_in - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OUT -> R.string.bg_blend_mode_dst_out - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_MODULATE -> R.string.bg_blend_mode_modulate - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_XOR -> R.string.bg_blend_mode_xor - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC -> R.string.bg_blend_mode_src + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER -> R.string.bg_blend_mode_src_over + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_ATOP -> R.string.bg_blend_mode_src_atop + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_IN -> R.string.bg_blend_mode_src_in + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OUT -> R.string.bg_blend_mode_src_out + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST -> R.string.bg_blend_mode_dst + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OVER -> R.string.bg_blend_mode_dst_over + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_ATOP -> R.string.bg_blend_mode_dst_atop + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_IN -> R.string.bg_blend_mode_dst_in + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OUT -> R.string.bg_blend_mode_dst_out + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_MODULATE -> R.string.bg_blend_mode_modulate + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_XOR -> R.string.bg_blend_mode_xor + else -> R.string.bg_node_unknown + } fun ProtoBrushBehavior.Node.NodeCase.displayStringRId(): Int = - when (this) { - ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> R.string.bg_node_source - ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> R.string.bg_node_constant - ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> R.string.bg_node_noise - ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> R.string.bg_node_tool_type_filter - ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> R.string.bg_node_damping - ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> R.string.bg_node_response - ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> R.string.bg_node_integral - ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> R.string.bg_node_binary_op - ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> R.string.bg_node_interpolation - ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> R.string.bg_node_target - ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> R.string.bg_node_polar_target - else -> R.string.bg_node_unknown - } + when (this) { + ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> R.string.bg_node_source + ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> R.string.bg_node_constant + ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> R.string.bg_node_noise + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> R.string.bg_node_tool_type_filter + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> R.string.bg_node_damping + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> R.string.bg_node_response + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> R.string.bg_node_integral + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> R.string.bg_node_binary_op + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> R.string.bg_node_interpolation + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> R.string.bg_node_target + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> R.string.bg_node_polar_target + else -> R.string.bg_node_unknown + } fun ProtoBrushFamily.InputModel.displayStringRId(): Int = - when { - hasSlidingWindowModel() -> R.string.bg_model_sliding_window - hasPassthroughModel() -> R.string.bg_model_passthrough - else -> R.string.bg_unknown_model - } + when { + hasSlidingWindowModel() -> R.string.bg_model_sliding_window + hasPassthroughModel() -> R.string.bg_model_passthrough + else -> R.string.bg_unknown_model + } 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 d8433697..61bedd23 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 @@ -1,34 +1,34 @@ /* - * * 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. + * 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.data -import ink.proto.BrushBehavior as ProtoBrushBehavior -import ink.proto.BrushCoat as ProtoBrushCoat -import ink.proto.BrushFamily as ProtoBrushFamily -import ink.proto.BrushPaint as ProtoBrushPaint -import ink.proto.BrushTip as ProtoBrushTip -import ink.proto.ColorFunction as ProtoColorFunction -import java.util.UUID import androidx.ink.brush.BrushFamily +import androidx.ink.brush.Version import androidx.ink.storage.decode +import com.example.cahier.R import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.util.UUID import java.util.zip.GZIPOutputStream -import com.example.cahier.R +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.BrushTip as ProtoBrushTip import ink.proto.Color as ProtoColor +import ink.proto.ColorFunction as ProtoColorFunction /** * Converts a [ProtoBrushFamily] into a functional [BrushFamily] object. @@ -36,339 +36,495 @@ import ink.proto.Color as ProtoColor * This handles the necessary GZIP compression and decoding steps required by the [BrushFamily.decode] API. */ fun ProtoBrushFamily.toBrushFamily(): BrushFamily { - val rawBytes = this.toByteArray() - val baos = ByteArrayOutputStream() - GZIPOutputStream(baos).use { it.write(rawBytes) } - return ByteArrayInputStream(baos.toByteArray()).use { inputStream -> - BrushFamily.decode(inputStream) - } + val rawBytes = this.toByteArray() + val baos = ByteArrayOutputStream() + GZIPOutputStream(baos).use { it.write(rawBytes) } + return ByteArrayInputStream(baos.toByteArray()).use { inputStream -> + BrushFamily.decode(inputStream, maxVersion = Version.DEVELOPMENT) + } } data class GraphPoint(val x: Float, val y: Float) /** Representation of a single node in the brush behavior graph. */ data class GraphNode( - val id: String = UUID.randomUUID().toString(), - val data: NodeData, - val isExpanded: Boolean = false, - val hasError: Boolean = false, - val hasWarning: Boolean = false, - val isDisabled: Boolean = false, + val id: String = UUID.randomUUID().toString(), + val data: NodeData, + val isExpanded: Boolean = false, + val hasError: Boolean = false, + val hasWarning: Boolean = false, + val isDisabled: Boolean = false, ) /** Represents the core data/component within a node. */ sealed interface NodeData { - /** Returns a list of the input ports visible on this node */ - fun getVisiblePorts(nodeId: String, graph: BrushGraph): List = emptyList() + /** Returns a list of the input ports visible on this node */ + fun getVisiblePorts(nodeId: String, graph: BrushGraph): List = emptyList() + + /** Metadata for the inputs of this node. */ + fun inputLabels(): List = emptyList() + + /** Returns whether this node has an output port. */ + fun hasOutput(): Boolean = true + + /** Title to be displayed on the node. */ + fun title(): Int + + /** Subtitle for additional context, if any. */ + fun subtitles(): List = emptyList() + + /** Wraps a [ProtoBrushTip]. */ + data class Tip( + val tip: ProtoBrushTip, + val behaviorPortIds: List = emptyList(), + ) : NodeData { + override fun inputLabels() = listOf(R.string.bg_port_behaviors) + + override fun title() = R.string.bg_tip + + override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { + val ports = mutableListOf() + for (portId in behaviorPortIds) { + ports.add( + Port.Input( + nodeId, + portId, + label = DisplayText.Resource(R.string.bg_port_behavior) + ) + ) + } + ports.add( + Port.AddBehavior( + nodeId, + "add_behavior", + label = DisplayText.Resource(R.string.bg_behavior) + ) + ) + return ports + } + } - /** Metadata for the inputs of this node. */ - fun inputLabels(): List = emptyList() + /** Wraps a [ProtoBrushPaint]. */ + data class Paint( + val paint: ProtoBrushPaint, + val texturePortIds: List = emptyList(), + val colorPortIds: List = emptyList(), + ) : NodeData { + override fun inputLabels(): List { + val labels = mutableListOf() + for (i in texturePortIds.indices) labels.add(R.string.bg_port_texture) + labels.add(R.string.bg_port_texture) + for (i in colorPortIds.indices) labels.add(R.string.bg_port_color) + labels.add(R.string.bg_port_color) + return labels + } - /** Returns whether this node has an output port. */ - fun hasOutput(): Boolean = true + override fun title() = R.string.bg_paint - /** Title to be displayed on the node. */ - fun title(): Int + override fun subtitles() = listOf( + DisplayText.Resource( + R.string.bg_overlap_label, + listOf(DisplayText.Resource(paint.selfOverlap.displayStringRId())) + ) + ) - /** Subtitle for additional context, if any. */ - fun subtitles(): List = emptyList() + override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { + val ports = mutableListOf() + for (portId in texturePortIds) { + ports.add( + Port.Input( + nodeId, + portId, + label = DisplayText.Resource(R.string.bg_port_texture) + ) + ) + } + ports.add( + Port.AddTexture( + nodeId, + "add_texture", + label = DisplayText.Resource(R.string.bg_port_texture) + ) + ) - /** Wraps a [ProtoBrushTip]. */ - data class Tip( - val tip: ProtoBrushTip, - val behaviorPortIds: List = emptyList() - ) : NodeData { - override fun inputLabels() = listOf(R.string.bg_port_behaviors) + for (portId in colorPortIds) { + ports.add( + Port.Input( + nodeId, + portId, + label = DisplayText.Resource(R.string.bg_port_color) + ) + ) + } + ports.add( + Port.AddColor( + nodeId, + "add_color", + label = DisplayText.Resource(R.string.bg_port_color) + ) + ) + return ports + } + } - override fun title() = R.string.bg_tip + /** Wraps a [ProtoBrushPaint.TextureLayer]. */ + data class TextureLayer( + val layer: ProtoBrushPaint.TextureLayer, + ) : NodeData { + override fun title() = R.string.bg_texture_layer - override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { - val ports = mutableListOf() - for (portId in behaviorPortIds) { - ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(R.string.bg_port_behavior))) - } - ports.add(Port.AddBehavior(nodeId, "add_behavior", label = DisplayText.Resource(R.string.bg_behavior))) - return ports + override fun subtitles() = listOf(DisplayText.Literal(layer.clientTextureId)) } - } - - /** Wraps a [ProtoBrushPaint]. */ - data class Paint( - val paint: ProtoBrushPaint, - val texturePortIds: List = emptyList(), - val colorPortIds: List = emptyList() - ) : NodeData { - override fun inputLabels(): List { - val labels = mutableListOf() - for (i in texturePortIds.indices) labels.add(R.string.bg_port_texture) - labels.add(R.string.bg_port_texture) - for (i in colorPortIds.indices) labels.add(R.string.bg_port_color) - labels.add(R.string.bg_port_color) - return labels + + /** Wraps a [ProtoColorFunction]. */ + data class ColorFunction( + val function: ProtoColorFunction, + ) : NodeData { + override fun title() = R.string.bg_color_function + + override fun subtitles() = + listOf( + if (function.hasOpacityMultiplier()) { + DisplayText.Resource(R.string.bg_opacity_multiplier) + } else { + DisplayText.Resource(R.string.bg_replace_color) + } + ) } - override fun title() = R.string.bg_paint + /** Wraps a [ProtoBrushBehavior.Node] */ + data class Behavior( + val node: ProtoBrushBehavior.Node, + val developerComment: String = "", + val behaviorId: String = "", + val inputPortIds: List = when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf("value", "start", "end") + else -> emptyList() + }, + ) : NodeData { + override fun inputLabels(): List { + return when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> listOf(R.string.bg_port_input) + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> listOf(R.string.bg_port_input) + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> listOf(R.string.bg_port_input) + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> listOf(R.string.bg_port_input) + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> listOf( + R.string.bg_port_a, + R.string.bg_port_b + ) - override fun subtitles() = listOf(DisplayText.Resource(R.string.bg_overlap_label, listOf(DisplayText.Resource(paint.selfOverlap.displayStringRId())))) + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf( + R.string.bg_port_value, + R.string.bg_port_start, + R.string.bg_port_end + ) - override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { - val ports = mutableListOf() - for (portId in texturePortIds) { - ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(R.string.bg_port_texture))) - } - ports.add(Port.AddTexture(nodeId, "add_texture", label = DisplayText.Resource(R.string.bg_port_texture))) + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> listOf(R.string.bg_port_input) + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> listOf( + R.string.bg_port_angle, + R.string.bg_port_mag + ) - for (portId in colorPortIds) { - ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(R.string.bg_port_color))) - } - ports.add(Port.AddColor(nodeId, "add_color", label = DisplayText.Resource(R.string.bg_port_color))) - return ports - } - } - - /** Wraps a [ProtoBrushPaint.TextureLayer]. */ - data class TextureLayer( - val layer: ProtoBrushPaint.TextureLayer - ) : NodeData { - override fun title() = R.string.bg_texture_layer - - override fun subtitles() = listOf(DisplayText.Literal(layer.clientTextureId)) - } - - /** Wraps a [ProtoColorFunction]. */ - data class ColorFunction( - val function: ProtoColorFunction - ) : NodeData { - override fun title() = R.string.bg_color_function - - override fun subtitles() = - listOf( - if (function.hasOpacityMultiplier()) { - DisplayText.Resource(R.string.bg_opacity_multiplier) - } else { - DisplayText.Resource(R.string.bg_replace_color) + else -> emptyList() + } } - ) - } - - /** Wraps a [ProtoBrushBehavior.Node] */ - data class Behavior( - val node: ProtoBrushBehavior.Node, - val developerComment: String = "", - val behaviorId: String = "", - val inputPortIds: List = when (node.nodeCase) { - ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf("value", "start", "end") - else -> emptyList() - } - ) : NodeData { - override fun inputLabels(): List { - return when (node.nodeCase) { - ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> listOf(R.string.bg_port_input) - ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> listOf(R.string.bg_port_input) - ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> listOf(R.string.bg_port_input) - ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> listOf(R.string.bg_port_input) - ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> listOf(R.string.bg_port_a, R.string.bg_port_b) - ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf(R.string.bg_port_value, R.string.bg_port_start, R.string.bg_port_end) - ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> listOf(R.string.bg_port_input) - ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> listOf(R.string.bg_port_angle, R.string.bg_port_mag) - else -> emptyList() - } - } - val isOperator: Boolean - get() = when (node.nodeCase) { - ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE, - ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE, - ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE, - ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE, - ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE, - ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> true - else -> false - } - - override fun title() = - when (node.nodeCase) { - ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> R.string.bg_node_source - ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> R.string.bg_node_constant - ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> R.string.bg_node_noise - ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> R.string.bg_node_tool_type_filter - ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> R.string.bg_node_damping - ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> R.string.bg_node_response - ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> R.string.bg_node_integral - ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> R.string.bg_node_binary_op - ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> R.string.bg_node_interpolation - ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> R.string.bg_node_target - ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> R.string.bg_node_polar_target - else -> R.string.bg_node_unknown - } - - override fun subtitles(): List { - val s = when (node.nodeCase) { - ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> DisplayText.Resource(node.sourceNode.source.displayStringRId()) - ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> DisplayText.Literal("%.2f".format(java.util.Locale.US, node.constantNode.value)) - ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> - return listOf( - DisplayText.Resource(node.noiseNode.varyOver.displayStringRId()), - DisplayText.Resource(R.string.bg_period_label, listOf(node.noiseNode.basePeriod.toString())) - ) - ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> { - val bitmask = node.toolTypeFilterNode.enabledToolTypes - val enabled = mutableListOf() - if (bitmask and (1 shl 0) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_unknown)) - if (bitmask and (1 shl 1) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_mouse)) - if (bitmask and (1 shl 2) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_touch)) - if (bitmask and (1 shl 3) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_stylus)) - return if (enabled.isEmpty()) listOf(DisplayText.Resource(R.string.bg_none)) - else enabled - } - ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> { - val source = node.dampingNode.dampingSource - val unit = when (source) { - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> DisplayText.Resource(R.string.bg_unit_cm) - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> DisplayText.Resource(R.string.bg_unit_size) - ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> DisplayText.Resource(R.string.bg_unit_s) - else -> DisplayText.Literal("") - } - return listOf( - DisplayText.Resource(source.displayStringRId()), - DisplayText.Resource(R.string.bg_gap_label, listOf(node.dampingNode.dampingGap.toString(), unit)) - ) - } - ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> DisplayText.Resource(node.responseNode.displayStringRId()) - ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> DisplayText.Resource(node.integralNode.integrateOver.displayStringRId()) - ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> DisplayText.Resource(node.binaryOpNode.operation.displayStringRId()) - ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> DisplayText.Resource(node.interpolationNode.interpolation.displayStringRId()) - ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> DisplayText.Resource(node.targetNode.target.displayStringRId()) - ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> DisplayText.Resource(node.polarTargetNode.target.displayStringRId()) - else -> DisplayText.Literal(node.nodeCase.name) - } - return listOf(s) - } + val isOperator: Boolean + get() = when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE, + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE, + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE, + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE, + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE, + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE, + -> true + + else -> false + } - override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { - val ports = mutableListOf() - when (node.nodeCase) { - ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> { - val labels = inputLabels() - for (i in labels.indices) { - val portId = inputPortIds.getOrElse(i) { "invalid_port_$i" } - ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(labels[i]))) - } + override fun title() = + when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> R.string.bg_node_source + ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> R.string.bg_node_constant + ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> R.string.bg_node_noise + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> R.string.bg_node_tool_type_filter + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> R.string.bg_node_damping + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> R.string.bg_node_response + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> R.string.bg_node_integral + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> R.string.bg_node_binary_op + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> R.string.bg_node_interpolation + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> R.string.bg_node_target + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> R.string.bg_node_polar_target + else -> R.string.bg_node_unknown } - ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> { - var nextIndex = 0 - for (portId in inputPortIds) { - var n = nextIndex + 1 - val builder = StringBuilder() - while (n > 0) { - val m = (n - 1) % 26 - builder.append(('A'.code + m).toChar()) - n = (n - 1) / 26 + + override fun subtitles(): List { + val s = when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> DisplayText.Resource(node.sourceNode.source.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> DisplayText.Literal( + "%.2f".format( + java.util.Locale.US, + node.constantNode.value + ) + ) + + ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> + return listOf( + DisplayText.Resource(node.noiseNode.varyOver.displayStringRId()), + DisplayText.Resource( + R.string.bg_period_label, + listOf(node.noiseNode.basePeriod.toString()) + ) + ) + + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> { + val bitmask = node.toolTypeFilterNode.enabledToolTypes + val enabled = mutableListOf() + if (bitmask and (1 shl 0) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_unknown)) + if (bitmask and (1 shl 1) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_mouse)) + if (bitmask and (1 shl 2) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_touch)) + if (bitmask and (1 shl 3) != 0) enabled.add(DisplayText.Resource(R.string.bg_tool_type_stylus)) + return if (enabled.isEmpty()) listOf(DisplayText.Resource(R.string.bg_none)) + else enabled + } + + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> { + val source = node.dampingNode.dampingSource + val unit = when (source) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> DisplayText.Resource( + R.string.bg_unit_cm + ) + + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> DisplayText.Resource( + R.string.bg_unit_size + ) + + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> DisplayText.Resource( + R.string.bg_unit_s + ) + + else -> DisplayText.Literal("") } - val label = builder.reverse().toString() - ports.add(Port.Input(nodeId, portId, label = DisplayText.Literal(label))) - nextIndex++ + return listOf( + DisplayText.Resource(source.displayStringRId()), + DisplayText.Resource( + R.string.bg_gap_label, + listOf(node.dampingNode.dampingGap.toString(), unit) + ) + ) } - ports.add(Port.AddInput(nodeId, "add_input", label = DisplayText.Resource(R.string.bg_port_input))) + + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> DisplayText.Resource(node.responseNode.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> DisplayText.Resource(node.integralNode.integrateOver.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> DisplayText.Resource(node.binaryOpNode.operation.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> DisplayText.Resource(node.interpolationNode.interpolation.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> DisplayText.Resource(node.targetNode.target.displayStringRId()) + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> DisplayText.Resource(node.polarTargetNode.target.displayStringRId()) + else -> DisplayText.Literal(node.nodeCase.name) } - ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> { - val labels = inputLabels() - for (i in inputPortIds.indices) { - val label = labels[i % labels.size] - ports.add(Port.Input(nodeId, inputPortIds[i], label = DisplayText.Resource(label))) + return listOf(s) + } + + override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { + val ports = mutableListOf() + when (node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> { + val labels = inputLabels() + for (i in labels.indices) { + val portId = inputPortIds.getOrElse(i) { "invalid_port_$i" } + ports.add( + Port.Input( + nodeId, + portId, + label = DisplayText.Resource(labels[i]) + ) + ) + } } - ports.add(Port.AddInput(nodeId, "add_input", label = DisplayText.Resource(R.string.bg_port_input))) - } - else -> { - val labels = inputLabels() - if (labels.size == 1) { + + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> { + var nextIndex = 0 for (portId in inputPortIds) { - ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(labels[0]))) + var n = nextIndex + 1 + val builder = StringBuilder() + while (n > 0) { + val m = (n - 1) % 26 + builder.append(('A'.code + m).toChar()) + n = (n - 1) / 26 + } + val label = builder.reverse().toString() + ports.add(Port.Input(nodeId, portId, label = DisplayText.Literal(label))) + nextIndex++ } - ports.add(Port.AddInput(nodeId, "add_input", label = DisplayText.Resource(R.string.bg_port_input))) - } else { - for (i in labels.indices) { - val portId = inputPortIds.getOrElse(i) { "invalid_port_$i" } - ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(labels[i]))) + ports.add( + Port.AddInput( + nodeId, + "add_input", + label = DisplayText.Resource(R.string.bg_port_input) + ) + ) + } + + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> { + val labels = inputLabels() + for (i in inputPortIds.indices) { + val label = labels[i % labels.size] + ports.add( + Port.Input( + nodeId, + inputPortIds[i], + label = DisplayText.Resource(label) + ) + ) + } + ports.add( + Port.AddInput( + nodeId, + "add_input", + label = DisplayText.Resource(R.string.bg_port_input) + ) + ) + } + + else -> { + val labels = inputLabels() + if (labels.size == 1) { + for (portId in inputPortIds) { + ports.add( + Port.Input( + nodeId, + portId, + label = DisplayText.Resource(labels[0]) + ) + ) + } + ports.add( + Port.AddInput( + nodeId, + "add_input", + label = DisplayText.Resource(R.string.bg_port_input) + ) + ) + } else { + for (i in labels.indices) { + val portId = inputPortIds.getOrElse(i) { "invalid_port_$i" } + ports.add( + Port.Input( + nodeId, + portId, + label = DisplayText.Resource(labels[i]) + ) + ) + } } } } + return ports } - return ports - } - } - - /** Represents a [ProtoBrushCoat]. */ - data class Coat( - val tipPortId: String = "tip", - val paintPortIds: List = emptyList() - ) : NodeData { - override fun inputLabels(): List { - val labels = mutableListOf(R.string.bg_port_tip) - for (i in paintPortIds.indices) { - labels.add(R.string.bg_port_paint) - } - labels.add(R.string.bg_port_paint) - return labels } - override fun title() = R.string.bg_coat + /** Represents a [ProtoBrushCoat]. */ + data class Coat( + val tipPortId: String = "tip", + val paintPortIds: List = emptyList(), + ) : NodeData { + override fun inputLabels(): List { + val labels = mutableListOf(R.string.bg_port_tip) + for (i in paintPortIds.indices) { + labels.add(R.string.bg_port_paint) + } + labels.add(R.string.bg_port_paint) + return labels + } + + override fun title() = R.string.bg_coat - override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { - val ports = mutableListOf() - ports.add(Port.AddTip(nodeId, tipPortId, label = DisplayText.Resource(R.string.bg_port_tip))) - for (portId in paintPortIds) { - ports.add(Port.Input(nodeId, portId, label = DisplayText.Resource(R.string.bg_port_paint))) + override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { + val ports = mutableListOf() + ports.add( + Port.AddTip( + nodeId, + tipPortId, + label = DisplayText.Resource(R.string.bg_port_tip) + ) + ) + for (portId in paintPortIds) { + ports.add( + Port.Input( + nodeId, + portId, + label = DisplayText.Resource(R.string.bg_port_paint) + ) + ) + } + ports.add( + Port.AddPaint( + nodeId, + "add_paint", + label = DisplayText.Resource(R.string.bg_port_paint) + ) + ) + return ports } - ports.add(Port.AddPaint(nodeId, "add_paint", label = DisplayText.Resource(R.string.bg_port_paint))) - return ports - } - } - - /** Represents the [ProtoBrushFamily] root. */ - data class Family( - val clientBrushFamilyId: String = "", - val developerComment: String = "", - val inputModel: ProtoBrushFamily.InputModel = - ProtoBrushFamily.InputModel.newBuilder() - .setSlidingWindowModel( - ProtoBrushFamily.SlidingWindowModel.newBuilder() - .setWindowSizeSeconds(0.02f) - .setExperimentalUpsamplingPeriodSeconds(0.005f) - ) - .build(), - val coatPortIds: List = emptyList(), - ) : NodeData { - override fun inputLabels(): List { - val labels = mutableListOf() - for (i in coatPortIds.indices) { - labels.add(R.string.bg_port_coat) - } - labels.add(R.string.bg_coat) - return labels } - override fun title() = R.string.bg_family + /** Represents the [ProtoBrushFamily] root. */ + data class Family( + val clientBrushFamilyId: String = "", + val name: String = "", + val developerComment: String = "", + val inputModel: ProtoBrushFamily.InputModel = + ProtoBrushFamily.InputModel.newBuilder() + .setSlidingWindowModel( + ProtoBrushFamily.SlidingWindowModel.newBuilder() + .setWindowSizeSeconds(0.02f) + .setExperimentalUpsamplingPeriodSeconds(0.005f) + ) + .build(), + val coatPortIds: List = emptyList(), + ) : NodeData { + override fun inputLabels(): List { + val labels = mutableListOf() + for (i in coatPortIds.indices) { + labels.add(R.string.bg_port_coat) + } + labels.add(R.string.bg_coat) + return labels + } + + override fun title() = R.string.bg_family - override fun subtitles() = listOf(DisplayText.Literal(clientBrushFamilyId)) + override fun subtitles() = listOf(DisplayText.Literal(name)) - override fun hasOutput() = false + override fun hasOutput() = false - override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { - val ports = mutableListOf() - for (i in coatPortIds.indices) { - ports.add(Port.Input(nodeId, coatPortIds[i], label = DisplayText.Resource(R.string.bg_port_coat, listOf(i)))) + override fun getVisiblePorts(nodeId: String, graph: BrushGraph): List { + val ports = mutableListOf() + for (i in coatPortIds.indices) { + ports.add( + Port.Input( + nodeId, + coatPortIds[i], + label = DisplayText.Resource(R.string.bg_port_coat, listOf(i)) + ) + ) + } + ports.add( + Port.AddCoat( + nodeId, + "add_coat", + label = DisplayText.Resource(R.string.bg_coat) + ) + ) + return ports } - ports.add(Port.AddCoat(nodeId, "add_coat", label = DisplayText.Resource(R.string.bg_coat))) - return ports } - } } /** Side of a node where a port is located. */ enum class PortSide { - INPUT, - OUTPUT, + INPUT, + OUTPUT, } /** Represents a connection between two nodes. */ @@ -376,64 +532,68 @@ data class GraphEdge( val fromNodeId: String, val toNodeId: String, val toPortId: String, - val isDisabled: Boolean = false + val isDisabled: Boolean = false, ) /** Represents the entire node graph state. */ data class BrushGraph( - val nodes: List = emptyList(), - val edges: List = emptyList(), + val nodes: List = emptyList(), + val edges: List = emptyList(), ) sealed class Port( val nodeId: String, val id: String, val label: DisplayText? = null, - val isAddPort: Boolean = false + val isAddPort: Boolean = false, ) { 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 } - class Input(nodeId: String, id: String, label: DisplayText? = null) : + class Input(nodeId: String, id: String, label: DisplayText? = null) : Port(nodeId, id, label, isAddPort = false) { override val side = PortSide.INPUT } - class AddCoat(nodeId: String, id: String, label: DisplayText? = null) : + class AddCoat(nodeId: String, id: String, label: DisplayText? = null) : Port(nodeId, id, label, isAddPort = true) { override val side = PortSide.INPUT } - class AddBehavior(nodeId: String, id: String, label: DisplayText? = null) : + class AddBehavior(nodeId: String, id: String, label: DisplayText? = null) : Port(nodeId, id, label, isAddPort = true) { override val side = PortSide.INPUT } - class AddInput(nodeId: String, id: String, label: DisplayText? = null) : + class AddInput(nodeId: String, id: String, label: DisplayText? = null) : Port(nodeId, id, label, isAddPort = true) { override val side = PortSide.INPUT } - class AddTexture(nodeId: String, id: String, label: DisplayText? = null) : + class AddTexture(nodeId: String, id: String, label: DisplayText? = null) : Port(nodeId, id, label, isAddPort = true) { override val side = PortSide.INPUT } - class AddColor(nodeId: String, id: String, label: DisplayText? = null) : + class AddColor(nodeId: String, id: String, label: DisplayText? = null) : Port(nodeId, id, label, isAddPort = true) { override val side = PortSide.INPUT } - class AddTip(nodeId: String, id: String, label: DisplayText? = null) : + class AddTip(nodeId: String, id: String, label: DisplayText? = null) : Port(nodeId, id, label, isAddPort = true) { override val side = PortSide.INPUT } - class AddPaint(nodeId: String, id: String, label: DisplayText? = null) : + class AddPaint(nodeId: String, id: String, label: DisplayText? = null) : Port(nodeId, id, label, isAddPort = true) { override val side = PortSide.INPUT } @@ -448,63 +608,72 @@ fun preserveEdgesOnTypeChange( nodeId: String, oldData: NodeData?, newData: NodeData, - edges: List + edges: List, ): Pair> { var finalNewData = newData var finalEdges = edges if (oldData is NodeData.Behavior && newData is NodeData.Behavior) { - val oldCase = oldData.node.nodeCase - val newCase = newData.node.nodeCase - if (oldCase != newCase) { - val incomingEdges = edges.filter { it.toNodeId == nodeId } - val newIds = mutableListOf() - val updatedEdges = mutableListOf() - - when (newCase) { - ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> { - val defaultIds = listOf("value", "start", "end") - for (i in 0..2) { - val edge = incomingEdges.getOrNull(i) - val portId = edge?.toPortId ?: defaultIds[i] - newIds.add(portId) - if (edge != null) { - updatedEdges.add(edge.copy(toPortId = portId)) - } - } - } - ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> { - incomingEdges.take(26).forEachIndexed { index, edge -> - val portId = if (index < newData.inputPortIds.size) newData.inputPortIds[index] else UUID.randomUUID().toString() - newIds.add(portId) - updatedEdges.add(edge.copy(toPortId = portId)) - } - } - ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> { - incomingEdges.forEachIndexed { index, edge -> - val portId = if (index < newData.inputPortIds.size) newData.inputPortIds[index] else UUID.randomUUID().toString() - newIds.add(portId) - updatedEdges.add(edge.copy(toPortId = portId)) - } - } - else -> { - val labels = newData.inputLabels() - if (labels.size == 1) { - incomingEdges.forEachIndexed { index, edge -> - val portId = if (index < newData.inputPortIds.size) newData.inputPortIds[index] else UUID.randomUUID().toString() - newIds.add(portId) - updatedEdges.add(edge.copy(toPortId = portId)) - } + val oldCase = oldData.node.nodeCase + val newCase = newData.node.nodeCase + if (oldCase != newCase) { + val incomingEdges = edges.filter { it.toNodeId == nodeId } + val newIds = mutableListOf() + val updatedEdges = mutableListOf() + + when (newCase) { + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> { + val defaultIds = listOf("value", "start", "end") + for (i in 0..2) { + val edge = incomingEdges.getOrNull(i) + val portId = edge?.toPortId ?: defaultIds[i] + newIds.add(portId) + if (edge != null) { + updatedEdges.add(edge.copy(toPortId = portId)) + } + } + } + + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> { + incomingEdges.take(26).forEachIndexed { index, edge -> + val portId = + if (index < newData.inputPortIds.size) newData.inputPortIds[index] else UUID.randomUUID() + .toString() + newIds.add(portId) + updatedEdges.add(edge.copy(toPortId = portId)) + } + } + + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> { + incomingEdges.forEachIndexed { index, edge -> + val portId = + if (index < newData.inputPortIds.size) newData.inputPortIds[index] else UUID.randomUUID() + .toString() + newIds.add(portId) + updatedEdges.add(edge.copy(toPortId = portId)) + } + } + + else -> { + val labels = newData.inputLabels() + if (labels.size == 1) { + incomingEdges.forEachIndexed { index, edge -> + val portId = + if (index < newData.inputPortIds.size) newData.inputPortIds[index] else UUID.randomUUID() + .toString() + newIds.add(portId) + updatedEdges.add(edge.copy(toPortId = portId)) + } + } + } } - } - } - finalNewData = newData.copy(inputPortIds = newIds) - val edgesWithoutIncoming = edges.filter { it.toNodeId != nodeId } - finalEdges = edgesWithoutIncoming + updatedEdges - } else if (newData.inputPortIds.isEmpty() && oldData.inputPortIds.isNotEmpty()) { - finalNewData = newData.copy(inputPortIds = oldData.inputPortIds) - } + finalNewData = newData.copy(inputPortIds = newIds) + val edgesWithoutIncoming = edges.filter { it.toNodeId != nodeId } + finalEdges = edgesWithoutIncoming + updatedEdges + } else if (newData.inputPortIds.isEmpty() && oldData.inputPortIds.isNotEmpty()) { + finalNewData = newData.copy(inputPortIds = oldData.inputPortIds) + } } return Pair(finalNewData, finalEdges) } @@ -515,79 +684,86 @@ fun Port.inferNodeData(node: GraphNode): NodeData? = when (this) { is Port.AddTip -> NodeData.Tip(ProtoBrushTip.getDefaultInstance()) is Port.AddPaint -> NodeData.Paint(ProtoBrushPaint.getDefaultInstance()) is Port.AddTexture -> NodeData.TextureLayer(ProtoBrushPaint.TextureLayer.getDefaultInstance()) - is Port.AddColor -> NodeData.ColorFunction(ProtoColorFunction.newBuilder() + is Port.AddColor -> NodeData.ColorFunction( + ProtoColorFunction.newBuilder() .setReplaceColor( - ProtoColor.newBuilder() - .setRed(0f) - .setGreen(0f) - .setBlue(0f) - .setAlpha(1f) - .build() - ).build()) + ProtoColor.newBuilder() + .setRed(0f) + .setGreen(0f) + .setBlue(0f) + .setAlpha(1f) + .build() + ).build() + ) + is Port.AddBehavior -> { NodeData.Behavior( - ProtoBrushBehavior.Node.newBuilder() - .setTargetNode( - ProtoBrushBehavior.TargetNode.newBuilder() - .setTarget(ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER) - .setTargetModifierRangeStart(0.0f) - .setTargetModifierRangeEnd(1.0f) - ) - .build(), - "", - UUID.randomUUID().toString() + ProtoBrushBehavior.Node.newBuilder() + .setTargetNode( + ProtoBrushBehavior.TargetNode.newBuilder() + .setTarget(ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER) + .setTargetModifierRangeStart(0.0f) + .setTargetModifierRangeEnd(1.0f) + ) + .build(), + "", + UUID.randomUUID().toString() ) } + is Port.AddInput -> { val data = node.data as NodeData.Behavior NodeData.Behavior( - ProtoBrushBehavior.Node.newBuilder() - .setSourceNode( - ProtoBrushBehavior.SourceNode.newBuilder() - .setSource(ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) - .setSourceOutOfRangeBehavior(ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) - .setSourceValueRangeStart(0.0f) - .setSourceValueRangeEnd(1.0f) - ) - .build(), - "", - data.behaviorId + ProtoBrushBehavior.Node.newBuilder() + .setSourceNode( + ProtoBrushBehavior.SourceNode.newBuilder() + .setSource(ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceOutOfRangeBehavior(ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) + .setSourceValueRangeStart(0.0f) + .setSourceValueRangeEnd(1.0f) + ) + .build(), + "", + data.behaviorId ) - } + } + is Port.Input -> { val data = node.data if (data is NodeData.Behavior) { NodeData.Behavior( - ProtoBrushBehavior.Node.newBuilder() - .setSourceNode( - ProtoBrushBehavior.SourceNode.newBuilder() - .setSource(ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) - .setSourceOutOfRangeBehavior(ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) - .setSourceValueRangeStart(0.0f) - .setSourceValueRangeEnd(1.0f) - ) - .build(), - "", - data.behaviorId + ProtoBrushBehavior.Node.newBuilder() + .setSourceNode( + ProtoBrushBehavior.SourceNode.newBuilder() + .setSource(ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceOutOfRangeBehavior(ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) + .setSourceValueRangeStart(0.0f) + .setSourceValueRangeEnd(1.0f) + ) + .build(), + "", + data.behaviorId ) } else { null } } + else -> null } /** Determines if a given [Port] can be reordered with drag handles in the UI. */ fun NodeData.isPortReorderable(port: Port, index: Int, hasAddPort: Boolean): Boolean { - return !port.isAddPort && hasAddPort && when (this) { - is NodeData.Coat -> index != 0 - is NodeData.Behavior -> { - if (this.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { - index % 2 == 0 - } else { - true - } + return !port.isAddPort && hasAddPort && when (this) { + is NodeData.Coat -> index != 0 + is NodeData.Behavior -> { + if (this.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + index % 2 == 0 + } else { + true + } + } + + else -> true } - else -> true - } } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphValidator.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphValidator.kt index 7d524f40..67089695 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphValidator.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphValidator.kt @@ -1,26 +1,21 @@ /* - * * 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. + * 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.data import com.example.cahier.R -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.DisplayText -import com.example.cahier.developer.brushgraph.data.getVisiblePorts import ink.proto.BrushBehavior as ProtoBrushBehavior import ink.proto.BrushPaint as ProtoBrushPaint @@ -32,16 +27,16 @@ import ink.proto.BrushPaint as ProtoBrushPaint * is orphaned from the graph, so a node not included in the graph doesn't block validation. */ enum class ValidationSeverity { - ERROR, - WARNING, - DEBUG, + ERROR, + WARNING, + DEBUG, } /** Exception thrown when the brush graph fails validation. */ data class GraphValidationException( - val displayMessage: DisplayText, - val nodeId: String? = null, - val severity: ValidationSeverity = ValidationSeverity.ERROR, + val displayMessage: DisplayText, + val nodeId: String? = null, + val severity: ValidationSeverity = ValidationSeverity.ERROR, ) : IllegalStateException( when (displayMessage) { is DisplayText.Literal -> displayMessage.text @@ -52,449 +47,615 @@ data class GraphValidationException( /** Utility to validate a [BrushGraph] for correctness. */ object GraphValidator { - /** Validates the entire graph and returns all found errors and warnings. */ - fun validateAll(graph: BrushGraph): List { - val issues = mutableListOf() - val activeNodeIds = findActiveNodes(graph) - - val nodesById = graph.nodes.associateBy { it.id } - - // Check for dangling edges. - for (edge in graph.edges) { - if (edge.isDisabled) continue - if (!nodesById.containsKey(edge.fromNodeId)) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_edge_missing_source), nodeId = edge.toNodeId)) - } - if (!nodesById.containsKey(edge.toNodeId)) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_edge_missing_target), nodeId = edge.fromNodeId)) - } - } + /** Validates the entire graph and returns all found errors and warnings. */ + fun validateAll(graph: BrushGraph): List { + val issues = mutableListOf() + val activeNodeIds = findActiveNodes(graph) - // Input labels and required connections. - val familyNodes = graph.nodes.filter { it.data is NodeData.Family } - if (familyNodes.size != 1) { - for (node in familyNodes) { - issues.add( - GraphValidationException( - displayMessage = DisplayText.Resource(R.string.bg_err_family_count, listOf(familyNodes.size)), - nodeId = node.id, - severity = ValidationSeverity.ERROR, - ) - ) - } - if (familyNodes.isEmpty()) { - issues.add( - GraphValidationException( - displayMessage = DisplayText.Resource(R.string.bg_err_family_count, listOf(0)), - severity = ValidationSeverity.ERROR, - ) - ) - } - } + val nodesById = graph.nodes.associateBy { it.id } - for (node in graph.nodes) { - if (node.isDisabled) continue - val isActive = activeNodeIds.contains(node.id) - val ports = node.getVisiblePorts(graph) - val isOptionalInput = node.data is NodeData.Tip || node.data is NodeData.Paint - val incomingEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == node.id && activeNodeIds.contains(it.fromNodeId) } - - val connectedPortIds = incomingEdges.map { it.toPortId }.toSet() - val active = isActive - - when (val data = node.data) { - is NodeData.Coat -> { - val hasTip = connectedPortIds.contains(data.tipPortId) - val hasPaint = data.paintPortIds.any { connectedPortIds.contains(it) } - if (!hasTip) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_coat_missing_tip), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) - } - if (!hasPaint) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_coat_missing_paint), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) - } - } - is NodeData.Behavior -> { - val nodeCase = data.node.nodeCase - val labels = data.inputLabels() - val ids = if (data.inputPortIds.isEmpty()) { - when (nodeCase) { - ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> listOf("input_0", "input_1") - ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> listOf("angle_0", "mag_0") - ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf("value", "start", "end") - else -> if (labels.size == 1) listOf("Input") else emptyList() - } - } else data.inputPortIds - - if (nodeCase == ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE) { - val labels = listOf(R.string.bg_port_value, R.string.bg_port_start, R.string.bg_port_end) - for (i in 0 until minOf(ids.size, labels.size)) { - if (!connectedPortIds.contains(ids[i])) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_interp_missing_input, listOf(DisplayText.Resource(labels[i]))), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) - } - } - } else if (nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { - val chunkedIds = ids.chunked(2) - val hasValidSet = chunkedIds.any { set -> set.size == 2 && set.all { connectedPortIds.contains(it) } } - if (!hasValidSet) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_polar_missing_inputs), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) - } - } else if (nodeCase == ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE) { - val numInputs = ids.count { connectedPortIds.contains(it) } - if (numInputs < 2) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_binary_min_inputs), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) - } else if (numInputs > 26) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_binary_max_inputs), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) - } - } else { - if (connectedPortIds.isEmpty() && data.inputLabels().isNotEmpty()) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_missing_input, listOf(DisplayText.Resource(data.title()))), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) + // Check for dangling edges. + for (edge in graph.edges) { + if (edge.isDisabled) continue + if (!nodesById.containsKey(edge.fromNodeId)) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_edge_missing_source), + nodeId = edge.toNodeId + ) + ) } - } - } - - is NodeData.Family -> { - if (connectedPortIds.isEmpty()) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_family_missing_coat), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) - } - } - else -> { - if (!isOptionalInput && data.inputLabels().isNotEmpty() && connectedPortIds.isEmpty()) { - issues.add(GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_node_missing_input, listOf(DisplayText.Resource(data.title()))), nodeId = node.id, severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING)) - } - } - } - - for (edge in incomingEdges) { - val fromNode = graph.nodes.find { it.id == edge.fromNodeId } - if (fromNode == null) { - issues.add( - GraphValidationException( - displayMessage = DisplayText.Resource(R.string.bg_err_invalid_conn_no_source), - nodeId = node.id, - severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING, - ) - ) - } else { - val actualSources = findActualSourceNode(graph, edge.fromNodeId) - if (actualSources.isEmpty()) { - issues.add( - GraphValidationException( - displayMessage = DisplayText.Resource(R.string.bg_err_missing_source_passthrough), - nodeId = node.id, - severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING, - ) - ) - } else { - for (actualSourceNode in actualSources) { - isValidConnection(actualSourceNode, node, edge.toPortId, graph)?.let { displayText -> + if (!nodesById.containsKey(edge.toNodeId)) { issues.add( - GraphValidationException( - displayMessage = DisplayText.Resource(R.string.bg_err_invalid_connection_detail, listOf(DisplayText.Resource(actualSourceNode.data.title()), DisplayText.Resource(node.data.title()), edge.toPortId, displayText)), - nodeId = node.id, - severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING, - ) + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_edge_missing_target), + nodeId = edge.fromNodeId + ) ) - } } - } - } - } - - if (node.data is NodeData.Family) { - if (graph.edges.none { !it.isDisabled && it.toNodeId == node.id && activeNodeIds.contains(it.fromNodeId) }) { - issues.add( - GraphValidationException( - displayMessage = DisplayText.Resource(R.string.bg_err_family_no_coat), - nodeId = node.id, - ValidationSeverity.ERROR, - ) - ) - } - } - - if (node.data !is NodeData.Family && node.data.hasOutput()) { - if (graph.edges.none { !it.isDisabled && it.fromNodeId == node.id && activeNodeIds.contains(it.toNodeId) }) { - issues.add( - GraphValidationException( - displayMessage = DisplayText.Resource(R.string.bg_err_unused_output, listOf(DisplayText.Resource(node.data.title()))), - nodeId = node.id, - severity = ValidationSeverity.WARNING, - ) - ) - } - } - - if (node.data is NodeData.Coat) { - val incomingEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == node.id && activeNodeIds.contains(it.fromNodeId) } - val tipEdge = incomingEdges.find { it.toPortId == node.data.tipPortId } - - val connectedPaints = node.data.paintPortIds.mapNotNull { portId -> - incomingEdges.find { it.toPortId == portId } - }.mapNotNull { edge -> - graph.nodes.find { it.id == edge.fromNodeId } } - if (tipEdge != null && connectedPaints.isNotEmpty()) { - val discardPaints = connectedPaints.filter { - it.data is NodeData.Paint && - it.data.paint.selfOverlap == ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_DISCARD - } - - if (discardPaints.isNotEmpty()) { - val opacityTargetNodes = mutableListOf() - findOpacityTargetNodes(tipEdge.fromNodeId, graph, mutableSetOf(), opacityTargetNodes) - - if (opacityTargetNodes.isNotEmpty()) { - for (paintNode in discardPaints) { - issues.add( + // Input labels and required connections. + val familyNodes = graph.nodes.filter { it.data is NodeData.Family } + if (familyNodes.size != 1) { + for (node in familyNodes) { + issues.add( GraphValidationException( - displayMessage = DisplayText.Resource(R.string.bg_err_self_overlap_incompatible_op), - nodeId = paintNode.id, - severity = ValidationSeverity.WARNING, + displayMessage = DisplayText.Resource( + R.string.bg_err_family_count, + listOf(familyNodes.size) + ), + nodeId = node.id, + severity = ValidationSeverity.ERROR, ) - ) - } - opacityTargetNodes.forEach { targetNode -> + ) + } + if (familyNodes.isEmpty()) { issues.add( - GraphValidationException( - displayMessage = DisplayText.Resource(R.string.bg_err_op_incompatible_self_overlap), - nodeId = targetNode.id, - severity = ValidationSeverity.WARNING, - ) + GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_family_count, + listOf(0) + ), + severity = ValidationSeverity.ERROR, + ) ) - } } - } } - } - - if (node.data is NodeData.Behavior) { - val behaviorNode = node.data.node - if (behaviorNode.nodeCase == ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE) { - val sourceNode = behaviorNode.sourceNode - if (sourceNode.sourceValueRangeStart == sourceNode.sourceValueRangeEnd) { - issues.add( - GraphValidationException( - displayMessage = DisplayText.Resource(R.string.bg_err_source_range_equal, node.data.subtitles()), - nodeId = node.id, - severity = if (isActive) ValidationSeverity.ERROR else ValidationSeverity.WARNING, - ) - ) - } + + for (node in graph.nodes) { + if (node.isDisabled) continue + val isActive = activeNodeIds.contains(node.id) + val ports = node.getVisiblePorts(graph) + val isOptionalInput = node.data is NodeData.Tip || node.data is NodeData.Paint + val incomingEdges = graph.edges.filter { + !it.isDisabled && it.toNodeId == node.id && activeNodeIds.contains(it.fromNodeId) + } + + val connectedPortIds = incomingEdges.map { it.toPortId }.toSet() + val active = isActive + + when (val data = node.data) { + is NodeData.Coat -> { + val hasTip = connectedPortIds.contains(data.tipPortId) + val hasPaint = data.paintPortIds.any { connectedPortIds.contains(it) } + if (!hasTip) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_coat_missing_tip), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING + ) + ) + } + if (!hasPaint) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_coat_missing_paint), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING + ) + ) + } + } + + is NodeData.Behavior -> { + val nodeCase = data.node.nodeCase + val labels = data.inputLabels() + val ids = if (data.inputPortIds.isEmpty()) { + when (nodeCase) { + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> listOf( + "input_0", + "input_1" + ) + + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> listOf( + "angle_0", + "mag_0" + ) + + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> listOf( + "value", + "start", + "end" + ) + + else -> if (labels.size == 1) listOf("Input") else emptyList() + } + } else data.inputPortIds + + if (nodeCase == ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE) { + val labels = listOf( + R.string.bg_port_value, + R.string.bg_port_start, + R.string.bg_port_end + ) + for (i in 0 until minOf(ids.size, labels.size)) { + if (!connectedPortIds.contains(ids[i])) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_interp_missing_input, + listOf(DisplayText.Resource(labels[i])) + ), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING + ) + ) + } + } + } else if (nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) { + val chunkedIds = ids.chunked(2) + val hasValidSet = chunkedIds.any { set -> + set.size == 2 && set.all { + connectedPortIds.contains(it) + } + } + if (!hasValidSet) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_polar_missing_inputs + ), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING + ) + ) + } + } else if (nodeCase == ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE) { + val numInputs = ids.count { connectedPortIds.contains(it) } + if (numInputs < 2) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_binary_min_inputs + ), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING + ) + ) + } else if (numInputs > 26) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_binary_max_inputs + ), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING + ) + ) + } + } else { + if (connectedPortIds.isEmpty() && data.inputLabels().isNotEmpty()) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_node_missing_input, + listOf(DisplayText.Resource(data.title())) + ), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING + ) + ) + } + } + } + + is NodeData.Family -> { + if (connectedPortIds.isEmpty()) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_family_missing_coat), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING + ) + ) + } + } + + else -> { + if (!isOptionalInput && data.inputLabels() + .isNotEmpty() && connectedPortIds.isEmpty() + ) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_node_missing_input, + listOf(DisplayText.Resource(data.title())) + ), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING + ) + ) + } + } + } + + for (edge in incomingEdges) { + val fromNode = graph.nodes.find { it.id == edge.fromNodeId } + if (fromNode == null) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_invalid_conn_no_source), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING, + ) + ) + } else { + val actualSources = findActualSourceNode(graph, edge.fromNodeId) + if (actualSources.isEmpty()) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_missing_source_passthrough), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING, + ) + ) + } else { + for (actualSourceNode in actualSources) { + isValidConnection( + actualSourceNode, + node, + edge.toPortId, + graph + )?.let { displayText -> + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_invalid_connection_detail, + listOf( + DisplayText.Resource(actualSourceNode.data.title()), + DisplayText.Resource(node.data.title()), + edge.toPortId, + displayText + ) + ), + nodeId = node.id, + severity = if (active) ValidationSeverity.ERROR else ValidationSeverity.WARNING, + ) + ) + } + } + } + } + } + + if (node.data is NodeData.Family) { + if (graph.edges.none { + !it.isDisabled && it.toNodeId == node.id && activeNodeIds.contains( + it.fromNodeId + ) + }) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_family_no_coat), + nodeId = node.id, + ValidationSeverity.ERROR, + ) + ) + } + } + + if (node.data !is NodeData.Family && node.data.hasOutput()) { + if (graph.edges.none { + !it.isDisabled && it.fromNodeId == node.id && activeNodeIds.contains( + it.toNodeId + ) + }) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_unused_output, + listOf(DisplayText.Resource(node.data.title())) + ), + nodeId = node.id, + severity = ValidationSeverity.WARNING, + ) + ) + } + } + + if (node.data is NodeData.Coat) { + val incomingEdges = graph.edges.filter { + !it.isDisabled && it.toNodeId == node.id && activeNodeIds.contains(it.fromNodeId) + } + val tipEdge = incomingEdges.find { it.toPortId == node.data.tipPortId } + + val connectedPaints = node.data.paintPortIds.mapNotNull { portId -> + incomingEdges.find { it.toPortId == portId } + }.mapNotNull { edge -> + graph.nodes.find { it.id == edge.fromNodeId } + } + + if (tipEdge != null && connectedPaints.isNotEmpty()) { + val discardPaints = connectedPaints.filter { + it.data is NodeData.Paint && + it.data.paint.selfOverlap == ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_DISCARD + } + + if (discardPaints.isNotEmpty()) { + val opacityTargetNodes = mutableListOf() + findOpacityTargetNodes( + tipEdge.fromNodeId, + graph, + mutableSetOf(), + opacityTargetNodes + ) + + if (opacityTargetNodes.isNotEmpty()) { + for (paintNode in discardPaints) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_self_overlap_incompatible_op), + nodeId = paintNode.id, + severity = ValidationSeverity.WARNING, + ) + ) + } + opacityTargetNodes.forEach { targetNode -> + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_op_incompatible_self_overlap), + nodeId = targetNode.id, + severity = ValidationSeverity.WARNING, + ) + ) + } + } + } + } + } + + if (node.data is NodeData.Behavior) { + val behaviorNode = node.data.node + if (behaviorNode.nodeCase == ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE) { + val sourceNode = behaviorNode.sourceNode + if (sourceNode.sourceValueRangeStart == sourceNode.sourceValueRangeEnd) { + issues.add( + GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_source_range_equal, + node.data.subtitles() + ), + nodeId = node.id, + severity = if (isActive) ValidationSeverity.ERROR else ValidationSeverity.WARNING, + ) + ) + } + } + } } - } + + val visited = mutableSetOf() + for (node in graph.nodes) { + if (!visited.contains(node.id)) { + try { + checkCycle(node.id, graph, visited, mutableSetOf()) + } catch (e: GraphValidationException) { + issues.add( + if (activeNodeIds.contains(e.nodeId)) e else e.copy(severity = ValidationSeverity.WARNING) + ) + } + } + } + + return issues.distinct() } - val visited = mutableSetOf() - for (node in graph.nodes) { - if (!visited.contains(node.id)) { - try { - checkCycle(node.id, graph, visited, mutableSetOf()) - } catch (e: GraphValidationException) { - issues.add( - if (activeNodeIds.contains(e.nodeId)) e else e.copy(severity = ValidationSeverity.WARNING) - ) + /** Returns a failure message when a connection from [from] to [to] at [toPortId] is invalid. */ + fun isValidConnection( + from: GraphNode, + to: GraphNode, + toPortId: String, + graph: BrushGraph = BrushGraph(), + ): DisplayText? { + val fromData = from.data + val toData = to.data + val fromIsStructural = + fromData is NodeData.Tip || + fromData is NodeData.Coat || + fromData is NodeData.Paint || + fromData is NodeData.TextureLayer || + fromData is NodeData.ColorFunction || + fromData is NodeData.Family + val toIsStructural = + toData is NodeData.Tip || + toData is NodeData.Coat || + toData is NodeData.Paint || + toData is NodeData.TextureLayer || + toData is NodeData.ColorFunction || + toData is NodeData.Family + + val toPort = to.getVisiblePorts(graph).find { it.id == toPortId } + + return when (toData) { + is NodeData.Coat -> { + val coatData = toData + if (toPortId == coatData.tipPortId) { + if (fromData is NodeData.Tip) { + null + } else { + DisplayText.Resource(R.string.bg_err_coat_only_accepts_tip) + } + } else if (coatData.paintPortIds.contains(toPortId) || toPort is Port.AddPaint) { + if (fromData is NodeData.Paint) { + null + } else { + DisplayText.Resource(R.string.bg_err_coat_only_accepts_paint) + } + } else { + DisplayText.Resource(R.string.bg_err_invalid_port_coat) + } + } + + is NodeData.Family -> { + val familyData = toData + if (familyData.coatPortIds.contains(toPortId) || toPort is Port.AddCoat) { + if (fromData is NodeData.Coat) { + null + } else { + DisplayText.Resource(R.string.bg_err_family_only_accepts_coat) + } + } else { + DisplayText.Resource(R.string.bg_err_invalid_port_family) + } + } + + is NodeData.Tip -> { + if ( + !(fromData is NodeData.Behavior) || + (fromData.node.nodeCase != ProtoBrushBehavior.Node.NodeCase.TARGET_NODE && + fromData.node.nodeCase != ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) + ) { + DisplayText.Resource(R.string.bg_err_tip_only_accepts_target) + } else { + null + } + } + + is NodeData.Paint -> { + if (toData.texturePortIds.contains(toPortId) || toPort is Port.AddTexture) { + if (fromData is NodeData.TextureLayer) { + null + } else { + DisplayText.Resource(R.string.bg_err_paint_only_accepts_texture) + } + } else if (toData.colorPortIds.contains(toPortId) || toPort is Port.AddColor) { + if (fromData is NodeData.ColorFunction) { + null + } else { + DisplayText.Resource(R.string.bg_err_paint_only_accepts_color) + } + } else { + DisplayText.Resource(R.string.bg_err_invalid_port_paint) + } + } + + is NodeData.TextureLayer -> DisplayText.Resource(R.string.bg_err_texture_cannot_accept_inputs) + is NodeData.ColorFunction -> DisplayText.Resource(R.string.bg_err_color_cannot_accept_inputs) + else -> { + // 'to' is a behavior node. + if ( + fromData is NodeData.Behavior && + (fromData.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.TARGET_NODE || + fromData.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) + ) { + // Targets can only connect to Tip. + DisplayText.Resource( + R.string.bg_err_behavior_cannot_accept, + listOf( + DisplayText.Resource(toData.title()), + DisplayText.Resource(fromData.title()) + ) + ) + } else if (!fromIsStructural && !toIsStructural) { + null + } else { + DisplayText.Resource( + R.string.bg_err_behavior_cannot_accept_structural, + listOf( + DisplayText.Resource(toData.title()), + DisplayText.Resource(fromData.title()) + ) + ) + } + } } - } } - return issues.distinct() - } - - /** Returns a failure message when a connection from [from] to [to] at [toPortId] is invalid. */ - fun isValidConnection(from: GraphNode, to: GraphNode, toPortId: String, graph: BrushGraph = BrushGraph()): DisplayText? { - val fromData = from.data - val toData = to.data - val fromIsStructural = - fromData is NodeData.Tip || - fromData is NodeData.Coat || - fromData is NodeData.Paint || - fromData is NodeData.TextureLayer || - fromData is NodeData.ColorFunction || - fromData is NodeData.Family - val toIsStructural = - toData is NodeData.Tip || - toData is NodeData.Coat || - toData is NodeData.Paint || - toData is NodeData.TextureLayer || - toData is NodeData.ColorFunction || - toData is NodeData.Family - - val toPort = to.getVisiblePorts(graph).find { it.id == toPortId } - - return when (toData) { - is NodeData.Coat -> { - val coatData = toData - if (toPortId == coatData.tipPortId) { - if (fromData is NodeData.Tip) { - null - } else { - DisplayText.Resource(R.string.bg_err_coat_only_accepts_tip) - } - } else if (coatData.paintPortIds.contains(toPortId) || toPort is Port.AddPaint) { - if (fromData is NodeData.Paint) { - null - } else { - DisplayText.Resource(R.string.bg_err_coat_only_accepts_paint) - } - } else { - DisplayText.Resource(R.string.bg_err_invalid_port_coat) + /** Returns the set of node IDs for active (not disabled) nodes in the [BrushGraph] */ + private fun findActiveNodes(graph: BrushGraph): Set { + val familyNode = graph.nodes.find { it.data is NodeData.Family } ?: return emptySet() + if (familyNode.isDisabled) return emptySet() + val active = mutableSetOf(familyNode.id) + val queue = mutableListOf(familyNode.id) + while (queue.isNotEmpty()) { + val currentId = queue.removeAt(0) + val currentNode = graph.nodes.find { it.id == currentId } + val isPassThrough = currentNode != null && currentNode.isDisabled && + currentNode.data is NodeData.Behavior && currentNode.data.isOperator + + val currentNodeData = currentNode?.data as? NodeData.Behavior + val firstPortId = currentNodeData?.inputPortIds?.firstOrNull() + + for (edge in graph.edges.filter { !it.isDisabled && it.toNodeId == currentId }) { + if (isPassThrough && edge.toPortId != firstPortId) continue + + val fromNode = graph.nodes.find { it.id == edge.fromNodeId } + if (fromNode != null) { + val isFromPassThrough = fromNode.isDisabled && + fromNode.data is NodeData.Behavior && fromNode.data.isOperator + + if (!fromNode.isDisabled || isFromPassThrough) { + if (active.add(edge.fromNodeId)) queue.add(edge.fromNodeId) + } + } + } } - } - is NodeData.Family -> { - val familyData = toData - if (familyData.coatPortIds.contains(toPortId) || toPort is Port.AddCoat) { - if (fromData is NodeData.Coat) { - null - } else { - DisplayText.Resource(R.string.bg_err_family_only_accepts_coat) - } - } else { - DisplayText.Resource(R.string.bg_err_invalid_port_family) + return active + } + + /** Returns input nodes to disabled node, or returns the node itself. This logic enables data + * incoming to disabled nodes to "pass through" to where the disabled node is going. + */ + fun findActualSourceNode(graph: BrushGraph, nodeId: String): List { + val node = graph.nodes.find { it.id == nodeId } ?: return emptyList() + if (!node.isDisabled) return listOf(node) + if (node.data is NodeData.Behavior && node.data.isOperator) { + val incomingEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == nodeId } + val sources = mutableListOf() + for (edge in incomingEdges) { + sources.addAll(findActualSourceNode(graph, edge.fromNodeId)) + } + return sources } - } - is NodeData.Tip -> { - if ( - !(fromData is NodeData.Behavior) || - (fromData.node.nodeCase != ProtoBrushBehavior.Node.NodeCase.TARGET_NODE && - fromData.node.nodeCase != ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) - ) { - DisplayText.Resource(R.string.bg_err_tip_only_accepts_target) - } else { - null + return emptyList() + } + + private fun checkCycle( + nodeId: String, + graph: BrushGraph, + visited: MutableSet, + path: MutableSet, + ) { + if (!path.add(nodeId)) { + throw GraphValidationException( + displayMessage = DisplayText.Resource( + R.string.bg_err_cycle_detected, + listOf(nodeId) + ), nodeId = nodeId + ) } - } - is NodeData.Paint -> { - if (toData.texturePortIds.contains(toPortId) || toPort is Port.AddTexture) { - if (fromData is NodeData.TextureLayer) { - null - } else { - DisplayText.Resource(R.string.bg_err_paint_only_accepts_texture) - } - } else if (toData.colorPortIds.contains(toPortId) || toPort is Port.AddColor) { - if (fromData is NodeData.ColorFunction) { - null - } else { - DisplayText.Resource(R.string.bg_err_paint_only_accepts_color) - } - } else { - DisplayText.Resource(R.string.bg_err_invalid_port_paint) - } - } - is NodeData.TextureLayer -> DisplayText.Resource(R.string.bg_err_texture_cannot_accept_inputs) - is NodeData.ColorFunction -> DisplayText.Resource(R.string.bg_err_color_cannot_accept_inputs) - else -> { - // 'to' is a behavior node. - if ( - fromData is NodeData.Behavior && - (fromData.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.TARGET_NODE || - fromData.node.nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE) - ) { - // Targets can only connect to Tip. - DisplayText.Resource( - R.string.bg_err_behavior_cannot_accept, - listOf(DisplayText.Resource(toData.title()), DisplayText.Resource(fromData.title())) - ) - } else if (!fromIsStructural && !toIsStructural) { - null - } else { - DisplayText.Resource( - R.string.bg_err_behavior_cannot_accept_structural, - listOf(DisplayText.Resource(toData.title()), DisplayText.Resource(fromData.title())) - ) + visited.add(nodeId) + for (edge in graph.edges.filter { it.fromNodeId == nodeId }) { + checkCycle(edge.toNodeId, graph, visited, path) } - } + path.remove(nodeId) } - } - - /** Returns the set of node IDs for active (not disabled) nodes in the [BrushGraph] */ - private fun findActiveNodes(graph: BrushGraph): Set { - val familyNode = graph.nodes.find { it.data is NodeData.Family } ?: return emptySet() - if (familyNode.isDisabled) return emptySet() - val active = mutableSetOf(familyNode.id) - val queue = mutableListOf(familyNode.id) - while (queue.isNotEmpty()) { - val currentId = queue.removeAt(0) - val currentNode = graph.nodes.find { it.id == currentId } - val isPassThrough = currentNode != null && currentNode.isDisabled && - currentNode.data is NodeData.Behavior && currentNode.data.isOperator - - val currentNodeData = currentNode?.data as? NodeData.Behavior - val firstPortId = currentNodeData?.inputPortIds?.firstOrNull() - - for (edge in graph.edges.filter { !it.isDisabled && it.toNodeId == currentId }) { - if (isPassThrough && edge.toPortId != firstPortId) continue - - val fromNode = graph.nodes.find { it.id == edge.fromNodeId } - if (fromNode != null) { - val isFromPassThrough = fromNode.isDisabled && - fromNode.data is NodeData.Behavior && fromNode.data.isOperator - - if (!fromNode.isDisabled || isFromPassThrough) { - if (active.add(edge.fromNodeId)) queue.add(edge.fromNodeId) - } + + private fun findOpacityTargetNodes( + nodeId: String, + graph: BrushGraph, + visited: MutableSet, + results: MutableList, + ) { + if (!visited.add(nodeId)) return + val node = graph.nodes.find { it.id == nodeId } ?: return + if (node.isDisabled) return + + if (node.data is NodeData.Behavior) { + val brushNode = node.data.node + if ( + brushNode.hasTargetNode() && + brushNode.targetNode.target == ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER + ) { + results.add(node) + } } - } - } - return active - } - - /** Returns input nodes to disabled node, or returns the node itself. This logic enables data - * incoming to disabled nodes to "pass through" to where the disabled node is going. - */ - fun findActualSourceNode(graph: BrushGraph, nodeId: String): List { - val node = graph.nodes.find { it.id == nodeId } ?: return emptyList() - if (!node.isDisabled) return listOf(node) - if (node.data is NodeData.Behavior && node.data.isOperator) { - val incomingEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == nodeId } - val sources = mutableListOf() - for (edge in incomingEdges) { - sources.addAll(findActualSourceNode(graph, edge.fromNodeId)) - } - return sources - } - return emptyList() - } - - private fun checkCycle( - nodeId: String, - graph: BrushGraph, - visited: MutableSet, - path: MutableSet, - ) { - if (!path.add(nodeId)) { - throw GraphValidationException(displayMessage = DisplayText.Resource(R.string.bg_err_cycle_detected, listOf(nodeId)), nodeId = nodeId) - } - visited.add(nodeId) - for (edge in graph.edges.filter { it.fromNodeId == nodeId }) { - checkCycle(edge.toNodeId, graph, visited, path) - } - path.remove(nodeId) - } - - private fun findOpacityTargetNodes( - nodeId: String, - graph: BrushGraph, - visited: MutableSet, - results: MutableList, - ) { - if (!visited.add(nodeId)) return - val node = graph.nodes.find { it.id == nodeId } ?: return - if (node.isDisabled) return - - if (node.data is NodeData.Behavior) { - val brushNode = node.data.node - if ( - brushNode.hasTargetNode() && - brushNode.targetNode.target == ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER - ) { - results.add(node) - } - } - val incomingEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == nodeId } - for (edge in incomingEdges) { - findOpacityTargetNodes(edge.fromNodeId, graph, visited, results) + val incomingEdges = graph.edges.filter { !it.isDisabled && it.toNodeId == nodeId } + for (edge in incomingEdges) { + findOpacityTargetNodes(edge.fromNodeId, graph, visited, results) + } } - } } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/data/TutorialStep.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/data/TutorialStep.kt index ad4ff65e..a373f9ab 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/data/TutorialStep.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/TutorialStep.kt @@ -1,17 +1,17 @@ /* - * * 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. + * 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.data @@ -26,7 +26,7 @@ data class TutorialStep( val message: Int, val anchor: TutorialAnchor, val actionRequired: TutorialAction, - val getTargetNode: (BrushGraph) -> GraphNode? = { null } + val getTargetNode: (BrushGraph) -> GraphNode? = { null }, ) enum class TutorialAnchor { @@ -178,7 +178,7 @@ val TUTORIAL_STEPS = listOf( getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Behavior && - it.data.node.nodeCase == BrushBehavior.Node.NodeCase.TARGET_NODE + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.TARGET_NODE } } ), @@ -208,7 +208,7 @@ val TUTORIAL_STEPS = listOf( getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Behavior && - it.data.node.nodeCase == BrushBehavior.Node.NodeCase.SOURCE_NODE + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.SOURCE_NODE } } ), @@ -286,7 +286,7 @@ val TUTORIAL_STEPS = listOf( getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Behavior && - it.data.node.nodeCase == BrushBehavior.Node.NodeCase.SOURCE_NODE + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.SOURCE_NODE } } ), @@ -328,7 +328,7 @@ val TUTORIAL_STEPS = listOf( getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Behavior && - it.data.node.nodeCase == BrushBehavior.Node.NodeCase.RESPONSE_NODE + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.RESPONSE_NODE } } ), @@ -346,7 +346,7 @@ val TUTORIAL_STEPS = listOf( getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Behavior && - it.data.node.nodeCase == BrushBehavior.Node.NodeCase.BINARY_OP_NODE + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.BINARY_OP_NODE } } ), @@ -389,7 +389,7 @@ val TUTORIAL_STEPS = listOf( getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Behavior && - it.data.node.nodeCase == BrushBehavior.Node.NodeCase.BINARY_OP_NODE + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.BINARY_OP_NODE } } ), @@ -579,7 +579,7 @@ val TUTORIAL_STEPS = listOf( getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Behavior && - it.data.node.nodeCase == BrushBehavior.Node.NodeCase.TARGET_NODE + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.TARGET_NODE } } ), 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 new file mode 100644 index 00000000..2ec485b2 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphContent.kt @@ -0,0 +1,101 @@ +/* + * 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.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import androidx.window.core.layout.WindowWidthSizeClass + +@Composable +fun BrushGraphContent( + isNodeSelected: Boolean, + isEdgeSelected: Boolean, + isErrorPaneOpen: Boolean, + isPreviewExpanded: Boolean, + viewportSize: Size, + onViewportSizeChange: (Size) -> Unit, + canvasSlot: @Composable (trashPaddingBottom: Dp) -> Unit, + inspectorSlot: @Composable () -> Unit, + notificationPaneSlot: @Composable () -> Unit, + notificationIconSlot: @Composable (indicatorPaddingEnd: Dp) -> Unit, + previewSlot: @Composable () -> Unit, + menuSlot: @Composable () -> Unit, + fabSlot: @Composable (viewportSize: Size) -> Unit, + tutorialSlot: @Composable (viewportSize: Size) -> Unit, + dialogSlot: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + dialogSlot() + + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isWideScreen = windowSizeClass.windowWidthSizeClass != WindowWidthSizeClass.COMPACT + + val isSidePaneOpen = isWideScreen && (isNodeSelected || isEdgeSelected || isErrorPaneOpen) + val indicatorPaddingEnd by animateDpAsState( + 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 + } else { + (previewHeight + 16).dp + }, + label = "trashPaddingBottom", + ) + + Scaffold(modifier = modifier.fillMaxSize()) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .onGloballyPositioned { coordinates -> + onViewportSizeChange(coordinates.size.toSize()) + } + ) { + canvasSlot(trashPaddingBottom) + inspectorSlot() + notificationPaneSlot() + notificationIconSlot(indicatorPaddingEnd) + previewSlot() + menuSlot() + fabSlot(viewportSize) + tutorialSlot(viewportSize) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphDialogs.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphDialogs.kt new file mode 100644 index 00000000..cd9e009e --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphDialogs.kt @@ -0,0 +1,326 @@ +/* + * 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.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R + +@Composable +fun NameTextureDialog( + show: Boolean, + textureNameInput: String, + onTextureNameInputChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_name_texture)) }, + text = { + OutlinedTextField( + value = textureNameInput, + onValueChange = onTextureNameInputChange, + label = { Text(stringResource(R.string.bg_texture_id)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.bg_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_cancel)) + } + } + ) + } +} + +@Composable +fun SaveToPaletteDialog( + show: Boolean, + paletteBrushNameInput: String, + onPaletteBrushNameInputChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_save_to_palette)) }, + text = { + OutlinedTextField( + value = paletteBrushNameInput, + onValueChange = onPaletteBrushNameInputChange, + label = { Text(stringResource(R.string.bg_brush_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_cancel)) + } + } + ) + } +} + +@Composable +fun ClearGraphConfirmationDialog( + show: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_clear_graph)) }, + text = { + Text(stringResource(R.string.bg_clear_graph_confirmation)) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.clear)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_cancel)) + } + } + ) + } +} + +@Composable +fun TutorialWarningDialog( + show: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_start_tutorial)) }, + text = { + Text(stringResource(R.string.bg_start_tutorial_message)) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.bg_start)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_cancel)) + } + } + ) + } +} + +@Composable +fun TutorialFinishDialog( + show: Boolean, + onKeepChanges: () -> Unit, + onRestoreOriginal: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_exit_tutorial)) }, + text = { + Text(stringResource(R.string.bg_exit_tutorial_message)) + }, + confirmButton = { + Button(onClick = onKeepChanges) { + Text(stringResource(R.string.bg_keep_tutorial_brush)) + } + }, + dismissButton = { + Button(onClick = onRestoreOriginal) { + Text(stringResource(R.string.bg_restore_original_brush)) + } + } + ) + } +} + +@Composable +fun OptionsDialog( + show: Boolean, + textFieldsLocked: Boolean, + onToggleTextFieldsLocked: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_options)) }, + text = { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Text( + stringResource(R.string.bg_lock_text_fields), + modifier = Modifier.weight(1f) + ) + Switch( + checked = textFieldsLocked, + onCheckedChange = { onToggleTextFieldsLocked() } + ) + } + } + }, + confirmButton = { + Button(onClick = onDismiss) { + Text(stringResource(R.string.bg_ok)) + } + } + ) + } +} + +@Composable +fun ReorganizeConfirmationDialog( + show: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_reorganize_graph)) }, + text = { + Text(stringResource(R.string.bg_reorganize_graph_confirmation)) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.bg_reorganize)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_cancel)) + } + } + ) + } +} + +@Composable +internal fun TooltipDialog( + title: String, + text: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(text) }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_ok)) + } + } + ) +} + +@Composable +internal fun FieldWithTooltip( + tooltipTitle: String, + tooltipText: String, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + var showTooltip by remember { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth() + ) { + Box(modifier = Modifier.weight(1f)) { + content() + } + IconButton(onClick = { showTooltip = true }) { + Icon( + Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(R.string.bg_cd_help) + ) + } + } + if (showTooltip) { + TooltipDialog( + title = tooltipTitle, + text = tooltipText, + onDismiss = { showTooltip = false } + ) + } +} 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 new file mode 100644 index 00000000..4703974a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphMenus.kt @@ -0,0 +1,516 @@ +/* + * 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.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +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.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +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.DpOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.ink.brush.BrushFamily +import androidx.ink.brush.StockBrushes +import com.example.cahier.R +import com.example.cahier.core.ui.LocalTextureStore +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import com.example.cahier.developer.brushgraph.data.TutorialStep +import com.example.cahier.features.drawing.CustomBrushes + +@Composable +fun MoreOptionsMenu( + expanded: Boolean, + onDismiss: () -> Unit, + isTutorialSandboxMode: Boolean, + onSelectMode: () -> Unit, + onTutorialAction: () -> Unit, + onExport: () -> Unit, + onImport: () -> Unit, + onOrganize: () -> Unit, + showTemplatesMenu: Boolean, + onShowTemplatesMenuChange: (Boolean) -> Unit, + onTemplateSelect: (BrushFamily) -> Unit, + customBrushes: List>, + onCustomBrushSelect: (BrushFamily) -> Unit, + onDeleteBrush: () -> Unit, + onOptions: () -> Unit, +) { + val uriHandler = LocalUriHandler.current + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_select)) }, + onClick = onSelectMode + ) + DropdownMenuItem( + text = { + Text( + if (isTutorialSandboxMode) stringResource(R.string.bg_menu_exit_tutorial) else stringResource( + R.string.bg_menu_tutorial + ) + ) + }, + onClick = onTutorialAction + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_export)) }, + onClick = onExport + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_import)) }, + onClick = onImport + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_organize)) }, + onClick = onOrganize + ) + var itemSize by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + Box { + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_templates)) }, + onClick = { onShowTemplatesMenuChange(true) }, + trailingIcon = { Icon(Icons.Default.ChevronRight, contentDescription = null) }, + modifier = Modifier.onSizeChanged { itemSize = it } + ) + DropdownMenu( + expanded = showTemplatesMenu, + onDismissRequest = { onShowTemplatesMenuChange(false) }, + offset = with(density) { + DpOffset(x = itemSize.width.toDp(), y = -itemSize.height.toDp()) + } + ) { + listOf( + R.string.bg_pressure_pen to StockBrushes.pressurePen(), + R.string.marker to StockBrushes.marker(), + R.string.highlighter to StockBrushes.highlighter(), + R.string.dashed_line to StockBrushes.dashedLine() + ).forEach { (title, brush) -> + DropdownMenuItem( + text = { Text(stringResource(title)) }, + onClick = { + onTemplateSelect(brush) + onShowTemplatesMenuChange(false) + } + ) + } + + if (customBrushes.isNotEmpty()) { + HorizontalDivider() + Text( + text = stringResource(R.string.bg_menu_custom_brushes), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + customBrushes.forEach { (name, family) -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { + onCustomBrushSelect(family) + onShowTemplatesMenuChange(false) + } + ) + } + } + } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + DropdownMenuItem( + text = { + Text( + stringResource(R.string.bg_delete_brush), + color = MaterialTheme.colorScheme.error + ) + }, + onClick = onDeleteBrush + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_options)) }, + onClick = onOptions + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_menu_feedback)) }, + leadingIcon = { + Icon( + painterResource(R.drawable.outline_open_in_new_24), + contentDescription = null + ) + }, + onClick = { + onDismiss() + uriHandler.openUri("https://github.com/android/cahier/issues") + } + ) + } +} + +@Composable +fun PaletteMenu( + expanded: Boolean, + onDismiss: () -> Unit, + savedBrushes: List, + onBrushSelect: (CustomBrushEntity) -> Unit, + onBrushDelete: (CustomBrushEntity) -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + if (savedBrushes.isEmpty()) { + DropdownMenuItem( + text = { Text(stringResource(R.string.bg_no_saved_brushes)) }, + onClick = onDismiss, + ) + } else { + savedBrushes.forEach { entity -> + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(entity.name, modifier = Modifier.weight(1f)) + IconButton(onClick = { onBrushDelete(entity) }) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.bg_cd_delete) + ) + } + } + }, + onClick = { onBrushSelect(entity) } + ) + } + } + } +} + +@Composable +fun CreateNodeSpeedDial( + isWideScreen: Boolean, + isAnySidePaneOpen: Boolean, + isPreviewExpanded: Boolean, + 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 + } else { + (previewHeight + 16).dp + }, + label = "fabPaddingBottom", + ) + + val fabPaddingEnd by + animateDpAsState( + targetValue = + if (isWideScreen && isAnySidePaneOpen) { + (INSPECTOR_WIDTH_LANDSCAPE + 16).dp + } else { + 16.dp + }, + label = "fabPaddingEnd", + ) + + Box( + modifier = modifier + .padding(bottom = fabPaddingBottom, end = fabPaddingEnd) + .zIndex(2f) + ) { + Column(horizontalAlignment = Alignment.End) { + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Surface( + modifier = Modifier + .padding(bottom = 8.dp) + .width(180.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp, + shadowElevation = 8.dp, + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + menuContent { expanded = false } + } + } + } + FloatingActionButton( + onClick = { expanded = !expanded }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Icon( + if (expanded) Icons.Default.Close else Icons.Default.Add, + contentDescription = stringResource(R.string.bg_cd_create_node), + ) + } + } + } +} + +@Composable +fun GraphActionMenu( + onClose: () -> Unit, + onExport: () -> Unit, + onLoadBrushFile: () -> Unit, + onSaveToPalette: () -> Unit, + onOrganize: () -> Unit, + onDeleteBrush: () -> Unit, + onTutorialExitRequested: () -> Unit, + savedBrushes: List, + tutorialStep: TutorialStep?, + isTutorialSandboxMode: Boolean, + onEnterSelectionMode: () -> Unit, + onLoadBrushFamily: (BrushFamily) -> Unit, + onLoadFromPalette: (CustomBrushEntity) -> Unit, + onDeleteFromPalette: (String) -> Unit, + onStartTutorialSandbox: () -> Unit, + textFieldsLocked: Boolean, + onToggleTextFieldsLocked: () -> Unit, + modifier: Modifier = Modifier, +) { + val textureStore = LocalTextureStore.current + val context = LocalContext.current + var showMoreMenu by rememberSaveable { mutableStateOf(false) } + var showPaletteMenu by rememberSaveable { mutableStateOf(false) } + var showClearConfirmation by rememberSaveable { mutableStateOf(false) } + var showReorganizeConfirmation by rememberSaveable { mutableStateOf(false) } + var showTemplatesMenu by rememberSaveable { mutableStateOf(false) } + var showOptionsDialog by rememberSaveable { mutableStateOf(false) } + var showTutorialWarningDialog by rememberSaveable { mutableStateOf(false) } + + + ClearGraphConfirmationDialog( + show = showClearConfirmation, + onDismiss = { showClearConfirmation = false }, + onConfirm = { + onDeleteBrush() + showClearConfirmation = false + } + ) + + LaunchedEffect(tutorialStep) { + if (isTutorialSandboxMode && tutorialStep == null) { + onTutorialExitRequested() + } + } + + TutorialWarningDialog( + show = showTutorialWarningDialog, + onDismiss = { showTutorialWarningDialog = false }, + onConfirm = { + onStartTutorialSandbox() + showTutorialWarningDialog = false + } + ) + + OptionsDialog( + show = showOptionsDialog, + onDismiss = { showOptionsDialog = false }, + textFieldsLocked = textFieldsLocked, + onToggleTextFieldsLocked = onToggleTextFieldsLocked + ) + + ReorganizeConfirmationDialog( + show = showReorganizeConfirmation, + onDismiss = { showReorganizeConfirmation = false }, + onConfirm = { + onOrganize() + showReorganizeConfirmation = false + } + ) + + Surface( + modifier = modifier, + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + tonalElevation = 4.dp, + shadowElevation = 8.dp, + ) { + Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = onClose, + colors = IconButtonDefaults.iconButtonColors(containerColor = Color.Transparent), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.bg_cd_exit) + ) + } + + VerticalDivider( + modifier = Modifier + .height(24.dp) + .padding(horizontal = 4.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + + Box { + IconButton(onClick = { showMoreMenu = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.bg_cd_more_options) + ) + } + + MoreOptionsMenu( + expanded = showMoreMenu, + onDismiss = { showMoreMenu = false }, + isTutorialSandboxMode = isTutorialSandboxMode, + onSelectMode = { + onEnterSelectionMode() + showTemplatesMenu = false + showMoreMenu = false + }, + onTutorialAction = { + showMoreMenu = false + if (isTutorialSandboxMode) { + onTutorialExitRequested() + } else { + showTutorialWarningDialog = true + } + }, + onExport = { + showMoreMenu = false + onExport() + }, + onImport = { + showMoreMenu = false + onLoadBrushFile() + }, + onOrganize = { + showMoreMenu = false + showReorganizeConfirmation = true + }, + showTemplatesMenu = showTemplatesMenu, + onShowTemplatesMenuChange = { showTemplatesMenu = it }, + onTemplateSelect = { family -> + onLoadBrushFamily(family) + showTemplatesMenu = false + showMoreMenu = false + }, + customBrushes = CustomBrushes.getBrushes(context, textureStore) + .map { it.name to it.brushFamily }, + onCustomBrushSelect = { family -> + onLoadBrushFamily(family) + showTemplatesMenu = false + showMoreMenu = false + }, + onDeleteBrush = { + showMoreMenu = false + showClearConfirmation = true + }, + onOptions = { + showMoreMenu = false + showOptionsDialog = true + } + ) + } + + Box { + TextButton(onClick = { showPaletteMenu = true }) { + Text(stringResource(R.string.bg_my_palette)) + } + + PaletteMenu( + expanded = showPaletteMenu, + onDismiss = { showPaletteMenu = false }, + savedBrushes = savedBrushes, + onBrushSelect = { entity -> + onLoadFromPalette(entity) + showPaletteMenu = false + }, + onBrushDelete = { entity -> + onDeleteFromPalette(entity.name) + } + ) + } + + Spacer(Modifier.width(8.dp)) + + Button( + onClick = onSaveToPalette, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.height(40.dp), + ) { + Text(stringResource(R.string.bg_save_to_palette)) + } + } + } +} 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 new file mode 100644 index 00000000..8f0be17f --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphScreen.kt @@ -0,0 +1,748 @@ +/* + * 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 android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.ShapeLine +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.ink.brush.Version +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.storage.AndroidBrushFamilySerialization +import androidx.ink.storage.BrushFamilyDecodeCallback +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowWidthSizeClass +import com.example.cahier.R +import com.example.cahier.core.ui.LocalTextureStore +import com.example.cahier.core.ui.theme.CahierAppTheme +import com.example.cahier.developer.brushdesigner.ui.CustomColorPickerDialog +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.developer.brushgraph.data.GraphPoint +import com.example.cahier.developer.brushgraph.data.TutorialAction +import com.example.cahier.developer.brushgraph.data.ValidationSeverity +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import com.example.cahier.developer.brushgraph.data.inferNodeData +import com.example.cahier.developer.brushgraph.ui.node.NodeRegistry +import com.example.cahier.developer.brushgraph.viewmodel.BrushGraphViewModel +import kotlinx.coroutines.launch + +/** The main UI for the Brush Graph studio. */ +@Composable +fun BrushGraphScreen( + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + val viewModel: BrushGraphViewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isWideScreen = windowSizeClass.windowWidthSizeClass != WindowWidthSizeClass.COMPACT + val isTallAndWide = + isWideScreen && windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.EXPANDED + + val textureStore = LocalTextureStore.current + val cacheGen by textureStore.generation.collectAsState() + val renderer = remember(cacheGen) { CanvasStrokeRenderer.create(textureStore) } + + val primaryColor = MaterialTheme.colorScheme.primary + 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. + if (uiState.testBrushColor == null) + viewModel.updateTestBrushColor(primaryColor) + } + + var showColorPicker by remember { mutableStateOf(false) } + var colorPickerInitialColor by remember { mutableStateOf(onSurfaceColor) } + var colorPickerOnColorSelected by remember { mutableStateOf({ _: Color -> }) } + + if (showColorPicker) { + CustomColorPickerDialog( + initialColor = colorPickerInitialColor, + onColorSelected = colorPickerOnColorSelected, + onDismissRequest = { showColorPicker = false } + ) + } + + // Texture picking logic + var showTextureNameDialog by remember { mutableStateOf(false) } + var pendingTextureUri by remember { mutableStateOf(null) } + var textureNameInput by remember { mutableStateOf("") } + + val texturePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null) { + pendingTextureUri = uri + showTextureNameDialog = true + } + } + + NameTextureDialog( + show = showTextureNameDialog, + onDismiss = { showTextureNameDialog = false }, + textureNameInput = textureNameInput, + onTextureNameInputChange = { textureNameInput = it }, + onConfirm = { + if (textureNameInput.isNotBlank() && pendingTextureUri != null) { + val uri = pendingTextureUri!! + val name = textureNameInput + scope.launch { + val bitmap = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it) + } + } + if (bitmap != null) { + viewModel.loadTexture(name, bitmap) + } + showTextureNameDialog = false + textureNameInput = "" + } + } + } + ) + + val brushFilePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + uri?.let { + scope.launch { + try { + val family = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + context.contentResolver.openInputStream(it)?.use { stream -> + try { + AndroidBrushFamilySerialization.decode( + stream, + maxVersion = Version.DEVELOPMENT, + BrushFamilyDecodeCallback { id: String, bitmap: Bitmap? -> + if (bitmap != null) { + viewModel.loadTexture(id, bitmap) + } + id + } + ) + } catch (e: Exception) { + Log.d( + "BrushGraphWidget", + "Failed to decode with AndroidBrushFamilySerialization, trying legacy fallback" + ) + null + } + } + } + + if (family == null) { + Log.d( + "BrushGraphWidget", + "Failed to decode with AndroidBrushFamilySerialization, and legacy fallback is disabled." + ) + viewModel.postDebug(DisplayText.Resource(R.string.bg_err_load_brush)) + } else { + viewModel.loadBrushFamily(family) + viewModel.postDebug(DisplayText.Resource(R.string.bg_msg_brush_loaded_success)) + } + } catch (e: Exception) { + android.util.Log.e("BrushGraphWidget", "Failed to load brush", e) + viewModel.postDebug( + DisplayText.Resource( + R.string.bg_err_load_brush_failed_with_msg, + listOf(e.message ?: "") + ) + ) + } + } + } + } + + val brushExportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri: Uri? -> + uri?.let { + scope.launch { + try { + context.contentResolver.openOutputStream(it)?.use { outputStream -> + AndroidBrushFamilySerialization.encode( + viewModel.brush.value.family, + outputStream, + textureStore + ) + } + viewModel.postDebug(DisplayText.Resource(R.string.bg_msg_brush_exported_success)) + } catch (e: Exception) { + android.util.Log.e("BrushGraphWidget", "Failed to export brush", e) + viewModel.postDebug( + DisplayText.Resource( + R.string.bg_err_export_brush_failed_with_msg, + listOf(e.message ?: "") + ) + ) + } + } + } + } + + // Save to palette logic + var showSavePaletteDialog by remember { mutableStateOf(false) } + var paletteBrushNameInput by remember { mutableStateOf("") } + + SaveToPaletteDialog( + show = showSavePaletteDialog, + onDismiss = { showSavePaletteDialog = false }, + paletteBrushNameInput = paletteBrushNameInput, + onPaletteBrushNameInputChange = { paletteBrushNameInput = it }, + onConfirm = { + if (paletteBrushNameInput.isNotBlank()) { + viewModel.saveToPalette(paletteBrushNameInput) + showSavePaletteDialog = false + paletteBrushNameInput = "" + } + } + ) + + CahierAppTheme { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + var viewportSize by remember { mutableStateOf(androidx.compose.ui.geometry.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", + ) + val previewHeight = if (uiState.isPreviewExpanded) { + PREVIEW_HEIGHT_EXPANDED + } else { + PREVIEW_HEIGHT_COLLAPSED + } + val animatedPreviewHeight by animateDpAsState( + targetValue = previewHeight.dp, + label = "animatedPreviewHeight" + ) + 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 nodeRegistry = remember { NodeRegistry() } + val issues = uiState.graphIssues + + LaunchedEffect(uiState.graph) { + val missingNodes = + uiState.graph.nodes.filter { nodeRegistry.getNodePosition(it.id) == null } + if (missingNodes.isNotEmpty()) { + val layout = GraphLayout.calculateLayout(uiState.graph) + layout.forEach { (id, pos) -> + if (nodeRegistry.getNodePosition(id) == null) { + nodeRegistry.updateNodePosition(id, pos) + } + } + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + BrushGraphContent( + isNodeSelected = isNodeSelected, + isEdgeSelected = isEdgeSelected, + isErrorPaneOpen = isErrorPaneOpen, + isPreviewExpanded = uiState.isPreviewExpanded, + viewportSize = viewportSize, + onViewportSizeChange = { viewportSize = it }, + canvasSlot = { padding -> + GraphCanvas( + graph = uiState.graph, + zoom = uiState.zoom, + offset = Offset(uiState.offset.x, uiState.offset.y), + onZoomChange = { viewModel.updateZoom(it) }, + onOffsetChange = { viewModel.updateOffset(GraphPoint(it.x, it.y)) }, + onNodeMoveFinished = { viewModel.advanceTutorial(TutorialAction.MOVE_NODE) }, + onNodeClick = { id, _ -> + if (uiState.isSelectionMode) { + viewModel.toggleNodeSelection(id) + } else { + viewModel.onNodeClick(id) + } + }, + onNodeLongPress = { id -> viewModel.enterSelectionMode(id) }, + onNodeDelete = { id -> viewModel.deleteNode(id) }, + isSelectionMode = uiState.isSelectionMode, + selectedNodeIds = uiState.selectedNodeIds, + onSelectAll = { viewModel.selectAllNodes() }, + onDuplicateSelected = { + val idMap = viewModel.duplicateSelectedNodes() + idMap.forEach { (oldId, newId) -> + val oldPos = nodeRegistry.getNodePosition(oldId) + if (oldPos != null) { + nodeRegistry.updateNodePosition( + newId, + oldPos + Offset(50f, 50f) + ) + } + } + }, + onDeleteSelected = { viewModel.deleteSelectedNodes() }, + onDoneSelection = { viewModel.exitSelectionMode() }, + onAddEdge = { from, to, portId -> viewModel.addEdge(from, to, portId) }, + onEdgeClick = { viewModel.onEdgeClick(it) }, + onEdgeDelete = { viewModel.deleteEdge(it) }, + onEdgeDetach = { viewModel.detachEdge(it) }, + onFinalizeEdgeEdit = { oldEdge, fromId, toId, portId -> + viewModel.finalizeEdgeEdit( + oldEdge, + fromId, + toId, + portId + ) + }, + onCanvasClick = { viewModel.dismissPanes() }, + onPortClick = { nodeId, port -> + val node = uiState.graph.nodes.find { it.id == nodeId } + val nodeData = node?.let { port.inferNodeData(it) } + if (nodeData != null) { + val portPos = + nodeRegistry.getPortPosition(nodeId, port.id, uiState.graph) + val nodePos = + nodeRegistry.getNodePosition(nodeId) ?: Offset.Zero + val newX = nodePos.x - nodeData.width() - 100f + val newY = portPos.y - nodeData.height() / 2f + val newNodeId = + viewModel.addNodeAndConnect(nodeData, nodeId, port.id) + nodeRegistry.updateNodePosition(newNodeId, Offset(newX, newY)) + } + }, + onReorderPorts = { nodeId, fromIndex, toIndex -> + viewModel.reorderPorts( + nodeId, + fromIndex, + toIndex + ) + }, + nodeRegistry = nodeRegistry, + selectedEdge = uiState.selectedEdge, + detachedEdge = uiState.detachedEdge, + strokeRenderer = renderer, + selectedNodeId = uiState.selectedNodeId, + brush = viewModel.brush.collectAsStateWithLifecycle().value, + bottomPadding = padding, + rightPadding = if (isSidePaneOpen) INSPECTOR_WIDTH_LANDSCAPE.dp else 0.dp, + ) + }, + inspectorSlot = { + val selectedNode = + uiState.graph.nodes.find { it.id == uiState.selectedNodeId } + val selectedEdge = uiState.selectedEdge + val selectionName = if (selectedNode != null) { + stringResource(selectedNode.data.title()) + } else { + stringResource(R.string.bg_label_edge) + } + val titleText = + stringResource(R.string.bg_title_inspector_with_name, selectionName) + val selectionTooltip = + selectedNode?.data?.getTooltip()?.let { stringResource(it) } + + AdaptiveInspectorPane( + isWideScreen = isWideScreen, + visible = selectedNode != null || selectedEdge != null, + title = titleText, + tooltipText = selectionTooltip, + onClose = { + viewModel.clearSelectedNode() + viewModel.clearSelectedEdge() + }, + modifier = Modifier + .align(if (isWideScreen) Alignment.CenterEnd else Alignment.BottomCenter) + .let { + if (isTallAndWide) { + it.padding(bottom = animatedPreviewHeight) + } else { + it + } + }, + ) { + if (selectedNode != null) { + NodeInspector( + node = selectedNode, + onUpdate = { viewModel.updateNodeData(selectedNode.id, it) }, + onDisableChange = { + viewModel.setNodeDisabled( + selectedNode.id, + it + ) + }, + onChooseColor = { initialColor, onColorSelected -> + colorPickerInitialColor = initialColor + colorPickerOnColorSelected = onColorSelected + showColorPicker = true + }, + allTextureIds = uiState.allTextureIds, + onLoadTexture = { texturePickerLauncher.launch(arrayOf("image/*")) }, + strokeRenderer = renderer, + textFieldsLocked = uiState.textFieldsLocked, + onDelete = { viewModel.deleteNode(selectedNode.id) }, + onFieldEditComplete = { viewModel.advanceTutorial(TutorialAction.EDIT_FIELD) }, + onDropdownEditComplete = { + viewModel.advanceTutorial( + TutorialAction.EDIT_DROPDOWN + ) + }, + ) + } else if (selectedEdge != null) { + val fromNode = + uiState.graph.nodes.find { it.id == selectedEdge.fromNodeId } + val toNode = + uiState.graph.nodes.find { it.id == selectedEdge.toNodeId } + if (fromNode != null && toNode != null) { + val visiblePorts = toNode.getVisiblePorts(uiState.graph) + val port = visiblePorts.find { it.id == selectedEdge.toPortId } + val inputLabel = port?.label + EdgeInspector( + edge = selectedEdge, + fromNode = fromNode, + toNode = toNode, + inputLabel = inputLabel, + onNodeFocus = { nodeId: String -> + viewModel.centerNode( + nodeId + ) + }, + onDisableChange = { + viewModel.setEdgeDisabled( + selectedEdge, + it + ) + }, + onDelete = { viewModel.deleteEdge(selectedEdge) }, + onAddNodeBetween = { + val fromNodePos = + nodeRegistry.getNodePosition(selectedEdge.fromNodeId) + ?: Offset.Zero + val toNodePos = + nodeRegistry.getNodePosition(selectedEdge.toNodeId) + ?: Offset.Zero + val midpoint = (fromNodePos + toNodePos) / 2f + val newNodeId = viewModel.addNodeBetween(selectedEdge) + if (newNodeId != null) { + nodeRegistry.updateNodePosition(newNodeId, midpoint) + } + }, + ) + } + } + } + }, + notificationPaneSlot = { + NotificationPane( + isWideScreen = isWideScreen, + viewModel = viewModel, + modifier = Modifier + .align(if (isWideScreen) Alignment.CenterEnd else Alignment.BottomCenter) + .let { + if (isTallAndWide) { + it.padding(bottom = animatedPreviewHeight) + } else { + it + } + }, + ) + }, + notificationIconSlot = { padding -> + NotificationIcon( + issues = issues, + indicatorPaddingEnd = padding, + onToggleErrorPane = { viewModel.toggleErrorPane() }, + modifier = Modifier.align(Alignment.TopEnd) + ) + }, + previewSlot = { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter + ) { + val topIssue = remember(issues) { + issues.firstOrNull { it.severity == ValidationSeverity.ERROR } + ?: issues.firstOrNull { it.severity == ValidationSeverity.WARNING } + } + CollapsiblePreviewPane( + isPreviewExpanded = uiState.isPreviewExpanded, + isInvertedCanvas = uiState.isDarkCanvas, + testAutoUpdateStrokes = uiState.testAutoUpdateStrokes, + brushColor = uiState.testBrushColor ?: primaryColor, + brushSize = uiState.testBrushSize, + brush = viewModel.brush.collectAsStateWithLifecycle().value, + strokeList = viewModel.strokeList, + strokeRenderer = renderer, + topIssue = topIssue, + onGetNextBrush = { viewModel.brush.value }, + onTogglePreviewExpanded = { viewModel.togglePreviewExpanded() }, + onClearStrokes = { viewModel.clearStrokes() }, + onToggleCanvasTheme = { viewModel.toggleCanvasTheme() }, + onSetTestAutoUpdateStrokes = { viewModel.setTestAutoUpdateStrokes(it) }, + onUpdateTestBrushColor = { viewModel.updateTestBrushColor(it) }, + onUpdateTestBrushSize = { viewModel.updateTestBrushSize(it) }, + onStrokesAdded = { strokes -> + viewModel.strokeList.addAll(strokes) + viewModel.advanceTutorial(TutorialAction.DRAW_ON_CANVAS) + }, + onChooseColor = { initialColor, onColorSelected -> + colorPickerInitialColor = initialColor + colorPickerOnColorSelected = onColorSelected + showColorPicker = true + }, + onToggleNotificationPane = { viewModel.toggleErrorPane() } + ) + } + }, + menuSlot = { + GraphActionMenu( + onClose = onNavigateUp, + onExport = { + brushExportLauncher.launch("brush_${System.currentTimeMillis()}.brushfamily") + }, + onLoadBrushFile = { brushFilePickerLauncher.launch(arrayOf("*/*")) }, + onSaveToPalette = { + paletteBrushNameInput = "" + showSavePaletteDialog = true + }, + onOrganize = viewModel::reorganize, + onDeleteBrush = { viewModel.clearGraph() }, + onTutorialExitRequested = { showTutorialFinishDialog = true }, + savedBrushes = viewModel.savedPaletteBrushes.collectAsStateWithLifecycle().value, + tutorialStep = viewModel.tutorialStep, + isTutorialSandboxMode = viewModel.isTutorialSandboxMode, + onEnterSelectionMode = { viewModel.enterSelectionMode(null) }, + onLoadBrushFamily = { viewModel.loadBrushFamily(it) }, + onLoadFromPalette = { viewModel.loadFromPalette(it) }, + onDeleteFromPalette = { viewModel.deleteFromPalette(it) }, + onStartTutorialSandbox = { viewModel.startTutorialSandbox() }, + textFieldsLocked = uiState.textFieldsLocked, + onToggleTextFieldsLocked = { viewModel.toggleTextFieldsLocked() }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp) + .zIndex(2f), + ) + }, + fabSlot = { vSize -> + val density = LocalDensity.current.density + val previewHeight = + if (uiState.isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED + val isInspectorOpen = + (uiState.selectedNodeId != null || uiState.selectedEdge != null) + val isErrorPaneOpen = uiState.isErrorPaneOpen + val isAnySidePaneOpen = isInspectorOpen || isErrorPaneOpen + + val inspectorWidthPx = INSPECTOR_WIDTH_LANDSCAPE * density + val inspectorHeightPx = INSPECTOR_HEIGHT_PORTRAIT * density + val previewHeightPx = previewHeight * density + + val (visibleWidth, visibleHeight) = + if (isWideScreen) { + val w = + if (isAnySidePaneOpen) vSize.width - inspectorWidthPx else vSize.width + val h = vSize.height - previewHeightPx + w to h + } else { + val w = vSize.width + val h = if (isAnySidePaneOpen) vSize.height - maxOf( + previewHeightPx, + inspectorHeightPx + ) else vSize.height - previewHeightPx + w to h + } + + val visibleCenter = Offset(visibleWidth / 2f, visibleHeight / 2f) + val centerInCanvas = (visibleCenter - Offset( + uiState.offset.x, + uiState.offset.y + )) / uiState.zoom + + CreateNodeSpeedDial( + isWideScreen = isWideScreen, + isAnySidePaneOpen = isAnySidePaneOpen, + isPreviewExpanded = uiState.isPreviewExpanded, + viewportSize = vSize, + modifier = Modifier.align(Alignment.BottomEnd), + menuContent = { onClose -> + data class SpeedDialAction( + val labelRes: Int, + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val onClick: () -> Unit, + ) + + val actions = remember(centerInCanvas) { + listOf( + SpeedDialAction(R.string.bg_coat, Icons.Default.Layers) { + val id = viewModel.addCoatNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + SpeedDialAction(R.string.bg_paint, Icons.Default.Palette) { + val id = viewModel.addPaintNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + SpeedDialAction(R.string.bg_tip, Icons.Default.ShapeLine) { + val id = viewModel.addTipNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + SpeedDialAction( + R.string.bg_behavior, + Icons.Default.Psychology + ) { + val id = viewModel.addBehaviorNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + SpeedDialAction( + R.string.bg_color_function, + Icons.Default.Palette + ) { + val id = viewModel.addColorFunctionNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + SpeedDialAction( + R.string.bg_texture_layer, + Icons.Default.Layers + ) { + val id = viewModel.addTextureLayerNode() + nodeRegistry.updateNodePosition(id, centerInCanvas) + }, + ) + } + + actions.forEach { action -> + DropdownMenuItem( + text = { Text(stringResource(action.labelRes)) }, + leadingIcon = { + Icon( + action.icon, + contentDescription = null + ) + }, + onClick = { + action.onClick() + onClose() + } + ) + } + } + ) + }, + tutorialSlot = { vSize -> + TutorialOverlayHost( + tutorialStep = viewModel.tutorialStep, + graph = uiState.graph, + zoom = uiState.zoom, + offset = Offset(uiState.offset.x, uiState.offset.y), + selectedNodeId = uiState.selectedNodeId, + selectedEdge = uiState.selectedEdge, + currentStepIndex = viewModel.currentStepIndex, + isWideScreen = isWideScreen, + viewportSize = vSize, + isPreviewExpanded = uiState.isPreviewExpanded, + onAdvanceTutorial = { viewModel.advanceTutorial(it) }, + onRegressTutorial = { viewModel.regressTutorial() }, + onCloseTutorial = { showTutorialFinishDialog = true }, + nodeRegistry = nodeRegistry + ) + }, + dialogSlot = { + TutorialFinishDialog( + show = showTutorialFinishDialog, + onDismiss = { showTutorialFinishDialog = false }, + onKeepChanges = { + viewModel.endTutorialSandbox(keepChanges = true) + showTutorialFinishDialog = false + }, + onRestoreOriginal = { + viewModel.endTutorialSandbox(keepChanges = false) + showTutorialFinishDialog = false + } + ) + } + ) + } + + GraphCameraController( + offset = Offset(uiState.offset.x, uiState.offset.y), + tutorialStep = viewModel.tutorialStep, + focusTrigger = uiState.focusTrigger, + graph = uiState.graph, + zoom = uiState.zoom, + isPreviewExpanded = uiState.isPreviewExpanded, + 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/Dimensions.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Dimensions.kt new file mode 100644 index 00000000..dddb293a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Dimensions.kt @@ -0,0 +1,27 @@ +/* + * 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 + +const val NODE_WIDTH = 300f +const val NODE_PADDING_VERTICAL = 8f +const val NODE_PADDING_BOTTOM = 12f +const val INPUT_ROW_HEIGHT = 60f + +const val INSPECTOR_WIDTH_LANDSCAPE = 320f +const val INSPECTOR_HEIGHT_PORTRAIT = 400f +const val PREVIEW_HEIGHT_EXPANDED = 200f +const val PREVIEW_HEIGHT_COLLAPSED = 40f \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/DisplayTextExtensions.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/DisplayTextExtensions.kt new file mode 100644 index 00000000..c5831bb1 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/DisplayTextExtensions.kt @@ -0,0 +1,45 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.example.cahier.developer.brushgraph.data.DisplayText + +@Composable +fun DisplayText.asString(): String = + when (this) { + is DisplayText.Literal -> text + is DisplayText.Resource -> { + val resolvedArgs = args.map { + when (it) { + is DisplayText -> it.asString() + is List<*> -> { + val stringList = it.map { item -> + when (item) { + is DisplayText -> item.asString() + else -> item.toString() + } + } + stringList.joinToString(", ") + } + + else -> it + } + } + stringResource(resId, *resolvedArgs.toTypedArray()) + } + } 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 00000000..2cacd807 --- /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.abs +import kotlin.math.max +import kotlin.math.min + +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 00000000..d3c52b03 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCameraController.kt @@ -0,0 +1,141 @@ +/* + * 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 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 + +private const val TUTORIAL_TARGET_Y = 280f + +@Composable +fun GraphCameraController( + offset: Offset, + tutorialStep: TutorialStep?, + focusTrigger: Int, + graph: BrushGraph, + zoom: Float, + isPreviewExpanded: Boolean, + selectedNodeId: String?, + updateOffset: (Offset) -> Unit, + viewportSize: Size, + context: Context, + isWideScreen: 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 = TUTORIAL_TARGET_Y * 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, + isWideScreen = isWideScreen, + 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, + isWideScreen: 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 (isWideScreen) { + 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 00000000..a02294ad --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphCanvas.kt @@ -0,0 +1,638 @@ +/* + * 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.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.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.dp +import androidx.ink.brush.Brush +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphEdge +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 com.example.cahier.developer.brushgraph.ui.node.NodeRegistry +import com.example.cahier.developer.brushgraph.ui.node.NodeWidget +import androidx.compose.ui.graphics.drawscope.Stroke as DrawStroke + +/** + * 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, + strokeRenderer: CanvasStrokeRenderer, + 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 = { _, _, _ -> }, selectedNodeId: String? = null, + selectedEdge: GraphEdge? = null, + detachedEdge: GraphEdge? = null, + bottomPadding: Dp = 16.dp, + rightPadding: Dp = 0.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, + Port.OUTPUT_PORT_ID, + 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 -> + 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 + ) + }, + 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 rightPaddingPx = with(density) { rightPadding.toPx() } + val trashCenterX = (parentWidth - rightPaddingPx) / 2f + val trashCenter = Offset( + trashCenterX, + 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) + 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, + strokeRenderer = strokeRenderer, + brush = brush, + ) + } + } + } + } + + TrashCanArea( + graph = graph, + draggingNodeId = draggingNodeId, + draggingPointerPos = draggingPointerPos, + parentWidth = parentWidth, + parentHeight = parentHeight, + trashCenterPaddingPx = trashCenterPaddingPx, + bottomPadding = bottomPadding, + rightPadding = rightPadding, + onNodeDelete = onNodeDelete, + modifier = Modifier + .padding(end = rightPadding) + .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, + modifier: Modifier = Modifier, +) { + 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, Port.OUTPUT_PORT_ID, 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, + rightPadding: Dp = 0.dp, + onNodeDelete: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val draggingNode = graph.nodes.find { it.id == draggingNodeId } + val density = LocalDensity.current + val rightPaddingPx = with(density) { rightPadding.toPx() } + val trashCenterX = (parentWidth - rightPaddingPx) / 2f + + if (draggingNode != null && draggingNode.data !is NodeData.Family) { + Surface( + modifier = + modifier + .padding(bottom = bottomPadding) + .graphicsLayer { + val isOver = + draggingPointerPos?.let { pos -> + val trashCenter = + Offset(trashCenterX, 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/GraphLayout.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt new file mode 100644 index 00000000..61e4b7f1 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt @@ -0,0 +1,238 @@ +/* + * 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 com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.NodeData + +private const val TITLE_AREA_HEIGHT = 72f +private const val SUBTITLE_LINE_HEIGHT = 48f +private const val PREVIEW_AREA_HEIGHT = 72f + +fun NodeData.width(): Float = when (this) { + is NodeData.Family -> 3 * NODE_WIDTH + else -> NODE_WIDTH +} + +fun NodeData.height(portCount: Int = inputLabels().size): Float { + val previewH = + if (this is NodeData.ColorFunction || this is NodeData.TextureLayer) PREVIEW_AREA_HEIGHT else 0f + return NODE_PADDING_VERTICAL + + titleHeight() + + previewH + + maxOf(portCount, 1) * INPUT_ROW_HEIGHT + + NODE_PADDING_BOTTOM +} + +fun NodeData.titleHeight(): Float { + val subs = subtitles() + val subtitleHeight = subs.size * SUBTITLE_LINE_HEIGHT + if (subtitleHeight > 0f) { + return TITLE_AREA_HEIGHT + subtitleHeight + } + if (this is NodeData.Tip || this is NodeData.Coat) { + return TITLE_AREA_HEIGHT + PREVIEW_AREA_HEIGHT + } + return TITLE_AREA_HEIGHT +} + +object GraphLayout { + private const val HORIZONTAL_GAP = 200f + private const val VERTICAL_GAP = 80f + private const val FAMILY_NODE_X = 1600f + + /** Positions nodes in [BrushGraph] so they are somewhat evenly spaced and easier to see. */ + fun calculateLayout(graph: BrushGraph): Map { + val positions = mutableMapOf() + val familyNode = graph.nodes.find { it.data is NodeData.Family } ?: return positions + + positions[familyNode.id] = Offset(FAMILY_NODE_X, 0f) + + val coatEdges = graph.edges.filter { it.toNodeId == familyNode.id } + val familyData = familyNode.data as NodeData.Family + + // Sort coats by port order if possible, or just as they come + val coatNodes = familyData.coatPortIds.mapNotNull { portId -> + coatEdges.find { it.toPortId == portId }?.fromNodeId + }.mapNotNull { id -> graph.nodes.find { it.id == id } } + + var nextY = 0f + + for (coatNode in coatNodes) { + val coatData = coatNode.data as NodeData.Coat + val coatX = FAMILY_NODE_X - coatData.width() - HORIZONTAL_GAP + val coatY = nextY + positions[coatNode.id] = Offset(coatX, coatY) + + // Layout Tip + val tipEdge = + graph.edges.find { it.toNodeId == coatNode.id && it.toPortId == coatData.tipPortId } + val tipNode = tipEdge?.fromNodeId?.let { id -> graph.nodes.find { it.id == id } } + + var tipSubtreeMaxY = coatY + if (tipNode != null) { + val tipData = tipNode.data as NodeData.Tip + val tipX = coatX - tipData.width() - HORIZONTAL_GAP + positions[tipNode.id] = Offset(tipX, coatY) + + // Layout behavior graph connected to tip + val behaviorEdges = graph.edges.filter { it.toNodeId == tipNode.id } + val behaviorRootIds = tipData.behaviorPortIds.mapNotNull { portId -> + behaviorEdges.find { it.toPortId == portId }?.fromNodeId + } + + var currentY = coatY + val maxYPerDepth = mutableMapOf() + val assignedNodeIds = mutableSetOf() + val nodeSubtreeMaxY = mutableMapOf() + + for (rootId in behaviorRootIds) { + val maxY = layoutBehaviorNode( + rootId, + graph, + positions, + tipX, + currentY, + 0, + maxYPerDepth, + assignedNodeIds, + nodeSubtreeMaxY + ) + currentY = maxY + VERTICAL_GAP + } + tipSubtreeMaxY = maxOf( + coatY + tipData.height(tipData.behaviorPortIds.size + 1), + currentY - VERTICAL_GAP + ) + } + + // Layout Paints + var currentPaintY = tipSubtreeMaxY + VERTICAL_GAP + var maxPaintSubtreeY = currentPaintY + + val paintEdges = coatData.paintPortIds.mapNotNull { portId -> + graph.edges.find { it.toNodeId == coatNode.id && it.toPortId == portId } + } + + for (paintEdge in paintEdges) { + val paintNode = graph.nodes.find { it.id == paintEdge.fromNodeId } + if (paintNode != null) { + val paintData = paintNode.data as NodeData.Paint + val paintX = coatX - paintData.width() - HORIZONTAL_GAP + positions[paintNode.id] = Offset(paintX, currentPaintY) + + // Layout texture layers and color functions + var subY = currentPaintY + + val textureEdges = graph.edges.filter { + it.toNodeId == paintNode.id && paintData.texturePortIds.contains(it.toPortId) + } + for (te in textureEdges) { + val texNode = graph.nodes.find { it.id == te.fromNodeId } + if (texNode != null) { + val texX = paintX - texNode.data.width() - HORIZONTAL_GAP + positions[texNode.id] = Offset(texX, subY) + subY += texNode.data.height() + VERTICAL_GAP + } + } + + val colorEdges = graph.edges.filter { + it.toNodeId == paintNode.id && paintData.colorPortIds.contains(it.toPortId) + } + for (ce in colorEdges) { + val colNode = graph.nodes.find { it.id == ce.fromNodeId } + if (colNode != null) { + val colX = paintX - colNode.data.width() - HORIZONTAL_GAP + positions[colNode.id] = Offset(colX, subY) + subY += colNode.data.height() + VERTICAL_GAP + } + } + + val paintHeight = + paintData.height(paintData.texturePortIds.size + 1 + paintData.colorPortIds.size + 1) + currentPaintY = maxOf(currentPaintY + paintHeight, subY) + VERTICAL_GAP + maxPaintSubtreeY = maxOf(maxPaintSubtreeY, currentPaintY) + } + } + + val coatHeight = coatData.height(coatData.paintPortIds.size + 2) + nextY = maxOf(coatY + coatHeight, maxPaintSubtreeY) + VERTICAL_GAP * 4 + } + + return positions + } + + private fun layoutBehaviorNode( + nodeId: String, + graph: BrushGraph, + positions: MutableMap, + parentX: Float, + desiredY: Float, + depth: Int, + maxYPerDepth: MutableMap, + assignedNodeIds: MutableSet, + 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)) { + return nodeSubtreeMaxY[nodeId] ?: desiredY + } + + val childEdges = graph.edges.filter { it.toNodeId == nodeId } + val childNodes = data.inputPortIds.mapNotNull { portId -> + childEdges.find { it.toPortId == portId }?.fromNodeId?.let { id -> graph.nodes.find { it.id == id } } + } + + val x = parentX - (data.width() + HORIZONTAL_GAP) + val nodeHeight = data.height(childNodes.size + 1) + + val minY = maxYPerDepth[depth] ?: desiredY + val finalY = maxOf(desiredY, minY) + positions[nodeId] = Offset(x, finalY) + assignedNodeIds.add(nodeId) + + val nextParentX = x + var currentChildY = finalY + var maxChildYReached = finalY + nodeHeight + + for (i in childNodes.indices) { + val child = childNodes[i] + val targetY = if (i == 0) finalY else currentChildY + VERTICAL_GAP + val cY = layoutBehaviorNode( + child.id, + graph, + positions, + nextParentX, + targetY, + depth + 1, + maxYPerDepth, + assignedNodeIds, + nodeSubtreeMaxY + ) + currentChildY = cY + maxChildYReached = maxOf(maxChildYReached, cY) + } + + maxYPerDepth[depth] = maxChildYReached + VERTICAL_GAP + nodeSubtreeMaxY[nodeId] = maxChildYReached + + return maxChildYReached + } +} 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 00000000..de695cd4 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Inspector.kt @@ -0,0 +1,410 @@ +/* + * 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.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.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.fillMaxHeight +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.automirrored.filled.Help +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.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.res.stringResource +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.R +import com.example.cahier.developer.brushgraph.data.DisplayText +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 + +/** 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, + modifier: Modifier = Modifier, + 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( + isWideScreen: Boolean, + visible: Boolean, + title: String, + onClose: () -> Unit, + modifier: Modifier = Modifier, + tooltipText: String? = null, + content: @Composable () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = + if (isWideScreen) { + slideInHorizontally(initialOffsetX = { it }) + } else { + slideInVertically(initialOffsetY = { it }) + }, + exit = + if (isWideScreen) { + slideOutHorizontally(targetOffsetX = { it }) + } else { + slideOutVertically(targetOffsetY = { it }) + }, + modifier = modifier.zIndex(10f), + ) { + Surface( + modifier = + if (isWideScreen) { + 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/Notification.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Notification.kt new file mode 100644 index 00000000..2e4a19e2 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Notification.kt @@ -0,0 +1,334 @@ +/* + * 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 android.util.Log +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.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.cahier.R +import com.example.cahier.core.ui.theme.extendedColorScheme +import com.example.cahier.developer.brushgraph.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.ValidationSeverity +import com.example.cahier.developer.brushgraph.viewmodel.BrushGraphViewModel + +@Composable +fun NotificationPane( + isWideScreen: Boolean, + viewModel: BrushGraphViewModel, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val issues = uiState.graphIssues + val hasErrors = issues.any { it.severity == ValidationSeverity.ERROR } + val hasWarnings = issues.any { it.severity == ValidationSeverity.WARNING } + + AnimatedVisibility( + visible = uiState.isErrorPaneOpen, + enter = + if (isWideScreen) { + slideInHorizontally(initialOffsetX = { it }) + } else { + slideInVertically(initialOffsetY = { it }) + }, + exit = + if (isWideScreen) { + slideOutHorizontally(targetOffsetX = { it }) + } else { + slideOutVertically(targetOffsetY = { it }) + }, + modifier = modifier.zIndex(10f), + ) { + Surface( + modifier = + if (isWideScreen) { + 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 + val headerColor = + when { + hasErrors -> MaterialTheme.colorScheme.error + hasWarnings -> MaterialTheme.extendedColorScheme.warning + else -> MaterialTheme.colorScheme.primary + } + val iconColor = + when { + hasErrors -> MaterialTheme.colorScheme.onError + hasWarnings -> MaterialTheme.extendedColorScheme.onWarning + else -> MaterialTheme.colorScheme.onPrimary + } + val headerIcon = + when { + hasErrors -> Icons.Default.Error + hasWarnings -> Icons.Default.Warning + else -> Icons.Default.Info + } + + Surface(color = headerColor, tonalElevation = 2.dp) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), + ) { + Icon(headerIcon, contentDescription = null, tint = iconColor) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.bg_notifications_count, issues.size), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + color = iconColor, + ) + IconButton(onClick = { viewModel.toggleErrorPane() }) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.bg_cd_close_pane), + tint = iconColor + ) + } + } + } + LazyColumn(modifier = Modifier.padding(16.dp)) { + val errors = issues.filter { it.severity == ValidationSeverity.ERROR } + val warnings = issues.filter { it.severity == ValidationSeverity.WARNING } + val debugs = issues.filter { it.severity == ValidationSeverity.DEBUG } + + if (errors.isNotEmpty()) { + item { + NotificationGroup( + title = stringResource(R.string.bg_errors), + issues = errors, + icon = Icons.Default.Error, + color = MaterialTheme.colorScheme.error, + viewModel = viewModel, + isWideScreen = isWideScreen, + ) + } + } + if (warnings.isNotEmpty()) { + item { + NotificationGroup( + title = stringResource(R.string.bg_warnings), + issues = warnings, + icon = Icons.Default.Warning, + color = MaterialTheme.extendedColorScheme.warning, + viewModel = viewModel, + isWideScreen = isWideScreen, + ) + } + } + if (debugs.isNotEmpty()) { + item { + NotificationGroup( + title = stringResource(R.string.bg_debug), + issues = debugs, + icon = Icons.Default.Info, + color = MaterialTheme.colorScheme.onSurfaceVariant, + viewModel = viewModel, + isWideScreen = isWideScreen, + ) + } + } + } + } + } + } +} + +@Composable +fun NotificationGroup( + title: String, + issues: List, + icon: ImageVector, + color: Color, + viewModel: BrushGraphViewModel, + isWideScreen: Boolean, +) { + var expanded by remember { mutableStateOf(true) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + if (expanded) { + Icons.Default.KeyboardArrowDown + } else { + Icons.Default.ChevronRight + }, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + text = "$title (${issues.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = color, + ) + } + } + if (expanded) { + Column(modifier = Modifier.padding(start = 16.dp, top = 4.dp)) { + for (issue in issues) { + val density = LocalDensity.current.density + val message = issue.displayMessage.asString() + LaunchedEffect(issue) { + Log.d("NotificationPane", message) + } + Surface( + modifier = + Modifier.fillMaxWidth().padding(vertical = 4.dp).let { + if (issue.nodeId != null) { + it.clickable { + viewModel.onIssueClick( + issue, + isWideScreen, + density + ) + } + } else { + it + } + }, + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = message, + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodySmall, + color = color, + ) + } + } + } + } + } +} + +@Composable +fun NotificationIcon( + issues: List, + indicatorPaddingEnd: androidx.compose.ui.unit.Dp, + onToggleErrorPane: () -> Unit, + modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier, +) { + if (issues.isNotEmpty()) { + val hasErrors = issues.any { it.severity == ValidationSeverity.ERROR } + val hasWarnings = issues.any { it.severity == ValidationSeverity.WARNING } + val icon = + when { + hasErrors -> Icons.Default.Error + hasWarnings -> Icons.Default.Warning + else -> Icons.Default.Info + } + val containerColor = + when { + hasErrors -> MaterialTheme.colorScheme.error + hasWarnings -> MaterialTheme.extendedColorScheme.warning + else -> MaterialTheme.colorScheme.secondary + } + val contentColor = + when { + hasErrors -> MaterialTheme.colorScheme.onError + hasWarnings -> MaterialTheme.extendedColorScheme.onWarning + else -> MaterialTheme.colorScheme.onSecondary + } + + IconButton( + onClick = onToggleErrorPane, + modifier = modifier + .padding(top = 16.dp, end = indicatorPaddingEnd) + .zIndex(2f), + colors = + IconButtonDefaults.iconButtonColors( + containerColor = containerColor, + contentColor = contentColor, + ), + ) { + Icon(icon, contentDescription = stringResource(R.string.bg_cd_show_notifications)) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/PreviewWidgets.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/PreviewWidgets.kt new file mode 100644 index 00000000..2cfa33bf --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/PreviewWidgets.kt @@ -0,0 +1,416 @@ +/* + * 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 android.graphics.Matrix +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.ink.brush.Brush +import androidx.ink.brush.BrushFamily +import androidx.ink.brush.InputToolType +import androidx.ink.brush.StockBrushes +import androidx.ink.brush.compose.createWithComposeColor +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.strokes.InProgressStroke +import androidx.ink.strokes.MutableStrokeInputBatch +import com.example.cahier.developer.brushgraph.data.toBrushFamily +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ink.proto.BrushCoat as ProtoBrushCoat +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.BrushTip as ProtoBrushTip +import ink.proto.ColorFunction as ProtoColorFunction + +@Composable +fun SineWavePreview( + brush: Brush, + strokeRenderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + BoxWithConstraints(modifier = modifier) { + val canvasWidth = with(LocalDensity.current) { maxWidth.toPx() } + val canvasHeight = with(LocalDensity.current) { maxHeight.toPx() } + + val stroke = + remember(brush, canvasWidth, canvasHeight) { + if (canvasWidth <= 0f || canvasHeight <= 0f) return@remember null + val inputs = MutableStrokeInputBatch() + val numPoints = 100 + val horizontalBuffer = 40f + val effectiveWidth = canvasWidth - 2 * horizontalBuffer + if (effectiveWidth <= 0f) return@remember null + + val midY = canvasHeight / 2f + val amplitude = canvasHeight * 0.2f + val period = 1.5f + val frequency = 2f * Math.PI.toFloat() * period / effectiveWidth + + for (i in 0 until numPoints) { + val xOffset = i.toFloat() * effectiveWidth / (numPoints - 1) + val x = horizontalBuffer + xOffset + val y = midY + amplitude * kotlin.math.sin(frequency * xOffset) + inputs.add( + type = InputToolType.STYLUS, + x = x, + y = y, + elapsedTimeMillis = i.toLong() * 10 + ) + } + val inProgressStroke = InProgressStroke() + inProgressStroke.start(brush) + inProgressStroke.enqueueInputs(inputs, MutableStrokeInputBatch()) + inProgressStroke.updateShape(numPoints.toLong() * 10) + inProgressStroke.toImmutable() + } + + val surface = MaterialTheme.colorScheme.surface + val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant + + Canvas(modifier = Modifier.fillMaxSize()) { + // Checkerboard background + val tileSize = 7.dp.toPx() + val numTilesX = (size.width / tileSize).toInt() + 2 + val numTilesY = (size.height / tileSize).toInt() + 2 + val startX = (size.width - numTilesX * tileSize) / 2f + val startY = (size.height - numTilesY * tileSize) / 2f + for (ix in 0 until numTilesX) { + for (iy in 0 until numTilesY) { + val color = if ((ix + iy) % 2 == 0) surface else surfaceVariant + drawRect( + color = color, + topLeft = Offset(startX + ix * tileSize, startY + iy * tileSize), + size = Size(tileSize, tileSize), + ) + } + } + + if (stroke != null) { + drawIntoCanvas { canvas -> + strokeRenderer.draw(canvas.nativeCanvas, stroke, Matrix()) + } + } + } + } +} + +@Composable +fun CoatPreviewWidget( + brushCoat: ProtoBrushCoat, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + val family by produceState( + initialValue = StockBrushes.marker(), + key1 = brushCoat + ) { + value = withContext(Dispatchers.IO) { + val familyProto = ProtoBrushFamily.newBuilder().addCoats(brushCoat).build() + runCatching { familyProto.toBrushFamily() }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget( + modifier = modifier, + family = family, + renderer = renderer, + brushSize = 10f, + showSingleInput = false, + ) +} + +@Composable +fun TipPreviewWidget( + brushTip: ProtoBrushTip, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + val family by produceState(initialValue = StockBrushes.marker(), key1 = brushTip) { + value = withContext(Dispatchers.IO) { + val familyProto = ProtoBrushFamily.newBuilder() + .addCoats(ProtoBrushCoat.newBuilder().setTip(brushTip).build()) + .build() + runCatching { familyProto.toBrushFamily() }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget(modifier = modifier, family = family, renderer = renderer) +} + +@Composable +fun ColorFunctionPreviewWidget( + colorFunction: ProtoColorFunction, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + val family by produceState( + initialValue = StockBrushes.marker(), + key1 = colorFunction + ) { + value = withContext(Dispatchers.IO) { + runCatching { + ProtoBrushFamily.newBuilder() + .addCoats( + ProtoBrushCoat.newBuilder() + .setTip(ProtoBrushTip.newBuilder().setCornerRounding(0f).build()) + .addPaintPreferences( + ProtoBrushPaint.newBuilder().addColorFunctions(colorFunction) + .build() + ) + .build() + ) + .build() + .toBrushFamily() + }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget( + modifier = modifier, + family = family, + renderer = renderer, + brushSize = 30f, + zoom = 3f + ) +} + +@Composable +fun TextureLayerPreviewWidget( + textureLayer: ProtoBrushPaint.TextureLayer, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + val family by produceState( + initialValue = StockBrushes.marker(), + key1 = textureLayer + ) { + value = withContext(Dispatchers.IO) { + runCatching { + ProtoBrushFamily.newBuilder() + .addCoats( + ProtoBrushCoat.newBuilder() + .setTip(ProtoBrushTip.newBuilder().setCornerRounding(0f).build()) + .addPaintPreferences( + ProtoBrushPaint.newBuilder().addTextureLayers(textureLayer).build() + ) + .build() + ) + .build() + .toBrushFamily() + }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget( + modifier = modifier, + family = family, + renderer = renderer, + brushSize = 30f, + zoom = 3f + ) +} + +@Composable +fun TextureWrapPreviewWidget( + wrapX: ProtoBrushPaint.TextureLayer.Wrap, + wrapY: ProtoBrushPaint.TextureLayer.Wrap, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, + clientTextureId: String = "", +) { + val family by produceState( + initialValue = StockBrushes.marker(), + key1 = Triple(wrapX, wrapY, clientTextureId) + ) { + value = withContext(Dispatchers.IO) { + val textureLayer = + ProtoBrushPaint.TextureLayer.newBuilder() + .setClientTextureId(clientTextureId) + .setSizeX(1f / 3f) + .setSizeY(1f / 3f) + .setWrapX(wrapX) + .setWrapY(wrapY) + .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) + .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) + .build() + + runCatching { + ProtoBrushFamily.newBuilder() + .addCoats( + ProtoBrushCoat.newBuilder() + .setTip(ProtoBrushTip.newBuilder().setCornerRounding(0f).build()) + .addPaintPreferences( + ProtoBrushPaint.newBuilder().addTextureLayers(textureLayer).build() + ) + .build() + ) + .build() + .toBrushFamily() + }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget( + modifier = modifier, + family = family, + renderer = renderer, + brushSize = 30f, + zoom = 3f + ) +} + +@Composable +fun BlendModePreviewWidget( + blendMode: ProtoBrushPaint.TextureLayer.BlendMode, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, + clientTextureId: String = "", +) { + val family by produceState( + initialValue = StockBrushes.marker(), + key1 = Pair(blendMode, clientTextureId) + ) { + value = withContext(Dispatchers.IO) { + val topLayer = + ProtoBrushPaint.TextureLayer.newBuilder() + .setClientTextureId(clientTextureId) + .setBlendMode(blendMode) + .setSizeX(1f) + .setSizeY(1f) + .build() + + val bottomLayer = + ProtoBrushPaint.TextureLayer.newBuilder() + .setClientTextureId(clientTextureId) + .setOffsetX(0.2f) + .setOffsetY(0.2f) + .setSizeX(1f) + .setSizeY(1f) + .build() + + runCatching { + ProtoBrushFamily.newBuilder() + .addCoats( + ProtoBrushCoat.newBuilder() + .setTip(ProtoBrushTip.newBuilder().setCornerRounding(0f).build()) + .addPaintPreferences( + ProtoBrushPaint.newBuilder() + .addTextureLayers(bottomLayer) + .addTextureLayers(topLayer) + .build() + ) + .build() + ) + .build() + .toBrushFamily() + }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget( + modifier = modifier, + family = family, + renderer = renderer, + brushSize = 30f, + zoom = 3f + ) +} + +@Composable +fun StrokePreviewWidget( + family: BrushFamily, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, + brushSize: Float = 30f, + showSingleInput: Boolean = true, + zoom: Float = 1f, +) { + val canvasBackground = MaterialTheme.colorScheme.surface + val brush = + Brush.createWithComposeColor( + family, + MaterialTheme.colorScheme.primary, + size = brushSize, + epsilon = 0.1f + ) + + // The two [StrokeInputBatch]s for the preview widget. + val zigzagStroke = remember(brush) { + val mutable = MutableStrokeInputBatch() + .add(type = InputToolType.STYLUS, x = -30f, y = -24f, elapsedTimeMillis = 0L) + .add(type = InputToolType.STYLUS, x = 15f, y = -30f, elapsedTimeMillis = 100L) + .add(type = InputToolType.STYLUS, x = -24f, y = 24f, elapsedTimeMillis = 200L) + .add(type = InputToolType.STYLUS, x = 30f, y = 6f, elapsedTimeMillis = 300L) + val inProgress = InProgressStroke() + inProgress.start(brush) + inProgress.enqueueInputs(mutable, MutableStrokeInputBatch()) + inProgress.updateShape(300L) + inProgress.toImmutable() + } + + val dotStroke = remember(brush) { + val mutable = MutableStrokeInputBatch() + .add(type = InputToolType.STYLUS, x = 0f, y = 0f, elapsedTimeMillis = 0L) + val inProgress = InProgressStroke() + inProgress.start(brush) + inProgress.enqueueInputs(mutable, MutableStrokeInputBatch()) + inProgress.updateShape(0L) + inProgress.toImmutable() + } + + var zigzag by remember(brush) { mutableStateOf(zigzagStroke) } + var singleDot by remember(brush) { mutableStateOf(dotStroke) } + + // maxTipWidth is 4x the Brush.size. BrushTip.scale and BrushTip.slant each can double the width. + val maxTipWidth = with(LocalDensity.current) { (4f * brush.size).toDp() } + val maxStrokeWidth = with(LocalDensity.current) { (3f * 4f * brush.size).toDp() } + val canvasSize = if (showSingleInput) maxTipWidth else maxStrokeWidth + Canvas( + modifier = + modifier + .height(canvasSize) + .width(canvasSize) + .clip(RectangleShape) + .background(canvasBackground), + onDraw = { + drawIntoCanvas { canvas -> + // Translate stroke to center of the canvas. + canvas.scale(zoom, zoom) + canvas.translate(size.width / (2f * zoom), size.height / (2f * zoom)) + renderer.draw( + canvas.nativeCanvas, + if (showSingleInput) singleDot else zigzag, + Matrix() + ) + } + }, + ) +} 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 00000000..9f863788 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TestCanvas.kt @@ -0,0 +1,444 @@ +/* + * 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.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.horizontalScroll +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.fillMaxHeight +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.foundation.rememberScrollState +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.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.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.layout.Layout +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.ink.brush.Brush +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.extendedColorScheme +import com.example.cahier.developer.brushgraph.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.ValidationSeverity + +@Composable +fun TestCanvas( + isInvertedCanvas: Boolean, + strokeList: List, + strokeRenderer: CanvasStrokeRenderer, + brush: Brush, + modifier: Modifier = Modifier, + onGetNextBrush: () -> Brush, + onStrokesAdded: (List) -> Unit, +) { + Box(modifier = modifier.fillMaxSize()) { + Text( + stringResource(R.string.bg_test_canvas_draw_prompt), + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.labelMedium, + color = + if (isInvertedCanvas) { + MaterialTheme.colorScheme.inverseOnSurface + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + DrawingSurface( + strokes = strokeList, + canvasStrokeRenderer = strokeRenderer, + onStrokesFinished = onStrokesAdded, + onErase = { _, _ -> }, + onEraseStart = {}, + onEraseEnd = {}, + currentBrush = brush, + onGetNextBrush = onGetNextBrush, + isEraserMode = false, + backgroundImageUri = null, + onStartDrag = {}, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun CollapsiblePreviewPane( + isPreviewExpanded: Boolean, + isInvertedCanvas: Boolean, + testAutoUpdateStrokes: Boolean, + brushColor: Color, + brushSize: Float, + brush: Brush, + strokeList: List, + strokeRenderer: CanvasStrokeRenderer, + topIssue: GraphValidationException?, + modifier: Modifier = Modifier, + onGetNextBrush: () -> Brush, + onTogglePreviewExpanded: () -> Unit, + onClearStrokes: () -> Unit, + onToggleCanvasTheme: () -> Unit, + onSetTestAutoUpdateStrokes: (Boolean) -> Unit, + onUpdateTestBrushColor: (Color) -> Unit, + onUpdateTestBrushSize: (Float) -> Unit, + onStrokesAdded: (List) -> Unit, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + onToggleNotificationPane: () -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + // Toggle Tab (always visible) + Surface( + modifier = + Modifier + .fillMaxWidth() + .height(40.dp) + .clickable { onTogglePreviewExpanded() }, + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 4.dp, + shadowElevation = 8.dp, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterStart), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (isPreviewExpanded) { + Icons.Default.KeyboardArrowDown + } else { + Icons.Default.KeyboardArrowUp + }, + contentDescription = if (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 (isPreviewExpanded) { + AdaptivePreviewControlsLayout( + badgeMinWidth = 160.dp, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + val scrollState = rememberScrollState() + val showLeftFade = scrollState.value > 0 + val showRightFade = + scrollState.value < scrollState.maxValue && scrollState.maxValue > 0 + + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier + .fillMaxHeight() + .horizontalScroll(scrollState) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.bg_test_canvas_reset), + modifier = Modifier.clickable { onClearStrokes() }, + 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 { onToggleCanvasTheme() }, + 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 = testAutoUpdateStrokes, + onCheckedChange = { onSetTestAutoUpdateStrokes(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(brushColor) + .border(1.dp, MaterialTheme.colorScheme.outline) + .clickable { + onChooseColor(brushColor) { newColor -> + onUpdateTestBrushColor(newColor) + } + } + ) + 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 = "${brushSize.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 (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 = { + onUpdateTestBrushSize(size.toFloat()) + sizeExpanded = false + } + ) + } + } + } + } + + // Scroll fades + if (showLeftFade) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(16.dp) + .background( + androidx.compose.ui.graphics.Brush.horizontalGradient( + colors = listOf( + MaterialTheme.colorScheme.surfaceVariant, + Color.Transparent + ) + ) + ) + .align(Alignment.CenterStart) + ) + } + if (showRightFade) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(16.dp) + .background( + androidx.compose.ui.graphics.Brush.horizontalGradient( + colors = listOf( + Color.Transparent, + MaterialTheme.colorScheme.surfaceVariant + ) + ) + ) + .align(Alignment.CenterEnd) + ) + } + } + + // 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 + .fillMaxHeight() + .clickable { onToggleNotificationPane() } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 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, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } + } + } + } + + // Expanding Drawer Content + AnimatedVisibility( + visible = isPreviewExpanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + 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 + }, + ) { + TestCanvas( + strokeList = strokeList, + strokeRenderer = strokeRenderer, + brush = brush, + isInvertedCanvas = isInvertedCanvas, + onGetNextBrush = onGetNextBrush, + onStrokesAdded = onStrokesAdded, + ) + } + } + } +} + +@Composable +fun AdaptivePreviewControlsLayout( + badgeMinWidth: Dp, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Layout( + content = content, + modifier = modifier + ) { measurables, constraints -> + if (measurables.size == 1) { + val placeable = measurables[0].measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } else if (measurables.size >= 2) { + val controlsMeasurable = measurables[0] + val badgeMeasurable = measurables[1] + + val minBadgePx = badgeMinWidth.roundToPx() + val controlsMaxWidth = maxOf(0, constraints.maxWidth - minBadgePx) + + // Measure controls first up to available width minus minimum badge width + val controlsPlaceable = controlsMeasurable.measure( + constraints.copy(minWidth = 0, maxWidth = controlsMaxWidth) + ) + + // Badge takes all the remaining space + val badgeWidth = maxOf(minBadgePx, constraints.maxWidth - controlsPlaceable.width) + val badgePlaceable = badgeMeasurable.measure( + constraints.copy(minWidth = badgeWidth, maxWidth = badgeWidth) + ) + + val totalWidth = + minOf(constraints.maxWidth, controlsPlaceable.width + badgePlaceable.width) + val height = maxOf(controlsPlaceable.height, badgePlaceable.height) + + layout(totalWidth, height) { + controlsPlaceable.placeRelative(0, (height - controlsPlaceable.height) / 2) + badgePlaceable.placeRelative( + controlsPlaceable.width, + (height - badgePlaceable.height) / 2 + ) + } + } else { + layout(0, 0) {} + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Tooltips.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Tooltips.kt new file mode 100644 index 00000000..3c57ba80 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Tooltips.kt @@ -0,0 +1,226 @@ +/* + * 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 com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.NodeData +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction +import ink.proto.StepPosition as ProtoStepPosition + +/** Extension functions to provide tooltips for nodes and enums. */ + +fun NodeData.getTooltip(): Int = when (this) { + is NodeData.Tip -> R.string.bg_tooltip_node_tip + is NodeData.Paint -> R.string.bg_tooltip_node_paint + is NodeData.Behavior -> when (this.node.nodeCase) { + ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> R.string.bg_tooltip_node_source + ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> R.string.bg_tooltip_node_constant + ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> R.string.bg_tooltip_node_noise + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> R.string.bg_tooltip_node_tool_type_filter + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> R.string.bg_tooltip_node_damping + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> R.string.bg_tooltip_node_response + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> R.string.bg_tooltip_node_binary_op + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> R.string.bg_tooltip_node_interpolation + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> R.string.bg_tooltip_node_integral + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> R.string.bg_tooltip_node_target + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> R.string.bg_tooltip_node_polar_target + else -> R.string.bg_tooltip_node_unknown + } + + is NodeData.TextureLayer -> R.string.bg_tooltip_node_texture_layer + is NodeData.ColorFunction -> R.string.bg_tooltip_node_color_func + is NodeData.Coat -> R.string.bg_tooltip_node_coat + is NodeData.Family -> R.string.bg_tooltip_node_family +} + +fun ProtoBrushBehavior.Source.getTooltip(): Int = when (this) { + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE -> R.string.bg_tooltip_source_pressure + ProtoBrushBehavior.Source.SOURCE_TILT_IN_RADIANS -> R.string.bg_tooltip_source_tilt + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_tooltip_source_speed + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_tooltip_source_velocity_x + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND -> R.string.bg_tooltip_source_velocity_y + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_X -> R.string.bg_tooltip_source_direction_x + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_Y -> R.string.bg_tooltip_source_direction_y + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_tooltip_source_distance_traveled + ProtoBrushBehavior.Source.SOURCE_TIME_OF_INPUT_IN_SECONDS -> R.string.bg_tooltip_source_time_of_input_s + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_tooltip_source_predicted_distance_traveled + ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS -> R.string.bg_tooltip_source_predicted_time_elapsed_s + ProtoBrushBehavior.Source.SOURCE_TILT_X_IN_RADIANS -> R.string.bg_tooltip_source_tilt_x + ProtoBrushBehavior.Source.SOURCE_TILT_Y_IN_RADIANS -> R.string.bg_tooltip_source_tilt_y + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_IN_RADIANS -> R.string.bg_tooltip_source_orientation + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_ABOUT_ZERO_IN_RADIANS -> R.string.bg_tooltip_source_orientation_about_zero + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_tooltip_source_distance_remaining + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_SECONDS -> R.string.bg_tooltip_source_time_since_input_s + ProtoBrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS -> R.string.bg_tooltip_source_direction + ProtoBrushBehavior.Source.SOURCE_DIRECTION_ABOUT_ZERO_IN_RADIANS -> R.string.bg_tooltip_source_direction_about_zero + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_x + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_y + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_forward + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_lateral + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_CENTIMETERS_PER_SECOND -> R.string.bg_tooltip_source_speed_absolute + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_CENTIMETERS_PER_SECOND -> R.string.bg_tooltip_source_velocity_x_absolute + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND -> R.string.bg_tooltip_source_velocity_y_absolute + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_CENTIMETERS -> R.string.bg_tooltip_source_distance_traveled_absolute + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_CENTIMETERS -> R.string.bg_tooltip_source_predicted_distance_traveled_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_x_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_y_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_forward_absolute + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_lateral_absolute + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_AS_FRACTION_OF_STROKE_LENGTH -> R.string.bg_tooltip_source_distance_remaining_fraction + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_STROKE_END_IN_SECONDS -> R.string.bg_tooltip_source_time_since_stroke_end + ProtoBrushBehavior.Source.SOURCE_TIME_FROM_INPUT_TO_STROKE_END_IN_SECONDS -> R.string.bg_tooltip_source_time_from_input_to_stroke_end + ProtoBrushBehavior.Source.SOURCE_UNSPECIFIED -> R.string.bg_tooltip_source_unspecified +} + +fun ProtoBrushBehavior.Target.getTooltip(): Int = when (this) { + ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER -> R.string.bg_tooltip_target_width_multiplier + ProtoBrushBehavior.Target.TARGET_HEIGHT_MULTIPLIER -> R.string.bg_tooltip_target_height_multiplier + ProtoBrushBehavior.Target.TARGET_SIZE_MULTIPLIER -> R.string.bg_tooltip_target_size_multiplier + ProtoBrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS -> R.string.bg_tooltip_target_rotation_offset + ProtoBrushBehavior.Target.TARGET_CORNER_ROUNDING_OFFSET -> R.string.bg_tooltip_target_corner_rounding_offset + ProtoBrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS -> R.string.bg_tooltip_target_hue_offset + ProtoBrushBehavior.Target.TARGET_SATURATION_MULTIPLIER -> R.string.bg_tooltip_target_saturation_multiplier + ProtoBrushBehavior.Target.TARGET_LUMINOSITY_OFFSET -> R.string.bg_tooltip_target_luminosity + ProtoBrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS -> R.string.bg_tooltip_target_slant_offset + ProtoBrushBehavior.Target.TARGET_PINCH_OFFSET -> R.string.bg_tooltip_target_pinch_offset + ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER -> R.string.bg_tooltip_target_opacity_multiplier + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_X_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_tooltip_target_position_offset_x + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_Y_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_tooltip_target_position_offset_y + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_tooltip_target_position_offset_forward + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_tooltip_target_position_offset_lateral + ProtoBrushBehavior.Target.TARGET_UNSPECIFIED -> R.string.bg_tooltip_target_unspecified +} + +fun ProtoBrushPaint.TextureLayer.Wrap.getTooltip(): Int = when (this) { + ProtoBrushPaint.TextureLayer.Wrap.WRAP_REPEAT -> R.string.bg_tooltip_wrap_repeat + ProtoBrushPaint.TextureLayer.Wrap.WRAP_MIRROR -> R.string.bg_tooltip_wrap_mirror + ProtoBrushPaint.TextureLayer.Wrap.WRAP_CLAMP -> R.string.bg_tooltip_wrap_clamp + ProtoBrushPaint.TextureLayer.Wrap.WRAP_UNSPECIFIED -> R.string.bg_tooltip_wrap_unspecified +} + +fun ProtoBrushPaint.TextureLayer.SizeUnit.getTooltip(): Int = when (this) { + ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_STROKE_COORDINATES -> R.string.bg_tooltip_size_unit_stroke_coordinates + ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE -> R.string.bg_tooltip_size_unit_brush_size + else -> R.string.bg_tooltip_size_unit_default +} + +fun ProtoBrushPaint.TextureLayer.Origin.getTooltip(): Int = when (this) { + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_STROKE_SPACE_ORIGIN -> R.string.bg_tooltip_origin_stroke_space_origin + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_FIRST_STROKE_INPUT -> R.string.bg_tooltip_origin_first_stroke_input + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_LAST_STROKE_INPUT -> R.string.bg_tooltip_origin_last_stroke_input + else -> R.string.bg_tooltip_origin_default +} + +fun ProtoBrushPaint.TextureLayer.Mapping.getTooltip(): Int = when (this) { + ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING -> R.string.bg_tooltip_mapping_tiling + ProtoBrushPaint.TextureLayer.Mapping.MAPPING_STAMPING -> R.string.bg_tooltip_mapping_stamping + else -> R.string.bg_tooltip_mapping_default +} + +fun ProtoBrushPaint.TextureLayer.BlendMode.getTooltip(): Int = when (this) { + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_MODULATE -> R.string.bg_tooltip_blend_mode_modulate + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_IN -> R.string.bg_tooltip_blend_mode_dst_in + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OUT -> R.string.bg_tooltip_blend_mode_dst_out + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_ATOP -> R.string.bg_tooltip_blend_mode_src_atop + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_IN -> R.string.bg_tooltip_blend_mode_src_in + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER -> R.string.bg_tooltip_blend_mode_src_over + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OVER -> R.string.bg_tooltip_blend_mode_dst_over + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC -> R.string.bg_tooltip_blend_mode_src + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST -> R.string.bg_tooltip_blend_mode_dst + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OUT -> R.string.bg_tooltip_blend_mode_src_out + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_ATOP -> R.string.bg_tooltip_blend_mode_dst_atop + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_XOR -> R.string.bg_tooltip_blend_mode_xor + else -> R.string.bg_tooltip_blend_mode_default +} + +fun ProtoBrushPaint.SelfOverlap.getTooltip(): Int = when (this) { + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ANY -> R.string.bg_tooltip_self_overlap_any + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ACCUMULATE -> R.string.bg_tooltip_self_overlap_accumulate + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_DISCARD -> R.string.bg_tooltip_self_overlap_discard + else -> R.string.bg_tooltip_self_overlap_default +} + +fun ProtoBrushBehavior.PolarTarget.getTooltip(): Int = when (this) { + ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_ABSOLUTE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_tooltip_polar_target_absolute + ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_RELATIVE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_tooltip_polar_target_relative + ProtoBrushBehavior.PolarTarget.POLAR_UNSPECIFIED -> R.string.bg_tooltip_polar_target_unspecified +} + +fun ProtoBrushBehavior.OutOfRange.getTooltip(): Int = when (this) { + ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP -> R.string.bg_tooltip_out_of_range_clamp + ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_REPEAT -> R.string.bg_tooltip_out_of_range_repeat + ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_MIRROR -> R.string.bg_tooltip_out_of_range_mirror + ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_UNSPECIFIED -> R.string.bg_tooltip_out_of_range_unspecified +} + +fun ProtoBrushBehavior.BinaryOp.getTooltip(): Int = when (this) { + ProtoBrushBehavior.BinaryOp.BINARY_OP_PRODUCT -> R.string.bg_tooltip_binary_op_product + ProtoBrushBehavior.BinaryOp.BINARY_OP_SUM -> R.string.bg_tooltip_binary_op_sum + ProtoBrushBehavior.BinaryOp.BINARY_OP_MIN -> R.string.bg_tooltip_binary_op_min + ProtoBrushBehavior.BinaryOp.BINARY_OP_MAX -> R.string.bg_tooltip_binary_op_max + ProtoBrushBehavior.BinaryOp.BINARY_OP_AND_THEN -> R.string.bg_tooltip_binary_op_and_then + ProtoBrushBehavior.BinaryOp.BINARY_OP_OR_ELSE -> R.string.bg_tooltip_binary_op_or_else + ProtoBrushBehavior.BinaryOp.BINARY_OP_XOR_ELSE -> R.string.bg_tooltip_binary_op_xor_else + else -> R.string.bg_tooltip_binary_op_default +} + +fun ProtoBrushBehavior.ProgressDomain.getTooltip(): Int = when (this) { + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS -> R.string.bg_tooltip_progress_domain_time + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS -> R.string.bg_tooltip_progress_domain_distance_cm + ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE -> R.string.bg_tooltip_progress_domain_distance_size + else -> R.string.bg_tooltip_progress_domain_default +} + +fun ProtoBrushBehavior.Interpolation.getTooltip(): Int = when (this) { + ProtoBrushBehavior.Interpolation.INTERPOLATION_LERP -> R.string.bg_tooltip_interpolation_lerp + ProtoBrushBehavior.Interpolation.INTERPOLATION_INVERSE_LERP -> R.string.bg_tooltip_interpolation_inverse_lerp + else -> R.string.bg_tooltip_interpolation_default +} + +fun ProtoPredefinedEasingFunction.getTooltip(): Int = when (this) { + ProtoPredefinedEasingFunction.PREDEFINED_EASING_LINEAR -> R.string.bg_tooltip_easing_linear + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE -> R.string.bg_tooltip_easing_ease + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_IN -> R.string.bg_tooltip_easing_ease_in + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_OUT -> R.string.bg_tooltip_easing_ease_out + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_IN_OUT -> R.string.bg_tooltip_easing_ease_in_out + ProtoPredefinedEasingFunction.PREDEFINED_EASING_STEP_START -> R.string.bg_tooltip_easing_step_start + ProtoPredefinedEasingFunction.PREDEFINED_EASING_STEP_END -> R.string.bg_tooltip_easing_step_end + else -> R.string.bg_tooltip_easing_default +} + +fun ProtoStepPosition.getTooltip(): Int = when (this) { + ProtoStepPosition.STEP_POSITION_JUMP_START -> R.string.bg_tooltip_step_position_jump_start + ProtoStepPosition.STEP_POSITION_JUMP_END -> R.string.bg_tooltip_step_position_jump_end + ProtoStepPosition.STEP_POSITION_JUMP_NONE -> R.string.bg_tooltip_step_position_jump_none + ProtoStepPosition.STEP_POSITION_JUMP_BOTH -> R.string.bg_tooltip_step_position_jump_both + else -> R.string.bg_tooltip_step_position_default +} + +fun getInputModelTooltip(resId: Int): Int = when (resId) { + R.string.bg_model_sliding_window -> R.string.bg_tooltip_model_sliding_window + R.string.bg_model_passthrough -> R.string.bg_tooltip_model_passthrough + else -> R.string.bg_tooltip_model_default +} + +fun getColorFunctionTooltip(resId: Int): Int = when (resId) { + R.string.bg_opacity_multiplier -> R.string.bg_tooltip_color_func_opacity + R.string.bg_replace_color -> R.string.bg_tooltip_color_func_replace + else -> R.string.bg_tooltip_color_func_default +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt new file mode 100644 index 00000000..60b7de96 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlay.kt @@ -0,0 +1,129 @@ +/* + * 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.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.TutorialAction +import com.example.cahier.developer.brushgraph.data.TutorialStep + +@Composable +fun TutorialOverlay( + step: TutorialStep, + onNext: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, +) { + Box( + modifier = modifier + .width(400.dp) + .wrapContentHeight() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.95f)) + .padding(16.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { /* Do nothing, just swallow the click */ } + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(step.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier + .padding(vertical = 4.dp) + .weight(1f) + ) + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.bg_cd_close_pane), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + Text( + text = stringResource(step.message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(vertical = 8.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (onBack != null) { + OutlinedButton( + onClick = onBack, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f) + ) + ) { + Text(stringResource(R.string.bg_back)) + } + } else { + Spacer(modifier = Modifier.width(1.dp)) + } + Button(onClick = onNext) { + Text( + if (step.actionRequired == TutorialAction.CLICK_NEXT) stringResource(R.string.bg_next) else stringResource( + R.string.bg_got_it + ) + ) + } + } + } + } +} 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 new file mode 100644 index 00000000..6de1470a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/TutorialOverlayHost.kt @@ -0,0 +1,162 @@ +/* + * 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.foundation.layout.BoxScope +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.TutorialAction +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 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?.let { step -> + val density = LocalDensity.current + val isInspectorOpen = (selectedNodeId != null || selectedEdge != null) + var overlaySize by remember { mutableStateOf(IntSize.Zero) } + + val tutorialModifier = when (step.anchor) { + TutorialAnchor.SCREEN_CENTER -> Modifier.align(Alignment.Center) + + TutorialAnchor.FAB -> { + if (isInspectorOpen) { + if (isWideScreen) { + Modifier + .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) + } + } else { + Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 80.dp, end = 80.dp) + } + } + + TutorialAnchor.NODE_CANVAS -> { + val node = step.getTargetNode(graph) + if (node != null) { + val nodePos = nodeRegistry.getNodePosition(node.id) + ?: androidx.compose.ui.geometry.Offset.Zero + val nodeCenterX = nodePos.x + node.data.width() / 2f + val nodeTopY = nodePos.y + + val screenX = nodeCenterX * zoom + offset.x + val screenY = nodeTopY * zoom + offset.y + + val paddingPx = with(density) { 16.dp.toPx() } + Modifier.offset { + IntOffset( + (screenX - overlaySize.width / 2).toInt(), + (screenY - overlaySize.height - paddingPx).toInt() + ) + } + } else { + Modifier.align(Alignment.Center) + } + } + + TutorialAnchor.INSPECTOR -> { + if (isWideScreen) { + Modifier + .align(Alignment.CenterEnd) + .padding(end = (INSPECTOR_WIDTH_LANDSCAPE + 16).dp) + } else { + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = (INSPECTOR_HEIGHT_PORTRAIT + 16).dp) + } + } + + TutorialAnchor.TEST_CANVAS -> { + val basePadding = + if (isPreviewExpanded) PREVIEW_HEIGHT_EXPANDED else PREVIEW_HEIGHT_COLLAPSED + if (isInspectorOpen && !isWideScreen) { + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = (maxOf(INSPECTOR_HEIGHT_PORTRAIT, basePadding) + 16).dp) + } else { + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = (basePadding + 16).dp) + } + } + + TutorialAnchor.ACTION_BAR -> Modifier + .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) + } + }.zIndex(20f) + + TutorialOverlay( + step = step, + onNext = { onAdvanceTutorial(step.actionRequired) }, + onBack = if (currentStepIndex > 0) { + { onRegressTutorial() } + } else null, + onClose = onCloseTutorial, + modifier = modifier + .then(tutorialModifier) + .onGloballyPositioned { coordinates -> + overlaySize = coordinates.size + } + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/BehaviorNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/BehaviorNodeFields.kt new file mode 100644 index 00000000..51acb18f --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/BehaviorNodeFields.kt @@ -0,0 +1,266 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun BehaviorNodeFields( + data: NodeData.Behavior, + onUpdate: (NodeData) -> Unit, + onDropdownEditComplete: () -> Unit, + onFieldEditComplete: () -> Unit, + textFieldsLocked: Boolean, + modifier: Modifier = Modifier, +) { + val behaviorNode = data.node + val nodeCase = behaviorNode.nodeCase + var expandedNodeTypes by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + // Node Type Selector + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + ExposedDropdownMenuBox( + expanded = expandedNodeTypes, + onExpandedChange = { expandedNodeTypes = it }, + modifier = Modifier.weight(1f) + ) { + OutlinedTextField( + value = stringResource(nodeCase.displayStringRId()), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.bg_node_type)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedNodeTypes) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expandedNodeTypes, + onDismissRequest = { expandedNodeTypes = false } + ) { + @Composable + fun DropdownSection( + label: String, + types: List, + modifier: Modifier = Modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp) + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = 8.dp) + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + types.forEach { type -> + DropdownMenuItem( + text = { Text(stringResource(type.displayStringRId())) }, + onClick = { + if (type != nodeCase) { + onUpdate(createDefaultNode(type)) + onDropdownEditComplete() + } + expandedNodeTypes = false + } + ) + } + } + + DropdownSection( + stringResource(R.string.bg_section_start_nodes), + NODE_TYPES_START + ) + DropdownSection( + stringResource(R.string.bg_section_operator_nodes), + NODE_TYPES_OPERATOR + ) + DropdownSection( + stringResource(R.string.bg_section_terminal_nodes), + NODE_TYPES_TERMINAL + ) + } + } + } + + // Developer Comment + if (nodeCase == ProtoBrushBehavior.Node.NodeCase.TARGET_NODE || + nodeCase == ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE + ) { + OutlinedTextField( + value = data.developerComment, + onValueChange = { + onUpdate(data.copy(developerComment = it)) + }, + label = { Text(stringResource(R.string.bg_developer_comment)) }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + minLines = 2, + enabled = !textFieldsLocked, + ) + } + + // Dispatch to specific node fields + when (nodeCase) { + ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> { + SourceNodeFields( + sourceNode = behaviorNode.sourceNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate, + onDropdownEditComplete = onDropdownEditComplete, + onFieldEditComplete = onFieldEditComplete, + ) + } + + ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> { + ConstantNodeFields( + constantNode = behaviorNode.constantNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate, + onFieldEditComplete = onFieldEditComplete + ) + } + + ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> { + NoiseNodeFields( + noiseNode = behaviorNode.noiseNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate, + onFieldEditComplete = onFieldEditComplete, + onDropdownEditComplete = onDropdownEditComplete, + textFieldsLocked = textFieldsLocked + ) + } + + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> { + ToolTypeFilterNodeFields( + filterNode = behaviorNode.toolTypeFilterNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate + ) + } + + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> { + DampingNodeFields( + dampingNode = behaviorNode.dampingNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate, + onFieldEditComplete = onFieldEditComplete, + onDropdownEditComplete = onDropdownEditComplete + ) + } + + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> { + ResponseNodeFields( + responseNode = behaviorNode.responseNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate + ) + } + + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> { + IntegralNodeFields( + integralNode = behaviorNode.integralNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate, + onFieldEditComplete = onFieldEditComplete, + onDropdownEditComplete = onDropdownEditComplete + ) + } + + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> { + BinaryOpNodeFields( + binaryNode = behaviorNode.binaryOpNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate, + onDropdownEditComplete = onDropdownEditComplete + ) + } + + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> { + InterpolationNodeFields( + interpNode = behaviorNode.interpolationNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate + ) + } + + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> { + TargetNodeFields( + targetNode = behaviorNode.targetNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate, + onFieldEditComplete = onFieldEditComplete, + onDropdownEditComplete = onDropdownEditComplete + ) + } + + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> { + PolarTargetNodeFields( + polarNode = behaviorNode.polarTargetNode, + behaviorNode = behaviorNode, + onUpdate = onUpdate, + onFieldEditComplete = onFieldEditComplete, + onDropdownEditComplete = onDropdownEditComplete + ) + } + + else -> { + // Fallback or empty view for unsupported nodes + Text( + stringResource( + R.string.bg_err_unsupported_behavior_node_type, + nodeCase.toString() + ) + ) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/BinaryOpNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/BinaryOpNodeFields.kt new file mode 100644 index 00000000..c79d5d95 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/BinaryOpNodeFields.kt @@ -0,0 +1,63 @@ +/* + * 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.fields + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.getTooltip +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun BinaryOpNodeFields( + binaryNode: ProtoBrushBehavior.BinaryOpNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + onDropdownEditComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + FieldWithTooltip( + modifier = modifier, + tooltipTitle = stringResource( + R.string.bg_title_operation_format, + stringResource(binaryNode.operation.displayStringRId()) + ), + tooltipText = stringResource(binaryNode.operation.getTooltip()), + ) { + EnumDropdown( + label = stringResource(R.string.bg_operation), + currentValue = binaryNode.operation, + values = ALL_BINARY_OPS.toList(), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { op -> + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setBinaryOpNode(binaryNode.toBuilder().setOperation(op).build()) + .build() + ) + ) + onDropdownEditComplete() + } + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/CoatNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/CoatNodeFields.kt new file mode 100644 index 00000000..9a85d6c8 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/CoatNodeFields.kt @@ -0,0 +1,34 @@ +/* + * 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.fields + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.example.cahier.R + +@Composable +fun CoatNodeFields( + modifier: Modifier = Modifier, +) { + Text( + modifier = modifier, + text = stringResource(R.string.bg_coat_node_description), + style = MaterialTheme.typography.bodySmall, + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ColorFunctionNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ColorFunctionNodeFields.kt new file mode 100644 index 00000000..80692eb3 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ColorFunctionNodeFields.kt @@ -0,0 +1,161 @@ +/* + * 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.fields + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushdesigner.ui.NumericLimits +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.getColorFunctionTooltip +import ink.proto.Color as ProtoColor +import ink.proto.ColorFunction as ProtoColorFunction + +@Composable +fun ColorFunctionNodeFields( + function: ProtoColorFunction, + onUpdate: (NodeData) -> Unit, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + onDropdownEditComplete: () -> Unit, + onFieldEditComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val currentTypeResId = if (function.hasOpacityMultiplier()) { + R.string.bg_opacity_multiplier + } else { + R.string.bg_replace_color + } + + Column(modifier = modifier) { + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_title_function_type_format, + stringResource(currentTypeResId) + ), + tooltipText = stringResource(getColorFunctionTooltip(currentTypeResId)), + ) { + EnumDropdown( + label = stringResource(R.string.bg_function_type), + currentValue = currentTypeResId, + values = listOf(R.string.bg_opacity_multiplier, R.string.bg_replace_color), + displayName = { stringResource(it) }, + onSelected = { resId -> + if (resId != currentTypeResId) { + onUpdate( + if (resId == R.string.bg_opacity_multiplier) { + NodeData.ColorFunction( + ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build() + ) + } else { + NodeData.ColorFunction( + ProtoColorFunction.newBuilder() + .setReplaceColor( + ProtoColor.newBuilder() + .setRed(0f) + .setGreen(0f) + .setBlue(0f) + .setAlpha(1f) + .build() + ) + .build() + ) + } + ) + } + onDropdownEditComplete() + } + ) + } + + if (function.hasOpacityMultiplier()) { + NumericField( + title = stringResource(R.string.bg_label_opacity_multiplier), + value = function.opacityMultiplier, + limits = NumericLimits(0f, 2f, 0.01f), + onValueChanged = { + onUpdate( + NodeData.ColorFunction( + function.toBuilder().setOpacityMultiplier(it).build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + } else if (function.hasReplaceColor()) { + val color = function.replaceColor + val composeColor = + Color(red = color.red, green = color.green, blue = color.blue, alpha = color.alpha) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 8.dp) + ) { + Text( + stringResource(R.string.bg_color_label), + style = MaterialTheme.typography.bodyMedium + ) + Surface( + onClick = { + onChooseColor(composeColor) { newColor -> + onUpdate( + NodeData.ColorFunction( + function.toBuilder() + .setReplaceColor( + ProtoColor.newBuilder() + .setRed(newColor.red) + .setGreen(newColor.green) + .setBlue(newColor.blue) + .setAlpha(newColor.alpha) + .build() + ) + .build() + ) + ) + } + }, + shape = RoundedCornerShape(4.dp), + color = composeColor, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + modifier = Modifier.size(40.dp) + ) {} + Spacer(Modifier.width(8.dp)) + Text( + text = String.format("ARGB #%08X", (composeColor.toArgb())), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ConstantNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ConstantNodeFields.kt new file mode 100644 index 00000000..99531445 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ConstantNodeFields.kt @@ -0,0 +1,51 @@ +/* + * 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.fields + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushdesigner.ui.NumericLimits +import com.example.cahier.developer.brushgraph.data.NodeData +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun ConstantNodeFields( + constantNode: ProtoBrushBehavior.ConstantNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + onFieldEditComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + NumericField( + modifier = modifier, + title = stringResource(R.string.bg_port_value), + value = constantNode.value, + limits = NumericLimits(-100f, 100f, 0.01f), + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setConstantNode(constantNode.toBuilder().setValue(it).build()).build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/DampingNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/DampingNodeFields.kt new file mode 100644 index 00000000..8e1b8cd2 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/DampingNodeFields.kt @@ -0,0 +1,94 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.ProgressDomainContext +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.data.getNumericLimits +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.getTooltip +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun DampingNodeFields( + dampingNode: ProtoBrushBehavior.DampingNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + onFieldEditComplete: () -> Unit, + onDropdownEditComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val limits = dampingNode.dampingSource.getNumericLimits(ProgressDomainContext.DAMPING) + Column(modifier = modifier) { + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_title_damping_source_format, + stringResource(dampingNode.dampingSource.displayStringRId()) + ), + tooltipText = stringResource(dampingNode.dampingSource.getTooltip()), + ) { + EnumDropdown( + label = stringResource(R.string.bg_damping_source), + currentValue = dampingNode.dampingSource, + values = ALL_PROGRESS_DOMAINS.toList(), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { domain -> + val newLimits = domain.getNumericLimits(ProgressDomainContext.DAMPING) + val clampedGap = dampingNode.dampingGap.coerceIn(newLimits.min, newLimits.max) + + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setDampingNode( + dampingNode.toBuilder() + .setDampingSource(domain) + .setDampingGap(clampedGap) + .build() + ) + .build() + ) + ) + onDropdownEditComplete() + } + ) + } + + NumericField( + title = stringResource(R.string.bg_label_damping_gap), + value = dampingNode.dampingGap, + limits = limits, + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setDampingNode(dampingNode.toBuilder().setDampingGap(it).build()) + .build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/FamilyNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/FamilyNodeFields.kt new file mode 100644 index 00000000..00fe613a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/FamilyNodeFields.kt @@ -0,0 +1,161 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushdesigner.ui.NumericLimits +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.getInputModelTooltip +import ink.proto.BrushFamily as ProtoBrushFamily + +@Composable +fun FamilyNodeFields( + data: NodeData.Family, + onUpdate: (NodeData) -> Unit, + onDropdownEditComplete: () -> Unit, + textFieldsLocked: Boolean, + modifier: Modifier = Modifier, +) { + + Column(modifier = modifier) { + OutlinedTextField( + value = data.name, + onValueChange = { onUpdate(data.copy(name = it)) }, + label = { Text(stringResource(R.string.bg_brush_family_name)) }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + singleLine = true, + enabled = !textFieldsLocked, + ) + OutlinedTextField( + value = data.developerComment, + onValueChange = { onUpdate(data.copy(developerComment = it)) }, + label = { Text(stringResource(R.string.bg_brush_developer_comment)) }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + minLines = 3, + enabled = !textFieldsLocked, + ) + + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_title_input_model_format, + stringResource(data.inputModel.displayStringRId()) + ), + tooltipText = stringResource(getInputModelTooltip(data.inputModel.displayStringRId())) + ) { + EnumDropdown( + label = stringResource(R.string.bg_input_model), + currentValue = data.inputModel.displayStringRId(), + values = listOf(R.string.bg_model_sliding_window, R.string.bg_model_passthrough), + displayName = { stringResource(it) }, + onSelected = { modelResId -> + val newModel = + when (modelResId) { + R.string.bg_model_passthrough -> + ProtoBrushFamily.InputModel.newBuilder() + .setPassthroughModel( + ProtoBrushFamily.PassthroughModel.getDefaultInstance() + ) + .build() + + R.string.bg_model_sliding_window -> + ProtoBrushFamily.InputModel.newBuilder() + .setSlidingWindowModel( + ProtoBrushFamily.SlidingWindowModel.newBuilder() + .setWindowSizeSeconds(0.02f) + .setExperimentalUpsamplingPeriodSeconds(0.005f) + ) + .build() + + else -> + ProtoBrushFamily.InputModel.newBuilder() + .setSlidingWindowModel( + ProtoBrushFamily.SlidingWindowModel.newBuilder() + .setWindowSizeSeconds(0.02f) + .setExperimentalUpsamplingPeriodSeconds(0.005f) + ) + .build() + } + onUpdate(data.copy(inputModel = newModel)) + onDropdownEditComplete() + } + ) + } + + val inputModel = data.inputModel + if (inputModel.hasSlidingWindowModel() || !inputModel.hasPassthroughModel()) { + val swModel = inputModel.slidingWindowModel + val windowMs = + if (swModel.hasWindowSizeSeconds()) (swModel.windowSizeSeconds * 1000).toLong() else 20L + val upsamplingHz = if (swModel.hasExperimentalUpsamplingPeriodSeconds()) { + val period = swModel.experimentalUpsamplingPeriodSeconds + if (period == Float.POSITIVE_INFINITY || period == 0f) 0 else (1f / period).toInt() + } else 180 + + Spacer(Modifier.height(16.dp)) + + NumericField( + title = stringResource(R.string.brush_designer_window_size_ms), + value = windowMs.toFloat(), + limits = NumericLimits(1f, 100f, 1f), + onValueChanged = { newValue -> + val newModel = inputModel.toBuilder() + .setSlidingWindowModel( + swModel.toBuilder() + .setWindowSizeSeconds(newValue / 1000f) + ) + .build() + onUpdate(data.copy(inputModel = newModel)) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_upsampling_frequency_hz), + value = upsamplingHz.toFloat(), + limits = NumericLimits(0f, 500f, 1f), + onValueChanged = { newValue -> + val newPeriod = if (newValue == 0f) Float.POSITIVE_INFINITY else 1f / newValue + val newModel = inputModel.toBuilder() + .setSlidingWindowModel( + swModel.toBuilder() + .setExperimentalUpsamplingPeriodSeconds(newPeriod) + ) + .build() + onUpdate(data.copy(inputModel = newModel)) + } + ) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/FieldsUtils.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/FieldsUtils.kt new file mode 100644 index 00000000..d4cd0f91 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/FieldsUtils.kt @@ -0,0 +1,161 @@ +/* + * 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.fields + +import androidx.ink.brush.InputToolType +import ink.proto.BrushBehavior as ProtoBrushBehavior + +internal val SOURCES_INPUT = listOf( + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE, + ProtoBrushBehavior.Source.SOURCE_TILT_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_TILT_X_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_TILT_Y_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_ORIENTATION_ABOUT_ZERO_IN_RADIANS +) + +internal val SOURCES_MOVEMENT = listOf( + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_SPEED_IN_CENTIMETERS_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_CENTIMETERS_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_DIRECTION_ABOUT_ZERO_IN_RADIANS, + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_X, + ProtoBrushBehavior.Source.SOURCE_NORMALIZED_DIRECTION_Y +) + +internal val SOURCES_DISTANCE = listOf( + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_CENTIMETERS, + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_CENTIMETERS, + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Source.SOURCE_DISTANCE_REMAINING_AS_FRACTION_OF_STROKE_LENGTH +) + +internal val SOURCES_TIME = listOf( + ProtoBrushBehavior.Source.SOURCE_TIME_OF_INPUT_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_STROKE_END_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_TIME_FROM_INPUT_TO_STROKE_END_IN_SECONDS +) + +internal val SOURCES_ACCELERATION = listOf( + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_CENTIMETERS_PER_SECOND_SQUARED +) + +internal val TARGETS_SIZE_SHAPE = listOf( + ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_HEIGHT_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_SIZE_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS, + ProtoBrushBehavior.Target.TARGET_PINCH_OFFSET, + ProtoBrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS, + ProtoBrushBehavior.Target.TARGET_CORNER_ROUNDING_OFFSET +) + +internal val TARGETS_POSITION = listOf( + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_X_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_Y_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Target.TARGET_POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE +) + +internal val TARGETS_COLOR_OPACITY = listOf( + ProtoBrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS, + ProtoBrushBehavior.Target.TARGET_SATURATION_MULTIPLIER, + ProtoBrushBehavior.Target.TARGET_LUMINOSITY_OFFSET, + ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER +) + +internal val ALL_POLAR_TARGETS = + ProtoBrushBehavior.PolarTarget.values() + .filter { it != ProtoBrushBehavior.PolarTarget.POLAR_UNSPECIFIED && it.ordinal >= 0 } + .toTypedArray() + +internal val ALL_BINARY_OPS = + ProtoBrushBehavior.BinaryOp.values() + .filter { it != ProtoBrushBehavior.BinaryOp.BINARY_OP_UNSPECIFIED && it.ordinal >= 0 } + .toTypedArray() + +internal val ALL_OUT_OF_RANGE = + ProtoBrushBehavior.OutOfRange.values() + .filter { it != ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_UNSPECIFIED && it.ordinal >= 0 } + .toTypedArray() + +internal val ALL_PROGRESS_DOMAINS = + ProtoBrushBehavior.ProgressDomain.values() + .filter { + it != ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_UNSPECIFIED && it.ordinal >= 0 + } + .toTypedArray() + +internal val ALL_INTERPOLATIONS = + ProtoBrushBehavior.Interpolation.values() + .filter { it != ProtoBrushBehavior.Interpolation.INTERPOLATION_UNSPECIFIED && it.ordinal >= 0 } + .toTypedArray() + +internal val ALL_TOOL_TYPES = + arrayOf(InputToolType.STYLUS, InputToolType.TOUCH, InputToolType.MOUSE, InputToolType.UNKNOWN) + +internal fun ProtoBrushBehavior.Source.isAngle(): Boolean { + return this == ProtoBrushBehavior.Source.SOURCE_TILT_IN_RADIANS || + this == ProtoBrushBehavior.Source.SOURCE_TILT_X_IN_RADIANS || + this == ProtoBrushBehavior.Source.SOURCE_TILT_Y_IN_RADIANS || + this == ProtoBrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS || + this == ProtoBrushBehavior.Source.SOURCE_ORIENTATION_IN_RADIANS || + this == ProtoBrushBehavior.Source.SOURCE_DIRECTION_ABOUT_ZERO_IN_RADIANS || + this == ProtoBrushBehavior.Source.SOURCE_ORIENTATION_ABOUT_ZERO_IN_RADIANS +} + +internal fun ProtoBrushBehavior.Target.isAngle(): Boolean { + return this == ProtoBrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS || + this == ProtoBrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS || + this == ProtoBrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS +} + +internal val NODE_TYPES_START = listOf( + ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE, + ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE, + ProtoBrushBehavior.Node.NodeCase.NOISE_NODE +) + +internal val NODE_TYPES_OPERATOR = listOf( + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE, + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE, + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE, + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE, + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE, + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE +) + +internal val NODE_TYPES_TERMINAL = listOf( + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE, + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE +) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/IntegralNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/IntegralNodeFields.kt new file mode 100644 index 00000000..853c2c6a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/IntegralNodeFields.kt @@ -0,0 +1,132 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.ProgressDomainContext +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.data.getNumericLimits +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun IntegralNodeFields( + integralNode: ProtoBrushBehavior.IntegralNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + onFieldEditComplete: () -> Unit, + onDropdownEditComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val limits = integralNode.integrateOver.getNumericLimits(ProgressDomainContext.INTEGRAL) + Column(modifier = modifier) { + Row(modifier = Modifier.fillMaxWidth()) { + EnumDropdown( + label = stringResource(R.string.bg_integrate_over), + currentValue = integralNode.integrateOver, + values = ALL_PROGRESS_DOMAINS.toList(), + modifier = Modifier.weight(1f), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { domain -> + val newLimits = domain.getNumericLimits(ProgressDomainContext.INTEGRAL) + val clampedStart = + integralNode.integralValueRangeStart.coerceIn(newLimits.min, newLimits.max) + val clampedEnd = + integralNode.integralValueRangeEnd.coerceIn(newLimits.min, newLimits.max) + + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setIntegralNode( + integralNode.toBuilder() + .setIntegrateOver(domain) + .setIntegralValueRangeStart(clampedStart) + .setIntegralValueRangeEnd(clampedEnd) + .build() + ) + .build() + ) + ) + onDropdownEditComplete() + } + ) + } + + NumericField( + title = stringResource(R.string.bg_label_range_start), + value = integralNode.integralValueRangeStart, + limits = limits, + onValueChangeFinished = onFieldEditComplete + ) { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setIntegralNode( + integralNode.toBuilder().setIntegralValueRangeStart(it).build() + ) + .build() + ) + ) + } + NumericField( + title = stringResource(R.string.bg_label_range_end), + value = integralNode.integralValueRangeEnd, + limits = limits, + onValueChangeFinished = onFieldEditComplete + ) { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setIntegralNode( + integralNode.toBuilder().setIntegralValueRangeEnd(it).build() + ) + .build() + ) + ) + } + + Row(modifier = Modifier.fillMaxWidth()) { + EnumDropdown( + label = stringResource(R.string.bg_out_of_range_behavior), + currentValue = integralNode.integralOutOfRangeBehavior, + values = ALL_OUT_OF_RANGE.toList(), + modifier = Modifier.weight(1f), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { oor -> + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setIntegralNode( + integralNode.toBuilder().setIntegralOutOfRangeBehavior(oor) + .build() + ) + .build() + ) + ) + } + ) + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/InterpolationNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/InterpolationNodeFields.kt new file mode 100644 index 00000000..625dbdb8 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/InterpolationNodeFields.kt @@ -0,0 +1,63 @@ +/* + * 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.fields + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.getTooltip +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun InterpolationNodeFields( + interpNode: ProtoBrushBehavior.InterpolationNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + modifier: Modifier = Modifier, +) { + FieldWithTooltip( + modifier = modifier, + tooltipTitle = stringResource( + R.string.bg_title_interpolation_format, + stringResource(interpNode.interpolation.displayStringRId()) + ), + tooltipText = stringResource(interpNode.interpolation.getTooltip()), + ) { + EnumDropdown( + label = stringResource(R.string.bg_interpolation), + currentValue = interpNode.interpolation, + values = ALL_INTERPOLATIONS.toList(), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { interp -> + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setInterpolationNode( + interpNode.toBuilder().setInterpolation(interp).build() + ) + .build() + ) + ) + } + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/NodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/NodeFields.kt new file mode 100644 index 00000000..c75d650f --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/NodeFields.kt @@ -0,0 +1,245 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction + +/** Renders the editable fields for a node. */ +@Composable +fun NodeFields( + node: GraphNode, + textFieldsLocked: Boolean, + strokeRenderer: CanvasStrokeRenderer, + allTextureIds: Set, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + onUpdate: (NodeData) -> Unit, + onLoadTexture: () -> Unit, + onFieldEditComplete: () -> Unit = {}, + onDropdownEditComplete: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .padding(top = 8.dp) + .heightIn(max = 600.dp) + .verticalScroll(rememberScrollState()) + ) { + when (val data = node.data) { + is NodeData.Behavior -> { + BehaviorNodeFields( + data = data, + onUpdate = onUpdate, + onDropdownEditComplete = onDropdownEditComplete, + onFieldEditComplete = onFieldEditComplete, + textFieldsLocked = textFieldsLocked + ) + } + + is NodeData.ColorFunction -> { + ColorFunctionNodeFields( + function = data.function, + onUpdate = onUpdate, + onChooseColor = onChooseColor, + onDropdownEditComplete = onDropdownEditComplete, + onFieldEditComplete = onFieldEditComplete + ) + } + + is NodeData.Family -> { + FamilyNodeFields( + data = data, + onUpdate = onUpdate, + onDropdownEditComplete = onDropdownEditComplete, + textFieldsLocked = textFieldsLocked + ) + } + + is NodeData.Tip -> { + TipNodeFields( + data = data, + onUpdate = onUpdate, + onFieldEditComplete = onFieldEditComplete, + strokeRenderer = strokeRenderer + ) + } + + is NodeData.Coat -> { + CoatNodeFields() + } + + is NodeData.Paint -> { + PaintNodeFields( + data = data, + onUpdate = onUpdate, + onDropdownEditComplete = onDropdownEditComplete + ) + } + + is NodeData.TextureLayer -> { + TextureLayerNodeFields( + layer = data.layer, + allTextureIds = allTextureIds, + onLoadTexture = onLoadTexture, + onUpdate = { onUpdate(it) }, + strokeRenderer = strokeRenderer + ) + } + } + } +} + +internal fun createDefaultNode(nodeCase: ProtoBrushBehavior.Node.NodeCase): NodeData { + return when (nodeCase) { + ProtoBrushBehavior.Node.NodeCase.SOURCE_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setSourceNode( + ProtoBrushBehavior.SourceNode.newBuilder() + .setSource(ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .setSourceOutOfRangeBehavior(ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) + ) + .build() + ) + + ProtoBrushBehavior.Node.NodeCase.CONSTANT_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setConstantNode(ProtoBrushBehavior.ConstantNode.newBuilder().setValue(0f)) + .build() + ) + + ProtoBrushBehavior.Node.NodeCase.NOISE_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setNoiseNode( + ProtoBrushBehavior.NoiseNode.newBuilder() + .setSeed(0) + .setVaryOver(ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE) + .setBasePeriod(1f) + ) + .build() + ) + + ProtoBrushBehavior.Node.NodeCase.TOOL_TYPE_FILTER_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setToolTypeFilterNode( + ProtoBrushBehavior.ToolTypeFilterNode.newBuilder() + .setEnabledToolTypes(1 shl 3) // Stylus + ) + .build() + ) + + ProtoBrushBehavior.Node.NodeCase.DAMPING_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setDampingNode( + ProtoBrushBehavior.DampingNode.newBuilder() + .setDampingSource(ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE) + .setDampingGap(0.1f) + ) + .build() + ) + + ProtoBrushBehavior.Node.NodeCase.RESPONSE_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setResponseNode( + ProtoBrushBehavior.ResponseNode.newBuilder() + .setPredefinedResponseCurve(ProtoPredefinedEasingFunction.PREDEFINED_EASING_LINEAR) + ) + .build() + ) + + ProtoBrushBehavior.Node.NodeCase.INTEGRAL_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setIntegralNode( + ProtoBrushBehavior.IntegralNode.newBuilder() + .setIntegrateOver(ProtoBrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS) + .setIntegralValueRangeStart(0f) + .setIntegralValueRangeEnd(1f) + .setIntegralOutOfRangeBehavior(ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) + ) + .build() + ) + + ProtoBrushBehavior.Node.NodeCase.BINARY_OP_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode( + ProtoBrushBehavior.BinaryOpNode.newBuilder() + .setOperation(ProtoBrushBehavior.BinaryOp.BINARY_OP_SUM) + ) + .build() + ) + + ProtoBrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setInterpolationNode( + ProtoBrushBehavior.InterpolationNode.newBuilder() + .setInterpolation(ProtoBrushBehavior.Interpolation.INTERPOLATION_LERP) + ) + .build() + ) + + ProtoBrushBehavior.Node.NodeCase.TARGET_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setTargetNode( + ProtoBrushBehavior.TargetNode.newBuilder() + .setTarget(ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER) + .setTargetModifierRangeStart(0f) + .setTargetModifierRangeEnd(1f) + ) + .build() + ) + + ProtoBrushBehavior.Node.NodeCase.POLAR_TARGET_NODE -> + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setPolarTargetNode( + ProtoBrushBehavior.PolarTargetNode.newBuilder() + .setTarget(ProtoBrushBehavior.PolarTarget.POLAR_POSITION_OFFSET_RELATIVE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE) + .setAngleRangeStart(0f) + .setAngleRangeEnd(6.28f) + .setMagnitudeRangeStart(0f) + .setMagnitudeRangeEnd(1f) + ) + .build() + ) + + else -> throw IllegalArgumentException("Unsupported node case: $nodeCase") + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/NoiseNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/NoiseNodeFields.kt new file mode 100644 index 00000000..3ad44f7a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/NoiseNodeFields.kt @@ -0,0 +1,114 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushdesigner.ui.NumericLimits +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.ProgressDomainContext +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.data.getNumericLimits +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.getTooltip +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun NoiseNodeFields( + noiseNode: ProtoBrushBehavior.NoiseNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + onFieldEditComplete: () -> Unit, + onDropdownEditComplete: () -> Unit, + textFieldsLocked: Boolean, + modifier: Modifier = Modifier, +) { + val limits = noiseNode.varyOver.getNumericLimits(ProgressDomainContext.NOISE) + + Column(modifier = modifier) { + NumericField( + title = stringResource(R.string.bg_label_seed), + value = noiseNode.seed.toFloat(), + limits = NumericLimits(0f, 100f, 1f), + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setNoiseNode(noiseNode.toBuilder().setSeed(it.toInt()).build()) + .build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_title_vary_over_format, + stringResource(noiseNode.varyOver.displayStringRId()) + ), + tooltipText = stringResource(noiseNode.varyOver.getTooltip()) + ) { + EnumDropdown( + label = stringResource(R.string.bg_vary_over), + currentValue = noiseNode.varyOver, + values = ALL_PROGRESS_DOMAINS.toList(), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { domain -> + val newLimits = domain.getNumericLimits(ProgressDomainContext.NOISE) + val clampedBasePeriod = + noiseNode.basePeriod.coerceIn(newLimits.min, newLimits.max) + + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setNoiseNode( + noiseNode.toBuilder() + .setVaryOver(domain) + .setBasePeriod(clampedBasePeriod) + .build() + ) + .build() + ) + ) + onDropdownEditComplete() + } + ) + } + + NumericField( + title = stringResource(R.string.bg_label_base_period), + value = noiseNode.basePeriod, + limits = limits, + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setNoiseNode(noiseNode.toBuilder().setBasePeriod(it).build()) + .build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/PaintNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/PaintNodeFields.kt new file mode 100644 index 00000000..57615b51 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/PaintNodeFields.kt @@ -0,0 +1,69 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.getTooltip +import ink.proto.BrushPaint as ProtoBrushPaint + +@Composable +fun PaintNodeFields( + data: NodeData.Paint, + onUpdate: (NodeData) -> Unit, + onDropdownEditComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val paint = data.paint + FieldWithTooltip( + modifier = modifier.padding(vertical = 4.dp), + tooltipTitle = stringResource( + R.string.bg_title_self_overlap_format, + stringResource(paint.selfOverlap.displayStringRId()) + ), + tooltipText = stringResource(paint.selfOverlap.getTooltip()), + ) { + EnumDropdown( + label = stringResource(R.string.bg_self_overlap), + currentValue = paint.selfOverlap, + values = listOf( + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ANY, + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ACCUMULATE, + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_DISCARD, + ), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { so -> + onUpdate( + NodeData.Paint( + paint.toBuilder().setSelfOverlap(so).build(), + texturePortIds = data.texturePortIds, + colorPortIds = data.colorPortIds + ) + ) + onDropdownEditComplete() + } + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/PolarTargetNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/PolarTargetNodeFields.kt new file mode 100644 index 00000000..76732813 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/PolarTargetNodeFields.kt @@ -0,0 +1,146 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushdesigner.ui.NumericLimits +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.getTooltip +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun PolarTargetNodeFields( + polarNode: ProtoBrushBehavior.PolarTargetNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + onFieldEditComplete: () -> Unit, + onDropdownEditComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_title_polar_target_format, + stringResource(polarNode.target.displayStringRId()) + ), + tooltipText = stringResource(polarNode.target.getTooltip()), + ) { + EnumDropdown( + label = stringResource(R.string.bg_polar_target), + currentValue = polarNode.target, + values = ALL_POLAR_TARGETS.toList(), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { target -> + val newMagLimits = NumericLimits(-10.0f, 10.0f, 0.01f) + val clampedMagStart = + polarNode.magnitudeRangeStart.coerceIn(newMagLimits.min, newMagLimits.max) + val clampedMagEnd = + polarNode.magnitudeRangeEnd.coerceIn(newMagLimits.min, newMagLimits.max) + + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setPolarTargetNode( + polarNode.toBuilder() + .setTarget(target) + .setMagnitudeRangeStart(clampedMagStart) + .setMagnitudeRangeEnd(clampedMagEnd) + .build() + ) + .build() + ) + ) + onDropdownEditComplete() + } + ) + } + + NumericField( + title = stringResource(R.string.bg_label_angle_start), + value = polarNode.angleRangeStart, + limits = NumericLimits.radiansShownAsDegrees(-360f, 360f), + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder().setPolarTargetNode( + polarNode.toBuilder().setAngleRangeStart(it).build() + ).build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + + NumericField( + title = stringResource(R.string.bg_label_angle_end), + value = polarNode.angleRangeEnd, + limits = NumericLimits.radiansShownAsDegrees(-360f, 360f), + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setPolarTargetNode(polarNode.toBuilder().setAngleRangeEnd(it).build()) + .build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + + val magLimits = NumericLimits(-10.0f, 10.0f, 0.01f) + + NumericField( + title = stringResource(R.string.bg_label_mag_start), + value = polarNode.magnitudeRangeStart, + limits = magLimits, + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder().setPolarTargetNode( + polarNode.toBuilder().setMagnitudeRangeStart(it).build() + ).build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + + NumericField( + title = stringResource(R.string.bg_label_mag_end), + value = polarNode.magnitudeRangeEnd, + limits = magLimits, + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder().setPolarTargetNode( + polarNode.toBuilder().setMagnitudeRangeEnd(it).build() + ).build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ResponseNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ResponseNodeFields.kt new file mode 100644 index 00000000..5140e230 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ResponseNodeFields.kt @@ -0,0 +1,543 @@ +/* + * 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.fields + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushdesigner.ui.NumericLimits +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import ink.proto.LinearEasingFunction +import kotlin.math.ceil +import kotlin.math.floor +import androidx.compose.ui.graphics.drawscope.Stroke as DrawStroke +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.CubicBezierEasingFunction as ProtoCubicBezier +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction +import ink.proto.StepPosition as ProtoStepPosition +import ink.proto.StepsEasingFunction as ProtoSteps + +@Composable +fun ResponseNodeFields( + responseNode: ProtoBrushBehavior.ResponseNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + CurvePreviewWidget( + responseNode = responseNode, + modifier = Modifier.padding(bottom = 8.dp) + ) + ResponseCurveWidget( + responseNode = responseNode, + onResponseNodeChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder().setResponseNode(it).build() + ) + ) + } + ) + } +} + +@Composable +fun CurvePreviewWidget( + responseNode: ProtoBrushBehavior.ResponseNode, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier + .height(120.dp) + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + val widthPx = constraints.maxWidth.toFloat() + val heightPx = constraints.maxHeight.toFloat() + + val backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow + val primaryColor = MaterialTheme.colorScheme.primary + val outlineColor = MaterialTheme.colorScheme.outline + val outlineVariantColor = MaterialTheme.colorScheme.outlineVariant + + Canvas(modifier = Modifier.fillMaxSize()) { + // Background and Grid + drawRect(backgroundColor) + val centerY = heightPx * 0.8f + val scaleY = heightPx * 0.6f + + // Zero line + drawLine(outlineColor, Offset(0f, centerY), Offset(widthPx, centerY)) + // One line + drawLine( + outlineVariantColor, + Offset(0f, centerY - scaleY), + Offset(widthPx, centerY - scaleY) + ) + + val path = androidx.compose.ui.graphics.Path() + when (responseNode.responseCurveCase) { + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> { + val c = responseNode.cubicBezierResponseCurve + path.moveTo(0f, centerY) + path.cubicTo( + c.x1 * widthPx, + centerY - c.y1 * scaleY, + c.x2 * widthPx, + centerY - c.y2 * scaleY, + widthPx, + centerY - scaleY + ) + } + + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> { + val s = responseNode.stepsResponseCurve + val steps = s.stepCount.coerceAtLeast(1) + val position = s.stepPosition + path.moveTo(0f, centerY) + val numSamples = 200 + for (i in 0..numSamples) { + val x = i.toFloat() / numSamples + val yVal = evaluateSteps(x, steps, position) + val px = x * widthPx + val py = centerY - yVal * scaleY + if (i == 0) path.moveTo(px, py) else path.lineTo(px, py) + } + } + + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> { + val l = responseNode.linearResponseCurve + val xs = l.xList + val ys = l.yList + path.moveTo(0f, centerY) + if (xs.isEmpty()) { + path.lineTo(widthPx, centerY - scaleY) + } else { + for (i in xs.indices) { + path.lineTo(xs[i] * widthPx, centerY - ys[i] * scaleY) + } + path.lineTo(widthPx, centerY - scaleY) + } + } + + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE -> { + val func = responseNode.predefinedResponseCurve + when (func) { + ProtoPredefinedEasingFunction.PREDEFINED_EASING_LINEAR -> { + path.moveTo(0f, centerY) + path.lineTo(widthPx, centerY - scaleY) + } + + ProtoPredefinedEasingFunction.PREDEFINED_EASING_STEP_START -> { + path.moveTo(0f, centerY - scaleY) + path.lineTo(widthPx, centerY - scaleY) + } + + ProtoPredefinedEasingFunction.PREDEFINED_EASING_STEP_END -> { + path.moveTo(0f, centerY) + path.lineTo(widthPx, centerY) + path.lineTo(widthPx, centerY - scaleY) + } + + else -> { + var x1 = 0f + var y1 = 0f + var x2 = 1f + var y2 = 1f + + when (func) { + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE -> { + x1 = 0.25f + y1 = 0.1f + x2 = 0.25f + y2 = 1f + } + + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_IN -> { + x1 = 0.42f + y1 = 0f + x2 = 1f + y2 = 1f + } + + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_OUT -> { + x1 = 0f + y1 = 0f + x2 = 0.58f + y2 = 1f + } + + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE_IN_OUT -> { + x1 = 0.42f + y1 = 0f + x2 = 0.58f + y2 = 1f + } + + else -> { + x1 = 0f + y1 = 0f + x2 = 1f + y2 = 1f + } + } + path.moveTo(0f, centerY) + path.cubicTo( + x1 * widthPx, + centerY - y1 * scaleY, + x2 * widthPx, + centerY - y2 * scaleY, + widthPx, + centerY - scaleY + ) + } + } + } + + else -> { + path.moveTo(0f, centerY) + path.lineTo(widthPx, centerY - scaleY) + } + } + drawPath( + path, + primaryColor, + style = DrawStroke(width = 3.dp.toPx()) + ) + } + } +} + +private fun evaluateSteps(x: Float, n: Int, position: ProtoStepPosition): Float { + val xClamped = x.coerceIn(0f, 1f) + return when (position) { + ProtoStepPosition.STEP_POSITION_JUMP_START -> { + ceil(xClamped * n).coerceAtLeast(1f) / n + } + + ProtoStepPosition.STEP_POSITION_JUMP_BOTH -> { + floor(xClamped * (n + 1) + 1f) / (n + 1) + } + + ProtoStepPosition.STEP_POSITION_JUMP_NONE -> { + if (n <= 1) xClamped else floor(xClamped * (n - 1)) / (n - 1) + } + + else -> { + floor(xClamped * n) / n + } + } +} + +@Composable +fun ResponseCurveWidget( + responseNode: ProtoBrushBehavior.ResponseNode, + onResponseNodeChanged: (ProtoBrushBehavior.ResponseNode) -> Unit, + modifier: Modifier = Modifier, +) { + val currentCase = responseNode.responseCurveCase + val tabs = + listOf( + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE, + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE, + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE, + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE, + ) + + val selectedTabIndex = tabs.indexOf(currentCase).coerceAtLeast(0) + + Column(modifier = modifier) { + TabRow(selectedTabIndex = selectedTabIndex) { + tabs.forEachIndexed { index, case -> + Tab( + selected = selectedTabIndex == index, + onClick = { + if (currentCase != case) { + val builder = responseNode.toBuilder() + when (case) { + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE -> + builder.setPredefinedResponseCurve( + ProtoPredefinedEasingFunction.PREDEFINED_EASING_LINEAR + ) + + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> + builder.setCubicBezierResponseCurve( + ProtoCubicBezier.newBuilder() + .setX1(0.5f) + .setY1(0f) + .setX2(0.5f) + .setY2(1f) + .build() + ) + + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> + builder.setStepsResponseCurve( + ProtoSteps.newBuilder() + .setStepCount(3) + .setStepPosition(ProtoStepPosition.STEP_POSITION_JUMP_START) + .build() + ) + + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> + builder.setLinearResponseCurve( + LinearEasingFunction.getDefaultInstance() + ) + + else -> {} + } + onResponseNodeChanged(builder.build()) + } + }, + text = { Text(stringResource(case.displayStringRId())) } + ) + } + } + + Box( + modifier = + Modifier + .padding(vertical = 8.dp) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + when (currentCase) { + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> + CubicBezierWidget(responseNode.cubicBezierResponseCurve) { + onResponseNodeChanged( + responseNode.toBuilder().setCubicBezierResponseCurve(it).build() + ) + } + + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> + StepsWidget(responseNode.stepsResponseCurve) { + onResponseNodeChanged( + responseNode.toBuilder().setStepsResponseCurve(it).build() + ) + } + + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE -> + PredefinedFunctionWidget(responseNode.predefinedResponseCurve) { + onResponseNodeChanged( + responseNode.toBuilder().setPredefinedResponseCurve(it).build() + ) + } + + ProtoBrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> + LinearWidget(responseNode.linearResponseCurve) { + onResponseNodeChanged( + responseNode.toBuilder().setLinearResponseCurve(it).build() + ) + } + + else -> Text( + stringResource(R.string.bg_unknown_curve_type), + modifier = Modifier.padding(16.dp) + ) + } + } + } +} + +@Composable +fun LinearWidget( + curve: LinearEasingFunction, + modifier: Modifier = Modifier, + onCurveChanged: (LinearEasingFunction) -> Unit, +) { + Column(modifier = modifier.padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.bg_points), + style = MaterialTheme.typography.titleSmall + ) + IconButton( + onClick = { + val builder = curve.toBuilder() + val newX = + if (curve.xCount > 0) (curve.getX(curve.xCount - 1) + 1f) / 2f else 0.5f + val newY = + if (curve.yCount > 0) (curve.getY(curve.yCount - 1) + 1f) / 2f else 0.5f + builder.addX(newX).addY(newY).build() + onCurveChanged(builder.build()) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(R.string.bg_cd_add_point) + ) + } + } + + for (i in 0 until curve.xCount) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + NumericField( + title = "X", + value = curve.getX(i), + limits = NumericLimits(0f, 1f, 0.01f), + onValueChanged = { newValue -> + val builder = curve.toBuilder() + builder.setX(i, newValue) + onCurveChanged(builder.build()) + } + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + NumericField( + title = "Y", + value = curve.getY(i), + limits = NumericLimits(-2f, 2f, 0.01f), + onValueChanged = { newValue -> + val builder = curve.toBuilder() + builder.setY(i, newValue) + onCurveChanged(builder.build()) + } + ) + } + IconButton( + onClick = { + val newXs = curve.xList.toMutableList() + val newYs = curve.yList.toMutableList() + if (i < newXs.size && i < newYs.size) { + newXs.removeAt(i) + newYs.removeAt(i) + } + onCurveChanged( + curve.toBuilder() + .clearX().addAllX(newXs) + .clearY().addAllY(newYs) + .build() + ) + } + ) { + Icon( + Icons.Default.Remove, + contentDescription = stringResource(R.string.bg_cd_remove_point) + ) + } + } + } + } +} + +@Composable +fun CubicBezierWidget( + curve: ProtoCubicBezier, + modifier: Modifier = Modifier, + onCurveChanged: (ProtoCubicBezier) -> Unit, +) { + Column(modifier = modifier.padding(8.dp)) { + NumericField( + title = "x1", + value = curve.x1, + limits = NumericLimits(0f, 1f, 0.01f), + onValueChanged = { onCurveChanged(curve.toBuilder().setX1(it).build()) }) + NumericField( + title = "y1", + value = curve.y1, + limits = NumericLimits(-2f, 2f, 0.01f), + onValueChanged = { onCurveChanged(curve.toBuilder().setY1(it).build()) }) + NumericField( + title = "x2", + value = curve.x2, + limits = NumericLimits(0f, 1f, 0.01f), + onValueChanged = { onCurveChanged(curve.toBuilder().setX2(it).build()) }) + NumericField( + title = "y2", + value = curve.y2, + limits = NumericLimits(-2f, 2f, 0.01f), + onValueChanged = { onCurveChanged(curve.toBuilder().setY2(it).build()) }) + } +} + +@Composable +fun StepsWidget( + curve: ProtoSteps, + modifier: Modifier = Modifier, + onCurveChanged: (ProtoSteps) -> Unit, +) { + Column(modifier = modifier.padding(8.dp)) { + NumericField( + title = stringResource(R.string.bg_label_step_count), + value = curve.stepCount.toFloat(), + limits = NumericLimits(1f, 20f, 1f), + onValueChanged = { + onCurveChanged(curve.toBuilder().setStepCount(it.toInt()).build()) + } + ) + EnumDropdown( + label = stringResource(R.string.bg_step_position), + currentValue = curve.stepPosition, + values = ProtoStepPosition.values().filter { + it != ProtoStepPosition.STEP_POSITION_UNSPECIFIED && it.ordinal >= 0 + }.toList(), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { position -> + onCurveChanged(curve.toBuilder().setStepPosition(position).build()) + } + ) + } +} + +@Composable +fun PredefinedFunctionWidget( + current: ProtoPredefinedEasingFunction, + modifier: Modifier = Modifier, + onChanged: (ProtoPredefinedEasingFunction) -> Unit, +) { + EnumDropdown( + label = stringResource(R.string.bg_predefined_function), + currentValue = current, + values = ProtoPredefinedEasingFunction.values().filter { + it != ProtoPredefinedEasingFunction.PREDEFINED_EASING_UNSPECIFIED && it.ordinal >= 0 + }.toList(), + modifier = modifier.padding(8.dp), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { func -> + onChanged(func) + } + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/SourceNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/SourceNodeFields.kt new file mode 100644 index 00000000..b92a7616 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/SourceNodeFields.kt @@ -0,0 +1,262 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.data.getNumericLimits +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.TooltipDialog +import com.example.cahier.developer.brushgraph.ui.getTooltip +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SourceNodeFields( + sourceNode: ProtoBrushBehavior.SourceNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + onDropdownEditComplete: () -> Unit, + onFieldEditComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val limits = sourceNode.source.getNumericLimits() + var expandedSource by remember { mutableStateOf(false) } + var showSourceTooltip by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + ExposedDropdownMenuBox( + expanded = expandedSource, + onExpandedChange = { expandedSource = it }, + modifier = Modifier.weight(1f) + ) { + OutlinedTextField( + value = stringResource(sourceNode.source.displayStringRId()), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.bg_source)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedSource) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expandedSource, + onDismissRequest = { expandedSource = false } + ) { + @Composable + fun SourceSection( + label: String, + sources: List, + modifier: Modifier = Modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp) + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = 8.dp) + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + sources.forEach { source -> + DropdownMenuItem( + text = { Text(stringResource(source.displayStringRId())) }, + onClick = { + val currentDisplayStart = + if (sourceNode.source.isAngle()) Math.toDegrees(sourceNode.sourceValueRangeStart.toDouble()) + .toFloat() else sourceNode.sourceValueRangeStart + val currentDisplayEnd = + if (sourceNode.source.isAngle()) Math.toDegrees(sourceNode.sourceValueRangeEnd.toDouble()) + .toFloat() else sourceNode.sourceValueRangeEnd + + val newLimits = source.getNumericLimits() + val clampedDisplayStart = + currentDisplayStart.coerceIn(newLimits.min, newLimits.max) + val clampedDisplayEnd = + currentDisplayEnd.coerceIn(newLimits.min, newLimits.max) + + val newProtoStart = + if (source.isAngle()) Math.toRadians(clampedDisplayStart.toDouble()) + .toFloat() else clampedDisplayStart + val newProtoEnd = + if (source.isAngle()) Math.toRadians(clampedDisplayEnd.toDouble()) + .toFloat() else clampedDisplayEnd + + val needsClamp = source.isTimeSince() + val newOor = + if (needsClamp) ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP else sourceNode.sourceOutOfRangeBehavior + + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setSourceNode( + sourceNode.toBuilder() + .setSource(source) + .setSourceOutOfRangeBehavior(newOor) + .setSourceValueRangeStart(newProtoStart) + .setSourceValueRangeEnd(newProtoEnd) + .build() + ) + .build() + ) + ) + onDropdownEditComplete() + expandedSource = false + } + ) + } + } + + SourceSection(stringResource(R.string.bg_section_input), SOURCES_INPUT) + SourceSection(stringResource(R.string.bg_section_movement), SOURCES_MOVEMENT) + SourceSection(stringResource(R.string.bg_section_distance), SOURCES_DISTANCE) + SourceSection(stringResource(R.string.bg_section_time), SOURCES_TIME) + SourceSection( + stringResource(R.string.bg_section_acceleration), + SOURCES_ACCELERATION + ) + } + } + IconButton(onClick = { showSourceTooltip = true }) { + Icon( + Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(R.string.bg_cd_help) + ) + } + } + + if (showSourceTooltip) { + TooltipDialog( + title = stringResource( + R.string.bg_title_source_format, + stringResource(sourceNode.source.displayStringRId()) + ), + text = stringResource(sourceNode.source.getTooltip()), + onDismiss = { showSourceTooltip = false } + ) + } + NumericField( + title = stringResource(R.string.bg_label_range_start), + value = sourceNode.sourceValueRangeStart, + limits = limits, + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder().setSourceNode( + sourceNode.toBuilder().setSourceValueRangeStart(it).build() + ).build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_range_end), + value = sourceNode.sourceValueRangeEnd, + limits = limits, + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder().setSourceNode( + sourceNode.toBuilder().setSourceValueRangeEnd(it).build() + ).build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + val isTimeSinceSource = sourceNode.source.isTimeSince() + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_title_out_of_range_behavior_format, + stringResource(sourceNode.sourceOutOfRangeBehavior.displayStringRId()) + ), + tooltipText = stringResource(sourceNode.sourceOutOfRangeBehavior.getTooltip()) + ) { + EnumDropdown( + label = stringResource(R.string.bg_out_of_range_behavior), + currentValue = sourceNode.sourceOutOfRangeBehavior, + values = if (isTimeSinceSource) { + listOf(ProtoBrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) + } else { + ALL_OUT_OF_RANGE.toList() + }, + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { oor -> + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder().setSourceNode( + sourceNode.toBuilder().setSourceOutOfRangeBehavior(oor).build() + ).build() + ) + ) + onDropdownEditComplete() + } + ) + } + if (isTimeSinceSource) { + Text( + text = stringResource(R.string.bg_msg_source_clamp_only), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} + +private fun ProtoBrushBehavior.Source.isTimeSince(): Boolean { + return this == ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_SECONDS || + this == ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_STROKE_END_IN_SECONDS +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TargetNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TargetNodeFields.kt new file mode 100644 index 00000000..14cd0442 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TargetNodeFields.kt @@ -0,0 +1,217 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.data.getNumericLimits +import com.example.cahier.developer.brushgraph.ui.TooltipDialog +import com.example.cahier.developer.brushgraph.ui.getTooltip +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TargetNodeFields( + targetNode: ProtoBrushBehavior.TargetNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + onFieldEditComplete: () -> Unit, + onDropdownEditComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val limits = targetNode.target.getNumericLimits() + var expandedTarget by remember { mutableStateOf(false) } + var showTargetTooltip by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + ExposedDropdownMenuBox( + expanded = expandedTarget, + onExpandedChange = { expandedTarget = it }, + modifier = Modifier.weight(1f) + ) { + OutlinedTextField( + value = stringResource(targetNode.target.displayStringRId()), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.bg_target)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedTarget) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expandedTarget, + onDismissRequest = { expandedTarget = false } + ) { + @Composable + fun TargetSection( + label: String, + targets: List, + modifier: Modifier = Modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp) + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = 8.dp) + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + targets.forEach { target -> + DropdownMenuItem( + text = { Text(stringResource(target.displayStringRId())) }, + onClick = { + val currentDisplayStart = + if (targetNode.target.isAngle()) Math.toDegrees(targetNode.targetModifierRangeStart.toDouble()) + .toFloat() else targetNode.targetModifierRangeStart + val currentDisplayEnd = + if (targetNode.target.isAngle()) Math.toDegrees(targetNode.targetModifierRangeEnd.toDouble()) + .toFloat() else targetNode.targetModifierRangeEnd + + val newLimits = target.getNumericLimits() + val clampedDisplayStart = + currentDisplayStart.coerceIn(newLimits.min, newLimits.max) + val clampedDisplayEnd = + currentDisplayEnd.coerceIn(newLimits.min, newLimits.max) + + val newProtoStart = + if (target.isAngle()) Math.toRadians(clampedDisplayStart.toDouble()) + .toFloat() else clampedDisplayStart + val newProtoEnd = + if (target.isAngle()) Math.toRadians(clampedDisplayEnd.toDouble()) + .toFloat() else clampedDisplayEnd + + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setTargetNode( + targetNode.toBuilder() + .setTarget(target) + .setTargetModifierRangeStart(newProtoStart) + .setTargetModifierRangeEnd(newProtoEnd) + .build() + ) + .build() + ) + ) + onDropdownEditComplete() + expandedTarget = false + } + ) + } + } + + TargetSection( + stringResource(R.string.bg_section_size_shape), + TARGETS_SIZE_SHAPE + ) + TargetSection(stringResource(R.string.bg_section_position), TARGETS_POSITION) + TargetSection( + stringResource(R.string.bg_section_color_opacity), + TARGETS_COLOR_OPACITY + ) + } + } + IconButton(onClick = { showTargetTooltip = true }) { + Icon( + Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(R.string.bg_cd_help) + ) + } + } + if (showTargetTooltip) { + TooltipDialog( + title = stringResource( + R.string.bg_title_target_format, + stringResource(targetNode.target.displayStringRId()) + ), + text = stringResource(targetNode.target.getTooltip()), + onDismiss = { showTargetTooltip = false } + ) + } + NumericField( + title = stringResource(R.string.bg_label_range_start), + value = targetNode.targetModifierRangeStart, + limits = limits, + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setTargetNode( + targetNode.toBuilder().setTargetModifierRangeStart(it).build() + ) + .build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_range_end), + value = targetNode.targetModifierRangeEnd, + limits = limits, + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setTargetNode( + targetNode.toBuilder().setTargetModifierRangeEnd(it).build() + ) + .build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TextureLayerNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TextureLayerNodeFields.kt new file mode 100644 index 00000000..b5f4dd46 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TextureLayerNodeFields.kt @@ -0,0 +1,368 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushdesigner.ui.NumericLimits +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import com.example.cahier.developer.brushgraph.ui.BlendModePreviewWidget +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.TextureLayerPreviewWidget +import com.example.cahier.developer.brushgraph.ui.TextureWrapPreviewWidget +import com.example.cahier.developer.brushgraph.ui.getTooltip +import ink.proto.BrushPaint as ProtoBrushPaint + +@Composable +fun TextureLayerNodeFields( + layer: ProtoBrushPaint.TextureLayer, + allTextureIds: Set, + onLoadTexture: () -> Unit, + onUpdate: (NodeData.TextureLayer) -> Unit, + strokeRenderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(bottom = 8.dp)) { + EnumDropdown( + label = stringResource(R.string.bg_texture_id), + currentValue = layer.clientTextureId, + values = allTextureIds.toList(), + modifier = Modifier.weight(1f), + displayName = { it }, + onSelected = { id -> + onUpdate( + NodeData.TextureLayer( + layer.toBuilder().setClientTextureId(id).build() + ) + ) + } + ) + IconButton(onClick = onLoadTexture, enabled = true) { + Icon( + Icons.Default.Upload, + contentDescription = stringResource(R.string.bg_cd_upload_texture) + ) + } + } + + TextureLayerPreviewWidget(textureLayer = layer, renderer = strokeRenderer) + + InspectorSectionHeader( + stringResource(R.string.bg_section_mapping), + stringResource(R.string.bg_section_mapping_sub) + ) + + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_label_mapping_mode_with_value, + stringResource(layer.mapping.displayStringRId()) + ), + tooltipText = stringResource(layer.mapping.getTooltip()), + modifier = Modifier.padding(vertical = 4.dp) + ) { + EnumDropdown( + label = stringResource(R.string.bg_mapping_mode), + currentValue = layer.mapping, + values = listOf( + ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING, + ProtoBrushPaint.TextureLayer.Mapping.MAPPING_STAMPING, + ), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { mapping -> + onUpdate(NodeData.TextureLayer(layer.toBuilder().setMapping(mapping).build())) + } + ) + } + + if (layer.mapping == ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) { + NumericField( + title = stringResource(R.string.bg_label_size_x), + value = layer.sizeX, + limits = NumericLimits(0.1f, 1000f, 0.1f), + onValueChanged = { + onUpdate( + NodeData.TextureLayer( + layer.toBuilder().setSizeX(it).build() + ) + ) + } + ) + NumericField( + title = stringResource(R.string.bg_label_size_y), + value = layer.sizeY, + limits = NumericLimits(0.1f, 1000f, 0.1f), + onValueChanged = { + onUpdate( + NodeData.TextureLayer( + layer.toBuilder().setSizeY(it).build() + ) + ) + } + ) + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_label_size_unit_with_value, + stringResource(layer.sizeUnit.displayStringRId()) + ), + tooltipText = stringResource(layer.sizeUnit.getTooltip()), + modifier = Modifier.padding(vertical = 4.dp) + ) { + EnumDropdown( + label = stringResource(R.string.bg_size_unit), + currentValue = layer.sizeUnit, + values = listOf( + ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE, + ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_STROKE_COORDINATES, + ), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { unit -> + onUpdate(NodeData.TextureLayer(layer.toBuilder().setSizeUnit(unit).build())) + } + ) + } + } + + InspectorSectionHeader( + stringResource(R.string.bg_section_positioning), + stringResource(R.string.bg_section_positioning_sub) + ) + + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_label_origin_with_value, + stringResource(layer.origin.displayStringRId()) + ), + tooltipText = stringResource(layer.origin.getTooltip()), + modifier = Modifier.padding(vertical = 4.dp) + ) { + EnumDropdown( + label = stringResource(R.string.bg_origin), + currentValue = layer.origin, + values = listOf( + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_STROKE_SPACE_ORIGIN, + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_FIRST_STROKE_INPUT, + ProtoBrushPaint.TextureLayer.Origin.ORIGIN_LAST_STROKE_INPUT, + ), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { origin -> + onUpdate(NodeData.TextureLayer(layer.toBuilder().setOrigin(origin).build())) + } + ) + } + NumericField( + title = stringResource(R.string.bg_label_offset_x), + value = layer.offsetX, + limits = NumericLimits(-1f, 1f, 0.01f), + onValueChanged = { + onUpdate( + NodeData.TextureLayer( + layer.toBuilder().setOffsetX(it).build() + ) + ) + } + ) + NumericField( + title = stringResource(R.string.bg_label_offset_y), + value = layer.offsetY, + limits = NumericLimits(-1f, 1f, 0.01f), + onValueChanged = { + onUpdate( + NodeData.TextureLayer( + layer.toBuilder().setOffsetY(it).build() + ) + ) + } + ) + NumericField( + title = stringResource(R.string.bg_label_rotation_degrees), + value = layer.rotationInRadians, + limits = NumericLimits.radiansShownAsDegrees(0f, 360f), + onValueChanged = { + onUpdate( + NodeData.TextureLayer( + layer.toBuilder().setRotationInRadians(it).build() + ) + ) + } + ) + + InspectorSectionHeader( + stringResource(R.string.bg_section_wrapping), + stringResource(R.string.bg_section_wrapping_sub) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_label_wrap_x_with_value, + stringResource(layer.wrapX.displayStringRId()) + ), + tooltipText = stringResource(layer.wrapX.getTooltip()), + modifier = Modifier.padding(vertical = 4.dp) + ) { + EnumDropdown( + label = stringResource(R.string.bg_wrap_x), + currentValue = layer.wrapX, + values = listOf( + ProtoBrushPaint.TextureLayer.Wrap.WRAP_REPEAT, + ProtoBrushPaint.TextureLayer.Wrap.WRAP_MIRROR, + ProtoBrushPaint.TextureLayer.Wrap.WRAP_CLAMP, + ), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { wrap -> + onUpdate( + NodeData.TextureLayer( + layer.toBuilder().setWrapX(wrap).build() + ) + ) + } + ) + } + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_label_wrap_y_with_value, + stringResource(layer.wrapY.displayStringRId()) + ), + tooltipText = stringResource(layer.wrapY.getTooltip()), + modifier = Modifier.padding(vertical = 4.dp) + ) { + EnumDropdown( + label = stringResource(R.string.bg_wrap_y), + currentValue = layer.wrapY, + values = listOf( + ProtoBrushPaint.TextureLayer.Wrap.WRAP_REPEAT, + ProtoBrushPaint.TextureLayer.Wrap.WRAP_MIRROR, + ProtoBrushPaint.TextureLayer.Wrap.WRAP_CLAMP, + ), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { wrap -> + onUpdate( + NodeData.TextureLayer( + layer.toBuilder().setWrapY(wrap).build() + ) + ) + } + ) + } + } + Box(modifier = Modifier.padding(start = 8.dp)) { + TextureWrapPreviewWidget( + wrapX = layer.wrapX, + wrapY = layer.wrapY, + renderer = strokeRenderer, + clientTextureId = layer.clientTextureId + ) + } + } + + InspectorSectionHeader( + stringResource(R.string.bg_section_blending), + stringResource(R.string.bg_section_blending_sub) + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.weight(1f)) { + FieldWithTooltip( + tooltipTitle = stringResource( + R.string.bg_label_blend_mode_with_value, + stringResource(layer.blendMode.displayStringRId()) + ), + tooltipText = stringResource(layer.blendMode.getTooltip()), + modifier = Modifier.padding(vertical = 4.dp) + ) { + EnumDropdown( + label = stringResource(R.string.bg_blend_mode), + currentValue = layer.blendMode, + values = listOf( + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_MODULATE, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OVER, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_IN, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_IN, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OUT, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OUT, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_ATOP, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_ATOP, + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_XOR, + ), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { mode -> + onUpdate( + NodeData.TextureLayer( + layer.toBuilder().setBlendMode(mode).build() + ) + ) + } + ) + } + } + Box(modifier = Modifier.padding(start = 8.dp)) { + BlendModePreviewWidget( + blendMode = layer.blendMode, + renderer = strokeRenderer, + clientTextureId = layer.clientTextureId + ) + } + } + + } +} + +@Composable +private fun InspectorSectionHeader(title: String, subtitle: String, modifier: Modifier = Modifier) { + Column(modifier = modifier.padding(top = 16.dp, bottom = 4.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary + ) + HorizontalDivider( + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TipNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TipNodeFields.kt new file mode 100644 index 00000000..d36a9175 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TipNodeFields.kt @@ -0,0 +1,154 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.ui.NumericField +import com.example.cahier.developer.brushdesigner.ui.NumericLimits +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.ui.TipPreviewWidget + +@Composable +fun TipNodeFields( + data: NodeData.Tip, + onUpdate: (NodeData) -> Unit, + onFieldEditComplete: () -> Unit, + strokeRenderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + val tip = data.tip + Column(modifier = modifier) { + TipPreviewWidget(brushTip = tip, renderer = strokeRenderer) + + NumericField( + title = stringResource(R.string.bg_label_scale_x), + value = tip.scaleX, + limits = NumericLimits(0f, 2f, 0.01f), + onValueChanged = { + onUpdate( + NodeData.Tip( + tip.toBuilder().setScaleX(it).build(), + behaviorPortIds = data.behaviorPortIds + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_scale_y), + value = tip.scaleY, + limits = NumericLimits(0f, 2f, 0.01f), + onValueChanged = { + onUpdate( + NodeData.Tip( + tip.toBuilder().setScaleY(it).build(), + behaviorPortIds = data.behaviorPortIds + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_corner_rounding), + value = tip.cornerRounding, + limits = NumericLimits(0f, 1f, 0.01f), + onValueChanged = { + onUpdate( + NodeData.Tip( + tip.toBuilder().setCornerRounding(it).build(), + behaviorPortIds = data.behaviorPortIds + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_slant_degrees), + value = tip.slantRadians, + limits = NumericLimits.radiansShownAsDegrees(-90f, 90f), + onValueChanged = { + onUpdate( + NodeData.Tip( + tip.toBuilder().setSlantRadians(it).build(), + behaviorPortIds = data.behaviorPortIds + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_pinch), + value = tip.pinch, + limits = NumericLimits(0f, 1f, 0.01f), + onValueChanged = { + onUpdate( + NodeData.Tip( + tip.toBuilder().setPinch(it).build(), + behaviorPortIds = data.behaviorPortIds + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_rotation_degrees), + value = tip.rotationRadians, + limits = NumericLimits.radiansShownAsDegrees(0f, 360f), + onValueChanged = { + onUpdate( + NodeData.Tip( + tip.toBuilder().setRotationRadians(it).build(), + behaviorPortIds = data.behaviorPortIds + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_particle_gap_distance_scale), + value = tip.particleGapDistanceScale, + limits = NumericLimits(0f, 5f, 0.01f), + onValueChanged = { + onUpdate( + NodeData.Tip( + tip.toBuilder().setParticleGapDistanceScale(it).build(), + behaviorPortIds = data.behaviorPortIds + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_particle_gap_duration_ms), + value = tip.particleGapDurationSeconds, + limits = NumericLimits(0f, 1000f, 1f, "ms", unitScale = 1000f), + onValueChanged = { + onUpdate( + NodeData.Tip( + tip.toBuilder().setParticleGapDurationSeconds(it).build(), + behaviorPortIds = data.behaviorPortIds + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ToolTypeFilterNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ToolTypeFilterNodeFields.kt new file mode 100644 index 00000000..6199236c --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ToolTypeFilterNodeFields.kt @@ -0,0 +1,82 @@ +/* + * 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.fields + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.ink.brush.InputToolType +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun ToolTypeFilterNodeFields( + filterNode: ProtoBrushBehavior.ToolTypeFilterNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + stringResource(R.string.bg_enabled_tool_types), + style = MaterialTheme.typography.bodySmall + ) + ALL_TOOL_TYPES.forEach { toolType -> + Row(verticalAlignment = Alignment.CenterVertically) { + val bitIndex = toolTypeBitIndex(toolType) + Checkbox( + checked = (filterNode.enabledToolTypes and (1 shl bitIndex)) != 0, + onCheckedChange = { checked -> + val newMask = + if (checked) { + filterNode.enabledToolTypes or (1 shl bitIndex) + } else { + filterNode.enabledToolTypes and (1 shl bitIndex).inv() + } + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setToolTypeFilterNode( + filterNode.toBuilder().setEnabledToolTypes(newMask).build() + ) + .build() + ) + ) + } + ) + Text(stringResource(toolType.displayStringRId())) + } + } + } +} + +private fun toolTypeBitIndex(toolType: InputToolType): Int = + when (toolType) { + InputToolType.UNKNOWN -> 0 + InputToolType.MOUSE -> 1 + InputToolType.TOUCH -> 2 + InputToolType.STYLUS -> 3 + else -> 0 + } diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeHeader.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeHeader.kt new file mode 100644 index 00000000..89f46647 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeHeader.kt @@ -0,0 +1,140 @@ +/* + * 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.node + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +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.ink.rendering.android.canvas.CanvasStrokeRenderer +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.ui.CoatPreviewWidget +import com.example.cahier.developer.brushgraph.ui.ColorFunctionPreviewWidget +import com.example.cahier.developer.brushgraph.ui.TextureLayerPreviewWidget +import com.example.cahier.developer.brushgraph.ui.TipPreviewWidget +import com.example.cahier.developer.brushgraph.ui.asString +import com.example.cahier.developer.brushgraph.ui.titleHeight +import ink.proto.BrushCoat as ProtoBrushCoat + +@Composable +fun NodeHeader( + node: GraphNode, + graph: BrushGraph, + strokeRenderer: CanvasStrokeRenderer, + textColor: Color, + modifier: Modifier = Modifier, +) { + val data = node.data + Column(modifier = modifier) { + Row( + modifier = Modifier + .height(with(LocalDensity.current) { data.titleHeight().toDp() }) + .padding(horizontal = 8.dp, vertical = 4.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text( + text = stringResource(data.title()), + color = textColor, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + for (subtitle in data.subtitles()) { + val subtitleText = subtitle.asString() + Text( + text = subtitleText, + style = MaterialTheme.typography.bodySmall, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + // Previews for Tip and Coat nodes. + if (data is NodeData.Tip) { + Box( + modifier = Modifier + .size(60.dp) + .padding(4.dp) + ) { + TipPreviewWidget(data.tip, strokeRenderer) + } + } else if (data is NodeData.Coat) { + val coat = remember(node, graph) { + try { + com.example.cahier.developer.brushgraph.data.BrushFamilyConverter.createCoat( + node, + graph + ) + } catch (e: Exception) { + ProtoBrushCoat.getDefaultInstance() + } + } + + Box( + modifier = Modifier + .size(60.dp) + .padding(4.dp) + ) { + CoatPreviewWidget(coat, strokeRenderer) + } + } + } + + if (data is NodeData.ColorFunction) { + Box( + modifier = Modifier + .size(60.dp) + .padding(4.dp) + ) { + ColorFunctionPreviewWidget(data.function, strokeRenderer) + } + } + if (data is NodeData.TextureLayer) { + Box( + modifier = Modifier + .size(60.dp) + .padding(4.dp) + ) { + TextureLayerPreviewWidget(data.layer, strokeRenderer) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodePortDots.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodePortDots.kt new file mode 100644 index 00000000..53c8df0d --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodePortDots.kt @@ -0,0 +1,166 @@ +/* + * 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.node + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf +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.layout.LayoutCoordinates +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 com.example.cahier.developer.brushgraph.data.PortSide +import com.example.cahier.developer.brushgraph.data.isPortReorderable +import com.example.cahier.developer.brushgraph.ui.INPUT_ROW_HEIGHT +import com.example.cahier.developer.brushgraph.ui.NODE_PADDING_VERTICAL +import com.example.cahier.developer.brushgraph.ui.titleHeight +import kotlin.math.roundToInt + +@Composable +fun BoxScope.NodePortDots( + node: GraphNode, + position: Offset, + graph: BrushGraph, + visiblePorts: List, + zoom: Float, + canvasCoordinates: LayoutCoordinates?, + onPortDrag: (PortSide, String, Boolean) -> Unit, + onPortDragUpdate: (Offset) -> Unit, + onPortDragEnd: () -> Unit, + getPortPosition: (String, Boolean) -> Offset, + onPortPositioned: (String, Offset) -> Unit, + onReorderPorts: (String, Int, Int) -> Unit, + modifier: Modifier = Modifier, +) { + var activeReorderPortIndex by remember { mutableStateOf(null) } + var cumulativeDeltaY by remember { mutableFloatStateOf(0f) } + val hasAddPort = visiblePorts.any { it.isAddPort } + + val portCounts = remember(graph.edges, graph.nodes, node.id) { + if (node.data is NodeData.Paint) { + val nodesById = graph.nodes.associateBy { it.id } + val textureCount = graph.edges.count { edge -> + edge.toNodeId == node.id && nodesById[edge.fromNodeId]?.data is NodeData.TextureLayer + } + val colorCount = graph.edges.count { edge -> + edge.toNodeId == node.id && nodesById[edge.fromNodeId]?.data is NodeData.ColorFunction + } + Pair(textureCount, colorCount) + } else { + Pair(0, 0) + } + } + val T = portCounts.first + val C = portCounts.second + + for ((index, port) in visiblePorts.withIndex()) { + val edge = graph.edges.find { it.toNodeId == node.id && it.toPortId == port.id } + val portKey = edge?.let { "${it.fromNodeId}_${port.id}" } ?: "port_${port.id}" + key(portKey) { + PortDot( + modifier = modifier.align(Alignment.TopStart), + port = port, + zoom = zoom, + onDrag = onPortDrag, + onDragUpdate = onPortDragUpdate, + onDragEnd = onPortDragEnd, + onPortPositioned = { pos -> onPortPositioned(port.id, pos) }, + canvasCoordinates = canvasCoordinates, + portPosition = getPortPosition(port.id, true) - position, + isReorderable = node.data.isPortReorderable(port, index, hasAddPort), + isLargeHandle = node.data is NodeData.Behavior && node.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE && index % 2 == 0, + onReorderUpdate = { deltaY -> + if (activeReorderPortIndex != index) { + activeReorderPortIndex = index + cumulativeDeltaY = 0f + } + cumulativeDeltaY += deltaY + + val originalY = getPortPosition(port.id, true).y - position.y + var maxValidIndex = visiblePorts.size - 2 // Exclude add port + var minValidIndex = if (node.data is NodeData.Coat) 1 else 0 + + if (node.data is NodeData.Paint) { + if (index in 0 until T) { + minValidIndex = 0 + maxValidIndex = T - 1 + } else if (index in (T + 1) until (T + 1 + C)) { + minValidIndex = T + 1 + maxValidIndex = T + 1 + C - 1 + } + } + + val minDragY = + NODE_PADDING_VERTICAL + node.data.titleHeight() + (0 + 0.5f) * INPUT_ROW_HEIGHT + val maxDragY = + NODE_PADDING_VERTICAL + node.data.titleHeight() + (visiblePorts.size - 1 + 0.5f) * INPUT_ROW_HEIGHT + + val requestedY = originalY + cumulativeDeltaY + + val isPolarTarget = + node.data is NodeData.Behavior && node.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE + + val targetIndex = if (isPolarTarget) { + val setSize = 2 + val currentSet = + ((requestedY - NODE_PADDING_VERTICAL - node.data.titleHeight()) / (INPUT_ROW_HEIGHT * setSize) - 0.5f).roundToInt() + currentSet * setSize + } else { + ((requestedY - NODE_PADDING_VERTICAL - node.data.titleHeight()) / INPUT_ROW_HEIGHT - 0.5f).roundToInt() + } + + val currentY = requestedY.coerceIn(minDragY, maxDragY) + cumulativeDeltaY = currentY - originalY + + if (targetIndex in minValidIndex..maxValidIndex && targetIndex != index) { + onReorderPorts(node.id, index, targetIndex) + cumulativeDeltaY -= (targetIndex - index) * INPUT_ROW_HEIGHT + activeReorderPortIndex = targetIndex + } + }, + onReorderEnd = { + activeReorderPortIndex = null + cumulativeDeltaY = 0f + }, + isDragging = index == activeReorderPortIndex, + dragOffset = if (index == activeReorderPortIndex) cumulativeDeltaY else 0f + ) + } + } + if (node.data.hasOutput()) { + PortDot( + modifier = modifier.align(Alignment.TopEnd), + port = Port.Output(node.id, "output"), + zoom = zoom, + onDrag = onPortDrag, + onDragUpdate = onPortDragUpdate, + onDragEnd = onPortDragEnd, + onPortPositioned = { pos -> onPortPositioned("output", pos) }, + canvasCoordinates = canvasCoordinates, + portPosition = getPortPosition("output", true) - position, + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodePortLabels.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodePortLabels.kt new file mode 100644 index 00000000..e0dda3ab --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodePortLabels.kt @@ -0,0 +1,144 @@ +/* + * 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.node + +import androidx.compose.foundation.background +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.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.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.Port +import com.example.cahier.developer.brushgraph.ui.INPUT_ROW_HEIGHT +import com.example.cahier.developer.brushgraph.ui.asString + +@Composable +fun NodePortLabels( + node: GraphNode, + graph: BrushGraph, + visiblePorts: List, + isSelectionMode: Boolean, + onPortClick: (String, Port) -> Unit, + textColor: Color, + addButtonColor: Color, + addButtonTextColor: Color, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val occupiedPortIds = remember(node.id, graph.edges) { + graph.edges.filter { it.toNodeId == node.id }.map { it.toPortId }.toSet() + } + Box(modifier = modifier.fillMaxWidth()) { + Column { + for ((index, port) in visiblePorts.withIndex()) { + with(density) { + val isPortEmpty = port.id !in occupiedPortIds + Box( + modifier = + Modifier.height(INPUT_ROW_HEIGHT.toDp()) + .fillMaxWidth() + .padding( + start = 8.dp, + end = if (index == 0 && node.data.hasOutput()) 48.dp else 8.dp + ) + .let { + if (isPortEmpty) { + it + .clickable(enabled = !isSelectionMode) { + onPortClick( + node.id, + port + ) + } + .background( + addButtonColor, RoundedCornerShape(4.dp) + ) + } else { + it + } + }, + contentAlignment = Alignment.CenterStart, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp) + ) { + if (isPortEmpty) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.bg_cd_add), + tint = addButtonTextColor, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = port.label?.asString() ?: "", + style = MaterialTheme.typography.labelSmall, + color = if (isPortEmpty) addButtonTextColor else textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + // Output label on the right, aligned with the first input row. + if (node.data.hasOutput()) { + with(density) { + Box( + modifier = + Modifier + .height(INPUT_ROW_HEIGHT.toDp()) + .align(Alignment.TopEnd) + .padding(horizontal = 4.dp) + ) { + Text( + text = stringResource(R.string.bg_label_out), + style = MaterialTheme.typography.labelSmall, + color = textColor, + modifier = Modifier.align(Alignment.CenterEnd), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeRegistry.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeRegistry.kt new file mode 100644 index 00000000..cabff685 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeRegistry.kt @@ -0,0 +1,132 @@ +/* + * 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.node + +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.geometry.Offset +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.Port +import com.example.cahier.developer.brushgraph.data.PortSide +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import com.example.cahier.developer.brushgraph.ui.INPUT_ROW_HEIGHT +import com.example.cahier.developer.brushgraph.ui.NODE_PADDING_VERTICAL +import com.example.cahier.developer.brushgraph.ui.titleHeight +import com.example.cahier.developer.brushgraph.ui.width + +/** Registry to track the actual position of ports and nodes on the screen. */ +data class PortKey(val nodeId: String, val portId: String) + +class NodeRegistry { + private val portPositions = mutableStateMapOf() + private val nodePositions = mutableStateMapOf() + + fun updatePort(nodeId: String, portId: String, position: Offset) { + portPositions[PortKey(nodeId, portId)] = position + } + + fun updateNodePosition(nodeId: String, position: Offset) { + nodePositions[nodeId] = position + } + + fun getNodePosition(nodeId: String): Offset? { + return nodePositions[nodeId] + } + + fun getPortPosition( + nodeId: String, + portId: String, + graph: BrushGraph, + useFallbackOnly: Boolean = false, + ): Offset { + val stored = portPositions[PortKey(nodeId, portId)] + if (stored != null && !useFallbackOnly) return stored + + val node = graph.nodes.find { it.id == nodeId } ?: return Offset.Zero + + // Special handling for output port which is not in visiblePorts + val nodePos = nodePositions[nodeId] ?: Offset.Zero + if (portId == "output") { + val w = node.data.width() + val yOffset = NODE_PADDING_VERTICAL + node.data.titleHeight() + 0.5f * INPUT_ROW_HEIGHT + return Offset(nodePos.x + w, nodePos.y + yOffset) + } + + val visiblePorts = node.getVisiblePorts(graph) + val port = visiblePorts.find { it.id == portId } ?: return Offset.Zero + val sameSidePorts = visiblePorts.filter { it.side == port.side } + val index = sameSidePorts.indexOf(port) + + val w = node.data.width() + val yOffset = NODE_PADDING_VERTICAL + + node.data.titleHeight() + + (index + 0.5f) * INPUT_ROW_HEIGHT + + val relativeOffset = when (port.side) { + PortSide.INPUT -> Offset(0f, yOffset) + PortSide.OUTPUT -> Offset(w, yOffset) + } + + val nodeOffset = nodePos + return nodeOffset + relativeOffset + } + + fun findNearestPort(pos: Offset, fromNodeId: String, graph: BrushGraph): Port? { + val thresholdSq = 3000f + var nearestPort: Port? = null + var minDistanceSq = thresholdSq + + for (node in graph.nodes) { + val visiblePorts = node.getVisiblePorts(graph) + visiblePorts.forEachIndexed { index, port -> + // Only snap to input ports. + if (port.side == PortSide.INPUT) { + // Ignore occupied ports (unless it's the same edge being edited). + val existingEdge = + graph.edges.find { it.toNodeId == port.nodeId && it.toPortId == port.id } + if (existingEdge != null && existingEdge.fromNodeId != fromNodeId) { + return@forEachIndexed // Occupied by another node's edge! + } + + val portPos = getPortPosition(port.nodeId, port.id, graph) + + val distSq = (pos - portPos).getDistanceSquared() + + if (distSq < minDistanceSq) { + minDistanceSq = distSq + nearestPort = port + } + } + } + } + return nearestPort + } + + fun deletePort(nodeId: String, portId: String) { + portPositions.remove(PortKey(nodeId, portId)) + } + + fun clearNode(nodeId: String) { + val keysToRemove = portPositions.keys.filter { it.nodeId == nodeId } + keysToRemove.forEach { portPositions.remove(it) } + nodePositions.remove(nodeId) + } + + fun clear() { + portPositions.clear() + nodePositions.clear() + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeWidget.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeWidget.kt new file mode 100644 index 00000000..f8cf9fb7 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeWidget.kt @@ -0,0 +1,360 @@ +/* + * 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.node + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.platform.LocalDensity +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 com.example.cahier.core.ui.theme.extendedColorScheme +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 com.example.cahier.developer.brushgraph.data.PortSide +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import com.example.cahier.developer.brushgraph.ui.NODE_PADDING_BOTTOM +import com.example.cahier.developer.brushgraph.ui.NODE_PADDING_VERTICAL +import com.example.cahier.developer.brushgraph.ui.NODE_WIDTH +import com.example.cahier.developer.brushgraph.ui.SineWavePreview +import com.example.cahier.developer.brushgraph.ui.height +import com.example.cahier.developer.brushgraph.ui.width +import kotlin.math.roundToInt + +@Composable +fun NodeWidget( + node: GraphNode, + position: Offset, + graph: BrushGraph, + isActiveSource: Boolean, + zoom: Float, strokeRenderer: CanvasStrokeRenderer, + brush: Brush, + onMove: (Offset) -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + canvasCoordinates: LayoutCoordinates? = null, + isSelected: Boolean = false, + isSelectionMode: Boolean = false, + isInSelectedSet: Boolean = false, + onDragStart: () -> Unit = {}, + onDrag: (PointerInputChange) -> Unit = {}, + onDragEnd: () -> Unit = {}, + onPortDrag: (PortSide, String, Boolean) -> Unit = { _, _, _ -> }, + onPortDragUpdate: (Offset) -> Unit = {}, + onPortDragEnd: () -> Unit = {}, + onReorderPorts: (String, Int, Int) -> Unit = { _, _, _ -> }, + onPortClick: (String, Port) -> Unit = { _, _ -> }, + getPortPosition: (String, Boolean) -> Offset, + onPortPositioned: (String, Offset) -> Unit, + onClearNodeCache: () -> Unit, + onLongPress: () -> Unit = {}, +) { + var isPressed by remember { mutableStateOf(false) } + val density = LocalDensity.current + val visiblePorts = remember(node.data, graph) { node.getVisiblePorts(graph) } + + androidx.compose.runtime.DisposableEffect(node.id) { + onDispose { + onClearNodeCache() + } + } + + val currentOnMove by androidx.compose.runtime.rememberUpdatedState(onMove) + val currentOnDragStart by androidx.compose.runtime.rememberUpdatedState(onDragStart) + val currentOnDragEnd by androidx.compose.runtime.rememberUpdatedState(onDragEnd) + val currentOnDragUpdate by androidx.compose.runtime.rememberUpdatedState(onDrag) + + val w = node.data.width() + val h = node.data.height(visiblePorts.size) + + data class NodeColorsByNodeData( + val backgroundColor: Color, + val textColor: Color, + val addButtonColor: Color, + val addButtonTextColor: Color, + ) + + val (backgroundColorByNodeData, textColorByNodeData, addButtonColorByNodeData, addButtonTextColorByNodeData) = + when (node.data) { + is NodeData.Coat, + is NodeData.Tip, + is NodeData.Paint, + -> NodeColorsByNodeData( + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer, + MaterialTheme.colorScheme.surface, + MaterialTheme.colorScheme.onSurface, + ) + + is NodeData.Family -> NodeColorsByNodeData( + MaterialTheme.colorScheme.tertiaryContainer, + MaterialTheme.colorScheme.onTertiaryContainer, + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.onSurfaceVariant, + ) + + is NodeData.TextureLayer, + is NodeData.ColorFunction, + -> NodeColorsByNodeData( + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.onSurfaceVariant, + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer + ) + + else -> NodeColorsByNodeData( + MaterialTheme.colorScheme.surfaceDim, + MaterialTheme.colorScheme.onSurface, + MaterialTheme.colorScheme.tertiaryContainer, + MaterialTheme.colorScheme.onTertiaryContainer + ) + } + + data class NodeColors( + val backgroundColor: Color, + val outlineWeight: Dp, + val outlineColor: Color, + val textColor: Color, + val addButtonColor: Color, + val addButtonTextColor: Color, + ) + val (backgroundColor, outlineWeight, outlineColor, textColor, addButtonColor, addButtonTextColor) = when { + node.isDisabled -> + NodeColors( + MaterialTheme.colorScheme.surfaceDim, + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), + MaterialTheme.colorScheme.onSurface, + addButtonColorByNodeData, + addButtonTextColorByNodeData + ) + + isActiveSource || isPressed || isSelected || isInSelectedSet -> + NodeColors( + MaterialTheme.colorScheme.primaryContainer, + 2.dp, + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.onPrimaryContainer, + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.onPrimary, + ) + + node.hasError -> + NodeColors( + MaterialTheme.colorScheme.errorContainer, + 2.dp, + MaterialTheme.colorScheme.error, + MaterialTheme.colorScheme.onErrorContainer, + MaterialTheme.colorScheme.error, + MaterialTheme.colorScheme.onError, + ) + + node.hasWarning -> + NodeColors( + MaterialTheme.extendedColorScheme.warningContainer, + 2.dp, + MaterialTheme.extendedColorScheme.warning, + MaterialTheme.extendedColorScheme.onWarningContainer, + MaterialTheme.extendedColorScheme.warning, + MaterialTheme.extendedColorScheme.onWarning, + ) + + else -> + NodeColors( + backgroundColorByNodeData, + 1.dp, + MaterialTheme.colorScheme.outline, + textColorByNodeData, + addButtonColorByNodeData, + addButtonTextColorByNodeData + ) + } + Box( + modifier = + modifier + .zIndex(if (isSelected) 1f else 0f) + .offset { IntOffset(position.x.roundToInt(), position.y.roundToInt()) } + .pointerInput(node.id, isSelected, isSelectionMode) { + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false + } + }, + onTap = { onClick() }, + onLongPress = { onLongPress() } + ) + } + .pointerInput(node.id, isSelected) { + detectDragGestures( + onDragStart = { currentOnDragStart() }, + onDragEnd = { currentOnDragEnd() }, + onDragCancel = { currentOnDragEnd() }, + onDrag = { change, dragAmount -> + change.consume() + currentOnMove(dragAmount) + currentOnDragUpdate(change) + }, + ) + } + .then( + with(density) { + Modifier + .width(w.toDp()) + .height(h.toDp()) + .background( + backgroundColor, + RoundedCornerShape(8.dp), + ) + .border( + outlineWeight, + outlineColor, + RoundedCornerShape(8.dp), + ) + } + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .alpha(if (node.isDisabled) 0.38f else 1f) + ) { + Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.Top) { + val nodeWidthDp = with(density) { NODE_WIDTH.toDp() } + val topPaddingDp = with(density) { NODE_PADDING_VERTICAL.toDp() } + val bottomPaddingDp = with(density) { NODE_PADDING_BOTTOM.toDp() } + Box( + modifier = Modifier + .width(nodeWidthDp) + .fillMaxHeight() + .padding(bottom = bottomPaddingDp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = topPaddingDp) + ) { + NodeHeader( + node = node, + graph = graph, + strokeRenderer = strokeRenderer, + textColor = textColor, + ) + + NodePortLabels( + node = node, + graph = graph, + visiblePorts = visiblePorts, + isSelectionMode = isSelectionMode, + onPortClick = onPortClick, + textColor = textColor, + addButtonColor = addButtonColor, + addButtonTextColor = addButtonTextColor + ) + } + + NodePortDots( + node = node, + position = position, + graph = graph, + visiblePorts = visiblePorts, + zoom = zoom, + onPortDrag = onPortDrag, + onPortDragUpdate = onPortDragUpdate, + onPortDragEnd = onPortDragEnd, + getPortPosition = getPortPosition, + onPortPositioned = onPortPositioned, + canvasCoordinates = canvasCoordinates, + onReorderPorts = onReorderPorts + ) + } + + if (node.data is NodeData.Family) { + // Division Line + Box( + modifier = Modifier + .fillMaxHeight() + .width(2.dp) + .background(outlineColor) + ) + + SineWavePreview( + brush = brush, + strokeRenderer = strokeRenderer, + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surface) + .clip(RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp)), + ) + } + } + + if (isSelectionMode && node.data !is NodeData.Family) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 6.dp, y = (-6).dp) + .size(16.dp) + .background( + if (isInSelectedSet) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface, + CircleShape + ) + .border(1.dp, MaterialTheme.colorScheme.primary, CircleShape) + ) + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/PortDot.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/PortDot.kt new file mode 100644 index 00000000..76187074 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/PortDot.kt @@ -0,0 +1,292 @@ +/* + * 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.node + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +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.geometry.Offset +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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.Port +import com.example.cahier.developer.brushgraph.data.PortSide +import com.example.cahier.developer.brushgraph.ui.INPUT_ROW_HEIGHT + +@Composable +fun PortDot( + port: Port, + zoom: Float, + canvasCoordinates: LayoutCoordinates?, + portPosition: Offset, + onDrag: (PortSide, String, Boolean) -> Unit, + onDragUpdate: (Offset) -> Unit, + onDragEnd: () -> Unit, + onPortPositioned: (Offset) -> Unit, + modifier: Modifier = Modifier, + isDragging: Boolean = false, + dragOffset: Float = 0f, + isLargeHandle: Boolean = false, + isReorderable: Boolean = false, + onReorderUpdate: (Float) -> Unit = {}, + onReorderEnd: () -> Unit = {}, +) { + var portCoordinates by remember { + mutableStateOf( + null + ) + } + val density = LocalDensity.current + + val currentOnDrag by androidx.compose.runtime.rememberUpdatedState(onDrag) + val currentOnDragUpdate by androidx.compose.runtime.rememberUpdatedState(onDragUpdate) + val currentOnDragEnd by androidx.compose.runtime.rememberUpdatedState(onDragEnd) + val currentOnReorderUpdate by androidx.compose.runtime.rememberUpdatedState(onReorderUpdate) + val currentOnReorderEnd by androidx.compose.runtime.rememberUpdatedState(onReorderEnd) + val currentPortId by androidx.compose.runtime.rememberUpdatedState(port.id) + val currentPortSide by androidx.compose.runtime.rememberUpdatedState(port.side) + + val outerWidth = if (port.side == PortSide.INPUT) 24.dp else 12.dp + val outerHeight = if (port.side == PortSide.INPUT) 32.dp else 12.dp + val outerX = if (port.side == PortSide.INPUT) (-24).dp else 14.dp + + val animatedY by animateDpAsState( + targetValue = with(density) { portPosition.y.toDp() } - (if (port.side == PortSide.INPUT) 16.dp else 6.dp), + label = "portY" + ) + val finalY = + if (isDragging) with(density) { portPosition.y.toDp() } - (if (port.side == PortSide.INPUT) 16.dp else 6.dp) else animatedY + + Box( + modifier = + modifier + .offset { + IntOffset(outerX.roundToPx(), finalY.roundToPx()) + } + .size(width = outerWidth, height = outerHeight) + .graphicsLayer { + if (isDragging) { + translationY = dragOffset + } + } + .zIndex(if (isDragging) 1f else 0f) + ) { + if (port.side == PortSide.INPUT) { + // Input Port (Left Half) + Box( + modifier = Modifier + .align(Alignment.TopStart) + .size(width = 12.dp, height = 32.dp) + .pointerInput(port.nodeId, port.side, canvasCoordinates, zoom) { + detectPortDragGestures( + zoom = zoom, + onDragStart = { currentOnDrag(currentPortSide, currentPortId, true) }, + onDragEnd = { currentOnDragEnd() }, + onDragCancel = { currentOnDragEnd() }, + ) { change, _ -> + change.consume() + val canvasCo = canvasCoordinates + val portCo = portCoordinates + if (canvasCo != null && portCo != null && canvasCo.isAttached && portCo.isAttached) { + val graphSpacePos = + canvasCo.localPositionOf(portCo, change.position) + currentOnDragUpdate(graphSpacePos) + } + } + } + ) { + Box( + modifier = Modifier + .align(Alignment.Center) + .size(12.dp) + .background( + if (isDragging) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) + .onGloballyPositioned { coordinates: androidx.compose.ui.layout.LayoutCoordinates -> + portCoordinates = coordinates + val canvasCo = canvasCoordinates + if (canvasCo != null && coordinates.isAttached) { + val center = Offset( + coordinates.size.width / 2f, + coordinates.size.height / 2f + ) + val graphSpacePos = canvasCo.localPositionOf(coordinates, center) + onPortPositioned(graphSpacePos) + } + } + ) + } + + // Reorder Handle (Right Half) + if (isReorderable) { + val handleHeight = + if (isLargeHandle) with(density) { (INPUT_ROW_HEIGHT * 2).toDp() } else 32.dp + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .size(width = 12.dp, height = handleHeight) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + RoundedCornerShape(2.dp) + ) + .pointerInput(port.nodeId, port.side, zoom) { + detectPortDragGestures( + zoom = zoom, + onDrag = { change, dragAmount -> + change.consume() + currentOnReorderUpdate(dragAmount.y) + }, + onDragEnd = { currentOnReorderEnd() }, + onDragCancel = { currentOnReorderEnd() } + ) + } + ) { + Icon( + painter = painterResource(R.drawable.gs_drag_indicator_vd_theme_24), + contentDescription = stringResource(R.string.bg_cd_reorder), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.Center) + .size(12.dp) + ) + } + } + } else { + // Output Port + Box( + modifier = Modifier + .align(Alignment.Center) + .size(12.dp) + .background( + if (isDragging) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) + .onGloballyPositioned { coordinates: androidx.compose.ui.layout.LayoutCoordinates -> + portCoordinates = coordinates + val canvasCo = canvasCoordinates + if (canvasCo != null && coordinates.isAttached) { + val center = + Offset(coordinates.size.width / 2f, coordinates.size.height / 2f) + val graphSpacePos = canvasCo.localPositionOf(coordinates, center) + onPortPositioned(graphSpacePos) + } + } + .pointerInput(port.nodeId, port.side, canvasCoordinates, zoom) { + detectPortDragGestures( + zoom = zoom, + onDragStart = { currentOnDrag(currentPortSide, currentPortId, true) }, + onDragEnd = { currentOnDragEnd() }, + onDragCancel = { currentOnDragEnd() }, + ) { change, _ -> + change.consume() + val canvasCo = canvasCoordinates + val portCo = portCoordinates + if (canvasCo != null && portCo != null && canvasCo.isAttached && portCo.isAttached) { + val graphSpacePos = + canvasCo.localPositionOf(portCo, change.position) + currentOnDragUpdate(graphSpacePos) + } + } + } + ) + } + } +} + +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() + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt new file mode 100644 index 00000000..1e396f5b --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphUiState.kt @@ -0,0 +1,45 @@ +/* + * 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.viewmodel + +import androidx.compose.ui.graphics.Color +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.GraphPoint +import com.example.cahier.developer.brushgraph.data.GraphValidationException + +data class BrushGraphUiState( + val graph: BrushGraph = BrushGraph(), + val isSelectionMode: Boolean = false, + val selectedNodeIds: Set = emptySet(), + val activeEdgeSourceId: String? = null, + val selectedEdge: GraphEdge? = null, + val testAutoUpdateStrokes: Boolean = true, + val testBrushColor: Color? = null, + val testBrushSize: Float = 10f, + val isErrorPaneOpen: Boolean = false, + val zoom: Float = 1f, + val offset: GraphPoint = GraphPoint(0f, 0f), + val textFieldsLocked: Boolean = false, + val selectedNodeId: String? = null, + val focusTrigger: Int = 0, + val detachedEdge: GraphEdge? = null, + val isPreviewExpanded: Boolean = true, + val isDarkCanvas: Boolean = false, + val graphIssues: List = emptyList(), + val allTextureIds: Set = emptySet(), +) \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt new file mode 100644 index 00000000..563c32f0 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt @@ -0,0 +1,625 @@ +/* + * 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.viewmodel + +import android.graphics.Bitmap +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.graphics.Color +import androidx.ink.brush.Brush +import androidx.ink.brush.BrushFamily +import androidx.ink.brush.StockBrushes +import androidx.ink.brush.Version +import androidx.ink.brush.compose.composeColor +import androidx.ink.brush.compose.createWithComposeColor +import androidx.ink.storage.AndroidBrushFamilySerialization +import androidx.ink.storage.BrushFamilyDecodeCallback +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import com.example.cahier.developer.brushgraph.data.BrushGraphRepository +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.GraphPoint +import com.example.cahier.developer.brushgraph.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.TutorialAction +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.inject.Inject +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.BrushTip as ProtoBrushTip +import ink.proto.ColorFunction as ProtoColorFunction + +/** ViewModel to manage the state of the brush graph. */ +@HiltViewModel +class BrushGraphViewModel @Inject constructor( + private val customBrushDao: CustomBrushDao, + private val textureStore: CahierTextureBitmapStore, + private val repository: BrushGraphRepository, +) : ViewModel() { + + /** Saved brushes in the palette. */ + val savedPaletteBrushes: StateFlow> = + customBrushDao.getAllCustomBrushes() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + private val _uiState = MutableStateFlow(BrushGraphUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val brush: StateFlow = uiState + .map { Triple(it.graph, it.testBrushColor, it.testBrushSize) } + .distinctUntilChanged() + .map { (graph, testBrushColor, testBrushSize) -> + val family = repository.getBrushFamily() + val color = testBrushColor ?: Color.Black + val size = testBrushSize + if (family != null) { + Brush.createWithComposeColor(family, color, size, 0.1f) + } else { + Brush.createWithComposeColor(StockBrushes.marker(), color, size, 0.1f) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = Brush.createWithComposeColor( + StockBrushes.marker(), + Color.Black, + size = 20f, + epsilon = 0.1f, + ) + ) + + /** The list of strokes drawn in the preview area. */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + val strokeList = mutableStateListOf() + + fun updateTestBrushColor(color: Color) { + _uiState.update { it.copy(testBrushColor = color) } + } + + fun updateTestBrushSize(size: Float) { + _uiState.update { it.copy(testBrushSize = size) } + } + + fun updateAllTextureIds() { + _uiState.update { state -> state.copy(allTextureIds = textureStore.getAllIds()) } + } + + private val tutorialManager = TutorialManager(repository) + + // Read-only for UI + val tutorialStep get() = tutorialManager.tutorialStep + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + val currentStepIndex get() = tutorialManager.currentStepIndex + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + val isTutorialSandboxMode get() = tutorialManager.isTutorialSandboxMode + + fun startTutorial() { + tutorialManager.startTutorial() + } + + fun startTutorialSandbox() { + val oldBrushFamily = brush.value.family + val defaultGraph = repository.createDefaultGraph() + repository.setGraph(defaultGraph) + + tutorialManager.startTutorialSandbox(oldBrushFamily) + + validate() + } + + fun advanceTutorial(action: TutorialAction = TutorialAction.CLICK_NEXT): Boolean { + return tutorialManager.advanceTutorial(action) + } + + fun regressTutorial() { + tutorialManager.regressTutorial() + } + + fun endTutorialSandbox(keepChanges: Boolean) { + val brushToRestore = tutorialManager.endTutorialSandbox(keepChanges) + if (brushToRestore != null) { + loadBrushFamily(brushToRestore) + } + } + + init { + validate() + + viewModelScope.launch { + repository.graph.collect { newGraph -> + _uiState.update { it.copy(graph = newGraph) } + } + } + + viewModelScope.launch { + repository.graphIssues.collect { newIssues -> + _uiState.update { it.copy(graphIssues = newIssues) } + } + } + + viewModelScope.launch { + brush.collect { newBrush -> + if (uiState.value.testAutoUpdateStrokes) { + for (i in strokeList.indices) { + strokeList[i] = strokeList[i].copy(brush = newBrush) + } + } + } + } + + viewModelScope.launch(Dispatchers.IO) { + val success = repository.loadAutoSaveBrush() + if (success) { + withContext(Dispatchers.Main) { + _uiState.update { state -> state.copy(allTextureIds = textureStore.getAllIds()) } + } + } + } + } + + fun postDebug(displayText: DisplayText) { + repository.postDebug(displayText) + } + + fun addNode(data: NodeData): String { + dismissPanes() + val newNodeId = repository.addNode(data) + _uiState.update { it.copy(selectedNodeId = newNodeId) } + validate() + + if (data is NodeData.Behavior) { + advanceTutorial(TutorialAction.ADD_INPUT_FAB) || advanceTutorial(TutorialAction.ADD_BEHAVIOR) + } else if (data is NodeData.ColorFunction) { + advanceTutorial(TutorialAction.ADD_COLOR) + } + return newNodeId + } + + fun addFamilyNode(): String { + return addNode(NodeData.Family()) + } + + fun addCoatNode(): String { + return addNode(NodeData.Coat()) + } + + fun addPaintNode(): String { + return addNode(NodeData.Paint(ProtoBrushPaint.getDefaultInstance())) + } + + fun addTipNode(): String { + return addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + } + + fun addColorFunctionNode(): String { + return addNode( + NodeData.ColorFunction( + ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build() + ) + ) + } + + fun addTextureLayerNode(): String { + return addNode(NodeData.TextureLayer(ProtoBrushPaint.TextureLayer.getDefaultInstance())) + } + + fun addBehaviorNode(): String { + return addNode( + NodeData.Behavior( + ProtoBrushBehavior.Node.newBuilder() + .setTargetNode( + ProtoBrushBehavior.TargetNode.newBuilder() + .setTarget(ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER) + .setTargetModifierRangeStart(0f) + .setTargetModifierRangeEnd(1f) + ) + .build() + ) + ) + } + + fun enterSelectionMode(initialNodeId: String? = null) { + val node = initialNodeId?.let { id -> uiState.value.graph.nodes.find { it.id == id } } + if (node?.data is NodeData.Family) return + _uiState.update { state -> + state.copy( + isSelectionMode = true, + selectedNodeIds = if (initialNodeId != null) setOf(initialNodeId) else emptySet() + ) + } + dismissPanes() + + advanceTutorial(TutorialAction.LONG_PRESS_NODE) + } + + fun toggleNodeSelection(nodeId: String) { + val node = uiState.value.graph.nodes.find { it.id == nodeId } + if (node?.data is NodeData.Family) return + _uiState.update { state -> + val newSelected = if (state.selectedNodeIds.contains(nodeId)) { + state.selectedNodeIds - nodeId + } else { + state.selectedNodeIds + nodeId + } + state.copy(selectedNodeIds = newSelected) + } + if (uiState.value.selectedNodeIds.isEmpty()) { + exitSelectionMode() + } + } + + fun selectAllNodes() { + val allNodeIds = + uiState.value.graph.nodes.filter { it.data !is NodeData.Family }.map { it.id }.toSet() + _uiState.update { it.copy(selectedNodeIds = allNodeIds) } + } + + fun exitSelectionMode() { + _uiState.update { it.copy(isSelectionMode = false) } + _uiState.update { it.copy(selectedNodeIds = emptySet()) } + + advanceTutorial(TutorialAction.CLICK_DONE) + } + + fun deleteSelectedNodes() { + val modifiedNodeIds = repository.deleteSelectedNodes(uiState.value.selectedNodeIds) + + advanceTutorial(TutorialAction.DELETE_NODE) + + exitSelectionMode() + } + + fun duplicateSelectedNodes(): Map { + val newNodeIdsMap = repository.duplicateSelectedNodes(uiState.value.selectedNodeIds) + + _uiState.update { it.copy(selectedNodeIds = newNodeIdsMap.values.toSet()) } + + advanceTutorial(TutorialAction.DUPLICATE_NODES) + return newNodeIdsMap + } + + fun updateNodeData(nodeId: String, newData: NodeData) { + repository.updateNodeData(nodeId, newData) + + validate() + } + + fun setNodeDisabled(nodeId: String, isDisabled: Boolean) { + repository.setNodeDisabled(nodeId, isDisabled) + validate() + } + + fun setEdgeDisabled(edge: GraphEdge, isDisabled: Boolean) { + _uiState.update { it.copy(selectedEdge = repository.setEdgeDisabled(edge, isDisabled)) } + validate() + } + + fun onNodeClick(nodeId: String) { + _uiState.update { state -> + state.copy( + selectedNodeId = if (state.selectedNodeId == nodeId) null else nodeId, + selectedEdge = null, + isErrorPaneOpen = false + ) + } + + advanceTutorial(TutorialAction.SELECT_NODE) + } + + private fun checkSelectNodeTrigger(nodeId: String) { + val node = uiState.value.graph.nodes.find { it.id == nodeId } + if (node != null) { + val shouldAdvance = (node.data is NodeData.Tip) || + (node.data is NodeData.Behavior && node.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.SOURCE_NODE) || + (node.data is NodeData.Behavior && node.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.BINARY_OP_NODE) || + (node.data is NodeData.Behavior && node.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.TARGET_NODE) || + tutorialStep?.getTargetNode(uiState.value.graph)?.id == nodeId + if (shouldAdvance) { + advanceTutorial(TutorialAction.SELECT_NODE) + } + } + } + + fun onEdgeClick(edge: GraphEdge) { + _uiState.update { state -> + val newEdge = if (state.selectedEdge?.fromNodeId == edge.fromNodeId && + state.selectedEdge.toNodeId == edge.toNodeId && state.selectedEdge.toPortId == edge.toPortId + ) null else edge + state.copy(selectedEdge = newEdge, isErrorPaneOpen = false, selectedNodeId = null) + } + + advanceTutorial(TutorialAction.SELECT_EDGE) + } + + fun clearSelectedNode() { + _uiState.update { it.copy(selectedNodeId = null) } + + advanceTutorial(TutorialAction.EXIT_INSPECTOR) + } + + fun clearSelectedEdge() { + _uiState.update { it.copy(selectedEdge = null) } + } + + fun toggleErrorPane() { + _uiState.update { it.copy(isErrorPaneOpen = !it.isErrorPaneOpen) } + if (uiState.value.isErrorPaneOpen) { + _uiState.update { it.copy(selectedNodeId = null) } + advanceTutorial(TutorialAction.CLICK_NOTIFICATION) + } + } + + fun dismissPanes() { + clearSelectedNode() + _uiState.update { + it.copy( + selectedEdge = null, + isErrorPaneOpen = false, + activeEdgeSourceId = null + ) + } + } + + fun onIssueClick(issue: GraphValidationException, isWideScreen: Boolean, density: Float) { + if (issue.nodeId != null) { + centerNode(issue.nodeId) + } + + advanceTutorial(TutorialAction.CLICK_ERROR_LINK) + } + + fun centerNode(nodeId: String) { + _uiState.update { + it.copy( + selectedNodeId = nodeId, + selectedEdge = null, + isErrorPaneOpen = false, + focusTrigger = it.focusTrigger + 1 + ) + } + checkSelectNodeTrigger(nodeId) + } + + fun togglePreviewExpanded() { + _uiState.update { it.copy(isPreviewExpanded = !it.isPreviewExpanded) } + } + + fun toggleCanvasTheme() { + _uiState.update { it.copy(isDarkCanvas = !it.isDarkCanvas) } + } + + fun addNodeAndConnect(nodeData: NodeData, targetNodeId: String, targetPortId: String): String { + val newNodeId = repository.addNode(nodeData) + + addEdge(newNodeId, targetNodeId, targetPortId) + + if (nodeData is NodeData.Behavior) { + advanceTutorial(TutorialAction.ADD_BEHAVIOR) + } else if (nodeData is NodeData.ColorFunction) { + advanceTutorial(TutorialAction.ADD_COLOR) + } + return newNodeId + } + + /** Adds a new edge between two nodes. */ + fun addEdge(fromNodeId: String, toNodeId: String, initialToPortId: String) { + repository.addEdge(fromNodeId, toNodeId, initialToPortId) + validate() + + val fromNode = uiState.value.graph.nodes.find { it.id == fromNodeId } ?: return + val toNode = uiState.value.graph.nodes.find { it.id == toNodeId } ?: return + val shouldAdvance = + (fromNode.data is NodeData.Behavior && fromNode.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.SOURCE_NODE && + toNode.data is NodeData.Behavior && toNode.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.TARGET_NODE) || + (fromNode.data is NodeData.Coat && toNode.data is NodeData.Family) || + (fromNode.data is NodeData.Behavior && toNode.data is NodeData.Tip) + if (shouldAdvance) { + advanceTutorial(TutorialAction.CONNECT_NODES) + } + } + + /** Finalizes an edge edit by deleting the old edge and adding the new one. */ + fun finalizeEdgeEdit( + oldEdge: GraphEdge, + newFromNodeId: String, + newToNodeId: String, + newToPortId: String, + ) { + if (oldEdge.toNodeId == newToNodeId && oldEdge.toPortId == newToPortId) { + // Reconnecting to the same port, just re-enable it. + setEdgeDisabled(oldEdge, false) + _uiState.update { it.copy(detachedEdge = null) } + return + } + + deleteEdge(oldEdge) + + addEdge(newFromNodeId, newToNodeId, newToPortId) + } + + /** Detaches an edge for editing by marking it as disabled. */ + fun detachEdge(edge: GraphEdge) { + _uiState.update { state -> state.copy(detachedEdge = edge) } + repository.setEdgeDisabled(edge, true) + } + + fun reorderPorts(nodeId: String, fromIndex: Int, toIndex: Int) { + repository.reorderPorts(nodeId, fromIndex, toIndex) + advanceTutorial(TutorialAction.SWAP_PORTS) + } + + fun deleteEdge(edge: GraphEdge) { + if (uiState.value.selectedEdge == edge) { + _uiState.update { state -> state.copy(selectedEdge = null) } + } + if (uiState.value.detachedEdge == edge) { + _uiState.update { state -> state.copy(detachedEdge = null) } + } + + val modifiedNodeIds = repository.deleteEdge(edge) + } + + fun addNodeBetween(edge: GraphEdge): String? { + dismissPanes() + val newNodeId = repository.addNodeBetween(edge) + if (newNodeId != null) { + _uiState.update { it.copy(selectedNodeId = newNodeId) } + } + + advanceTutorial(TutorialAction.ADD_NODE_BETWEEN) + return newNodeId + } + + fun clearGraph() { + dismissPanes() + repository.clearGraph() + clearStrokes() + validate() + } + + fun deleteNode(nodeId: String) { + val node = uiState.value.graph.nodes.find { it.id == nodeId } ?: return + if (node.data is NodeData.Family) { + return + } + if (uiState.value.selectedNodeId == nodeId) { + _uiState.update { it.copy(selectedNodeId = null) } + } + + advanceTutorial(TutorialAction.DELETE_NODE) + + val modifiedNodeIds = repository.deleteNode(nodeId) + validate() + } + + fun validate() { + repository.validate() + } + + fun reorganize() { + dismissPanes() + repository.reorganize() + } + + fun clearStrokes() { + strokeList.clear() + } + + fun loadBrushFamily(family: BrushFamily) { + dismissPanes() + repository.loadBrushFamily(family) + } + + fun getBrushColor(): Color = brush.value.composeColor + + fun updateZoom(newZoom: Float) { + _uiState.update { state -> state.copy(zoom = newZoom) } + } + + fun updateOffset(newOffset: GraphPoint) { + _uiState.update { state -> state.copy(offset = newOffset) } + } + + fun toggleTextFieldsLocked() { + _uiState.update { state -> state.copy(textFieldsLocked = !state.textFieldsLocked) } + } + + fun saveToPalette(brushName: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + val baos = ByteArrayOutputStream() + AndroidBrushFamilySerialization.encode(brush.value.family, baos, textureStore) + val finalCompressedBytes = baos.toByteArray() + + customBrushDao.saveCustomBrush( + CustomBrushEntity(name = brushName, brushBytes = finalCompressedBytes) + ) + } catch (e: Exception) { + postDebug( + DisplayText.Resource( + com.example.cahier.R.string.bg_err_save_palette, + listOf(e.message ?: e.javaClass.simpleName) + ) + ) + } + } + } + + fun deleteFromPalette(name: String) { + viewModelScope.launch(Dispatchers.IO) { + customBrushDao.deleteCustomBrush(name) + } + } + + fun loadTexture(id: String, bitmap: Bitmap) { + textureStore.loadTexture(id, bitmap) + updateAllTextureIds() + } + + fun loadFromPalette(entity: CustomBrushEntity) { + viewModelScope.launch(Dispatchers.IO) { + try { + val family = AndroidBrushFamilySerialization.decode( + ByteArrayInputStream(entity.brushBytes), + maxVersion = Version.DEVELOPMENT, + BrushFamilyDecodeCallback { id, bitmap -> + if (bitmap != null) { + loadTexture(id, bitmap) + } + id + } + ) + withContext(Dispatchers.Main) { + loadBrushFamily(family) + } + } catch (e: Exception) { + postDebug( + DisplayText.Resource( + com.example.cahier.R.string.bg_err_load_palette, + listOf(e.message ?: e.javaClass.simpleName) + ) + ) + } + } + } + + fun setTestAutoUpdateStrokes(value: Boolean) { + _uiState.update { state -> state.copy(testAutoUpdateStrokes = value) } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/TutorialManager.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/TutorialManager.kt new file mode 100644 index 00000000..5db15c99 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/TutorialManager.kt @@ -0,0 +1,88 @@ +/* + * 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.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.ink.brush.BrushFamily +import com.example.cahier.developer.brushgraph.data.BrushGraphRepository +import com.example.cahier.developer.brushgraph.data.TUTORIAL_STEPS +import com.example.cahier.developer.brushgraph.data.TutorialAction +import com.example.cahier.developer.brushgraph.data.TutorialStep + +class TutorialManager( + private val repository: BrushGraphRepository, +) { + var tutorialStep by mutableStateOf(null) + private set + + var currentStepIndex by mutableIntStateOf(0) + private set + + private val tutorialSteps = mutableStateListOf() + + var savedBrushFamily by mutableStateOf(null) + private set + + var isTutorialSandboxMode by mutableStateOf(false) + private set + + fun startTutorial() { + tutorialSteps.clear() + tutorialSteps.addAll(TUTORIAL_STEPS) + currentStepIndex = 0 + tutorialStep = tutorialSteps.getOrNull(currentStepIndex) + repository.clearIssues() + } + + fun startTutorialSandbox(currentBrushFamily: BrushFamily) { + savedBrushFamily = currentBrushFamily + isTutorialSandboxMode = true + startTutorial() + } + + fun advanceTutorial(action: TutorialAction = TutorialAction.CLICK_NEXT): Boolean { + val step = tutorialStep + if (step != null && step.actionRequired == action) { + currentStepIndex++ + if (currentStepIndex < tutorialSteps.size) { + tutorialStep = tutorialSteps[currentStepIndex] + } else { + tutorialStep = null // Tutorial finished! + } + return true + } + return false + } + + fun regressTutorial() { + if (currentStepIndex > 0) { + currentStepIndex-- + tutorialStep = tutorialSteps[currentStepIndex] + } + } + + fun endTutorialSandbox(keepChanges: Boolean): BrushFamily? { + isTutorialSandboxMode = false + val brushToRestore = if (!keepChanges) savedBrushFamily else null + savedBrushFamily = null + tutorialStep = null + return brushToRestore + } +} diff --git a/app/src/main/java/com/example/cahier/features/drawing/CustomBrushes.kt b/app/src/main/java/com/example/cahier/features/drawing/CustomBrushes.kt index 1de6722b..55fdb95e 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/CustomBrushes.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/CustomBrushes.kt @@ -22,25 +22,29 @@ package com.example.cahier.features.drawing import android.content.Context import android.util.Log -import androidx.ink.brush.BrushFamily import androidx.ink.brush.ExperimentalInkCustomBrushApi -import androidx.ink.storage.decode +import androidx.ink.brush.Version +import androidx.ink.storage.AndroidBrushFamilySerialization import com.example.cahier.R import com.example.cahier.core.data.CustomBrush +import com.example.cahier.core.ui.CahierTextureBitmapStore object CustomBrushes { private var customBrushes: List? = null private const val TAG = "CustomBrushes" - fun getBrushes(context: Context): List { + fun getBrushes(context: Context, textureStore: CahierTextureBitmapStore): List { return customBrushes ?: synchronized(this) { - customBrushes ?: loadCustomBrushes(context).also { customBrushes = it } + customBrushes ?: loadCustomBrushes(context, textureStore).also { customBrushes = it } } } @OptIn(ExperimentalInkCustomBrushApi::class) - private fun loadCustomBrushes(context: Context): List { + private fun loadCustomBrushes( + context: Context, + textureStore: CahierTextureBitmapStore, + ): List { val brushFiles = mapOf( "Calligraphy" to (R.raw.calligraphy to R.drawable.draw_24px), "Flag Banner" to (R.raw.flag_banner to R.drawable.flag_24px), @@ -58,7 +62,14 @@ object CustomBrushes { val (resourceId, icon) = pair try { val brushFamily = context.resources.openRawResource(resourceId).use { inputStream -> - BrushFamily.decode(inputStream) + AndroidBrushFamilySerialization.decode( + inputStream, + maxVersion = Version.DEVELOPMENT + ) { id, bitmap -> + if (bitmap != null) + textureStore.loadTexture(id, bitmap) + id + } } CustomBrush(name, icon, brushFamily) } catch (e: Exception) { diff --git a/app/src/main/java/com/example/cahier/features/drawing/DrawingCanvas.kt b/app/src/main/java/com/example/cahier/features/drawing/DrawingCanvas.kt index f8fe563e..1edd3b14 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/DrawingCanvas.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/DrawingCanvas.kt @@ -49,6 +49,7 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -63,7 +64,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -80,8 +80,8 @@ import com.example.cahier.core.ui.ColorPickerDialog import com.example.cahier.core.ui.ConfirmationDialog import com.example.cahier.core.ui.DrawingSurface import com.example.cahier.core.ui.FocusedFieldEnum +import com.example.cahier.core.ui.LocalTextureStore import com.example.cahier.core.ui.theme.CahierAppTheme -import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.core.utils.createDropTarget import com.example.cahier.features.drawing.viewmodel.DrawingCanvasViewModel @@ -93,8 +93,9 @@ import com.example.cahier.features.drawing.viewmodel.DrawingCanvasViewModel @Composable fun DrawingCanvas( navigateUp: () -> Unit, + navigateToBrushGraph: () -> Unit, modifier: Modifier = Modifier, - drawingCanvasViewModel: DrawingCanvasViewModel = hiltViewModel() + drawingCanvasViewModel: DrawingCanvasViewModel = hiltViewModel(), ) { val uiState by drawingCanvasViewModel.uiState.collectAsStateWithLifecycle() var showConfirmationDialog by rememberSaveable { mutableStateOf(false) } @@ -140,7 +141,8 @@ fun DrawingCanvas( DrawingCanvasContent( drawingCanvasViewModel = drawingCanvasViewModel, imagePickerLauncher = imagePickerLauncher, - onNavigateUp = navigateUp + onNavigateUp = navigateUp, + navigateToBrushGraph = navigateToBrushGraph ) } } @@ -148,7 +150,7 @@ fun DrawingCanvas( @Composable private fun DrawingCanvasTopBar( drawingCanvasViewModel: DrawingCanvasViewModel, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val uiState by drawingCanvasViewModel.uiState.collectAsStateWithLifecycle() var titleState by rememberSaveable(stateSaver = TextFieldValue.Saver) { @@ -198,7 +200,8 @@ private fun DrawingCanvasContent( drawingCanvasViewModel: DrawingCanvasViewModel, imagePickerLauncher: ActivityResultLauncher, onNavigateUp: () -> Unit, - modifier: Modifier = Modifier + navigateToBrushGraph: () -> Unit, + modifier: Modifier = Modifier, ) { val activity = LocalActivity.current as ComponentActivity val windowSizeClass = calculateWindowSizeClass(activity) @@ -237,6 +240,7 @@ private fun DrawingCanvasContent( onUndo = drawingCanvasViewModel::undo, onRedo = drawingCanvasViewModel::redo, onExit = onNavigateUp, + onEditActiveBrush = navigateToBrushGraph, onColorPickerClick = { showColorPicker = true }, ) @@ -274,16 +278,16 @@ private fun DrawingCanvasContent( @Composable private fun DrawingSurfaceWithTarget( drawingCanvasViewModel: DrawingCanvasViewModel, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { val uiState by drawingCanvasViewModel.uiState.collectAsStateWithLifecycle() val exportedUri by drawingCanvasViewModel.exportedImageUri.collectAsStateWithLifecycle() val currentBrush by drawingCanvasViewModel.currentBrush.collectAsStateWithLifecycle() val isEraserMode by drawingCanvasViewModel.isEraserMode.collectAsStateWithLifecycle() val strokes = remember { mutableStateListOf() } - val context = LocalContext.current - val textureStore = remember { CahierTextureBitmapStore(context) } - val canvasStrokeRenderer = remember { + val textureStore = LocalTextureStore.current + val cacheGen by textureStore.generation.collectAsState() + val canvasStrokeRenderer = remember(cacheGen) { CanvasStrokeRenderer.create(textureStore = textureStore) } var canvasSize by remember { mutableStateOf(IntSize.Zero) } @@ -370,7 +374,6 @@ private fun DrawingSurfaceWithTarget( onGetNextBrush = drawingCanvasViewModel::getCurrentBrush, isEraserMode = isEraserMode, backgroundImageUri = uiState.note.imageUriList?.firstOrNull(), - textureStore = textureStore, modifier = Modifier.fillMaxSize() ) } diff --git a/app/src/main/java/com/example/cahier/features/drawing/DrawingDetailThumbnail.kt b/app/src/main/java/com/example/cahier/features/drawing/DrawingDetailThumbnail.kt index 066b7b93..0e5155a6 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/DrawingDetailThumbnail.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/DrawingDetailThumbnail.kt @@ -24,18 +24,19 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer import androidx.ink.strokes.Stroke import coil3.compose.AsyncImage -import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.core.ui.LocalTextureStore @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -45,9 +46,10 @@ fun DrawingDetailThumbnail( modifier: Modifier = Modifier, backgroundImageUri: String? = null, ) { - val context = LocalContext.current - val canvasStrokeRenderer = remember { - CanvasStrokeRenderer.create(textureStore = CahierTextureBitmapStore(context)) + val textureStore = LocalTextureStore.current + val cacheGen by textureStore.generation.collectAsState() + val canvasStrokeRenderer = remember(cacheGen) { + CanvasStrokeRenderer.create(textureStore) } Box( @@ -85,7 +87,7 @@ fun DrawingDetailThumbnail( @Preview @Composable fun DrawingDetailThumbnailPreview( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { DrawingDetailThumbnail( modifier = modifier diff --git a/app/src/main/java/com/example/cahier/features/drawing/DrawingToolbox.kt b/app/src/main/java/com/example/cahier/features/drawing/DrawingToolbox.kt index ef82901b..9958b084 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/DrawingToolbox.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/DrawingToolbox.kt @@ -68,6 +68,7 @@ fun DrawingToolbox( onUndo: () -> Unit, onRedo: () -> Unit, onExit: () -> Unit, + onEditActiveBrush: () -> Unit, isVertical: Boolean, onColorPickerClick: () -> Unit, modifier: Modifier = Modifier @@ -86,6 +87,7 @@ fun DrawingToolbox( onUndo = onUndo, onRedo = onRedo, onExit = onExit, + onEditActiveBrush = onEditActiveBrush, onColorPickerClick = onColorPickerClick ) } else { @@ -97,6 +99,7 @@ fun DrawingToolbox( onUndo = onUndo, onRedo = onRedo, onExit = onExit, + onEditActiveBrush = onEditActiveBrush, onColorPickerClick = onColorPickerClick ) } @@ -308,6 +311,7 @@ internal fun ToolboxNoteActions( drawingCanvasViewModel: DrawingCanvasViewModel, imagePickerLauncher: ActivityResultLauncher, onExit: () -> Unit, + onEditActiveBrush: () -> Unit, isVertical: Boolean, modifier: Modifier = Modifier ) { @@ -316,7 +320,8 @@ internal fun ToolboxNoteActions( ToolboxNoteActionsContent( drawingCanvasViewModel = drawingCanvasViewModel, imagePickerLauncher = imagePickerLauncher, - onExit = onExit + onExit = onExit, + onEditActiveBrush = onEditActiveBrush ) } } else { @@ -324,7 +329,8 @@ internal fun ToolboxNoteActions( ToolboxNoteActionsContent( drawingCanvasViewModel = drawingCanvasViewModel, imagePickerLauncher = imagePickerLauncher, - onExit = onExit + onExit = onExit, + onEditActiveBrush = onEditActiveBrush ) } } @@ -334,10 +340,12 @@ internal fun ToolboxNoteActions( private fun ToolboxNoteActionsContent( drawingCanvasViewModel: DrawingCanvasViewModel, imagePickerLauncher: ActivityResultLauncher, - onExit: () -> Unit + onExit: () -> Unit, + onEditActiveBrush: () -> Unit ) { val uiState by drawingCanvasViewModel.uiState.collectAsStateWithLifecycle() var optionsMenuExpanded by rememberSaveable { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() IconButton( onClick = { drawingCanvasViewModel.toggleFavorite() }, modifier = Modifier.size(48.dp) @@ -383,6 +391,22 @@ private fun ToolboxNoteActionsContent( expanded = optionsMenuExpanded, onDismissRequest = { optionsMenuExpanded = false } ) { + DropdownMenuItem( + text = { Text("Edit active brush") }, + onClick = { + optionsMenuExpanded = false + coroutineScope.launch { + drawingCanvasViewModel.saveCurrentBrushToAutosave() + onEditActiveBrush() + } + }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.edit_24px), + contentDescription = null + ) + } + ) DropdownMenuItem( text = { Text(stringResource(R.string.exit)) }, onClick = { diff --git a/app/src/main/java/com/example/cahier/features/drawing/HorizontalToolbox.kt b/app/src/main/java/com/example/cahier/features/drawing/HorizontalToolbox.kt index 79b8307b..2cb543b0 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/HorizontalToolbox.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/HorizontalToolbox.kt @@ -43,6 +43,7 @@ internal fun HorizontalToolbox( onUndo: () -> Unit, onRedo: () -> Unit, onExit: () -> Unit, + onEditActiveBrush: () -> Unit, onColorPickerClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -99,6 +100,7 @@ internal fun HorizontalToolbox( drawingCanvasViewModel = drawingCanvasViewModel, imagePickerLauncher = imagePickerLauncher, onExit = onExit, + onEditActiveBrush = onEditActiveBrush, isVertical = false ) } diff --git a/app/src/main/java/com/example/cahier/features/drawing/VerticalToolbox.kt b/app/src/main/java/com/example/cahier/features/drawing/VerticalToolbox.kt index 739ee132..21a8e0b5 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/VerticalToolbox.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/VerticalToolbox.kt @@ -42,6 +42,7 @@ internal fun VerticalToolbox( onUndo: () -> Unit, onRedo: () -> Unit, onExit: () -> Unit, + onEditActiveBrush: () -> Unit, onColorPickerClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -94,6 +95,7 @@ internal fun VerticalToolbox( drawingCanvasViewModel = drawingCanvasViewModel, imagePickerLauncher = imagePickerLauncher, onExit = onExit, + onEditActiveBrush = onEditActiveBrush, isVertical = true ) } diff --git a/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt b/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt index f8c87393..9cc42710 100644 --- a/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt +++ b/app/src/main/java/com/example/cahier/features/drawing/viewmodel/DrawingCanvasViewModel.kt @@ -31,6 +31,7 @@ import androidx.ink.brush.Brush import androidx.ink.brush.BrushFamily import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.ink.brush.StockBrushes +import androidx.ink.brush.Version import androidx.ink.brush.compose.composeColor import androidx.ink.brush.compose.copyWithComposeColor import androidx.ink.brush.compose.createWithComposeColor @@ -40,7 +41,7 @@ import androidx.ink.geometry.MutableParallelogram import androidx.ink.geometry.MutableSegment import androidx.ink.geometry.MutableVec import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer -import androidx.ink.storage.decode +import androidx.ink.storage.AndroidBrushFamilySerialization import androidx.ink.strokes.Stroke import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -54,9 +55,11 @@ import com.example.cahier.core.data.NotesRepository import com.example.cahier.core.navigation.DrawingCanvasDestination import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.core.ui.CahierUiState +import com.example.cahier.core.utils.FileHelper +import com.example.cahier.developer.brushdesigner.data.AUTOSAVE_KEY import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity import com.example.cahier.features.drawing.CustomBrushes -import com.example.cahier.core.utils.FileHelper import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -73,12 +76,13 @@ import javax.inject.Inject @HiltViewModel class DrawingCanvasViewModel @Inject constructor( - @param: ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, savedStateHandle: SavedStateHandle, private val noteRepository: NotesRepository, val fileHelper: FileHelper, private val imageLoader: ImageLoader, private val customBrushDao: CustomBrushDao, + val textureStore: CahierTextureBitmapStore, ) : ViewModel() { private val _uiState = MutableStateFlow(CahierUiState()) @@ -122,8 +126,6 @@ class DrawingCanvasViewModel @Inject constructor( private var isBrushSelectedInSession = false - val textureStore = CahierTextureBitmapStore(context) - init { viewModelScope.launch { noteRepository.getNoteStream(noteId) @@ -282,7 +284,7 @@ class DrawingCanvasViewModel @Inject constructor( val strokeRenderer = CanvasStrokeRenderer.create( forcePathRendering = true, - textureStore = CahierTextureBitmapStore(context) + textureStore = textureStore ) strokes.forEach { stroke -> strokeRenderer.draw(canvas, stroke, android.graphics.Matrix()) @@ -454,10 +456,34 @@ class DrawingCanvasViewModel @Inject constructor( return _selectedBrush.value } + @OptIn(ExperimentalInkCustomBrushApi::class) + suspend fun saveCurrentBrushToAutosave() { + withContext(Dispatchers.IO) { + try { + val stream = java.io.ByteArrayOutputStream() + AndroidBrushFamilySerialization.encode( + _selectedBrush.value.family, + stream, + textureStore + ) + val encodedBrushFamily = stream.toByteArray() + customBrushDao.saveCustomBrush( + CustomBrushEntity( + AUTOSAVE_KEY, + encodedBrushFamily + ) + ) + Log.d(TAG, "Auto saved brush to database successfully") + } catch (e: Exception) { + Log.e(TAG, "Error auto saving brush to database", e) + } + } + } + @OptIn(ExperimentalInkCustomBrushApi::class) private fun loadCustomBrushes() { viewModelScope.launch(Dispatchers.IO) { - val builtInBrushes = CustomBrushes.getBrushes(context) + val builtInBrushes = CustomBrushes.getBrushes(context, textureStore) val decodedCache = mutableMapOf() @@ -489,7 +515,15 @@ class DrawingCanvasViewModel @Inject constructor( } ByteArrayInputStream(entity.brushBytes).use { inputStream -> - val family = BrushFamily.decode(inputStream) + val family = + AndroidBrushFamilySerialization.decode( + inputStream, + maxVersion = Version.DEVELOPMENT + ) { id, bitmap -> + if (bitmap != null) + textureStore.loadTexture(id, bitmap) + id + } CustomBrush( name = entity.name, icon = com.example.cahier.R.drawable.edit_24px, diff --git a/app/src/main/java/com/example/cahier/features/home/CahierApp.kt b/app/src/main/java/com/example/cahier/features/home/CahierApp.kt index 2bd1774e..7f4a7cff 100644 --- a/app/src/main/java/com/example/cahier/features/home/CahierApp.kt +++ b/app/src/main/java/com/example/cahier/features/home/CahierApp.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.compose.rememberNavController import com.example.cahier.core.data.NoteType import com.example.cahier.core.navigation.CahierNavHost +import com.example.cahier.core.ui.CahierTextureBitmapStore import com.example.cahier.core.navigation.DrawingCanvasDestination import com.example.cahier.core.navigation.TextCanvasDestination @@ -29,6 +30,7 @@ import com.example.cahier.core.navigation.TextCanvasDestination fun CahierApp( noteId: Long, noteType: NoteType?, + textureStore: CahierTextureBitmapStore, modifier: Modifier = Modifier, ) { val navController = rememberNavController() @@ -48,6 +50,7 @@ fun CahierApp( CahierNavHost( navController = navController, + textureStore = textureStore, modifier = modifier ) } \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/features/home/CahierHomeScreen.kt b/app/src/main/java/com/example/cahier/features/home/CahierHomeScreen.kt index 0a5595ec..6684754f 100644 --- a/app/src/main/java/com/example/cahier/features/home/CahierHomeScreen.kt +++ b/app/src/main/java/com/example/cahier/features/home/CahierHomeScreen.kt @@ -103,6 +103,7 @@ fun HomePane( navigateToCanvas: (Long) -> Unit, navigateToDrawingCanvas: (Long) -> Unit, navigateToBrushDesigner: () -> Unit = {}, + navigateToBrushGraph: () -> Unit = {}, navigateUp: () -> Unit, modifier: Modifier = Modifier, forceCompact: Boolean? = null, @@ -176,6 +177,7 @@ fun HomePane( navigateToCanvas = navigateToCanvas, navigateToDrawingCanvas = navigateToDrawingCanvas, navigateToBrushDesigner = navigateToBrushDesigner, + navigateToBrushGraph = navigateToBrushGraph, navigateUp = navigateUp ) } @@ -196,6 +198,7 @@ private fun CahierNavigationSuite( navigateToCanvas: (Long) -> Unit, navigateToDrawingCanvas: (Long) -> Unit, navigateToBrushDesigner: () -> Unit, + navigateToBrushGraph: () -> Unit, navigateUp: () -> Unit ) { NavigationSuiteScaffold( @@ -310,6 +313,7 @@ private fun CahierNavigationSuite( AppDestinations.Settings -> { SettingsScreen( navigateToBrushDesigner = navigateToBrushDesigner, + navigateToBrushGraph = navigateToBrushGraph, modifier = Modifier.fillMaxSize() ) } diff --git a/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt b/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt index e3c45107..74b0528b 100644 --- a/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt +++ b/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt @@ -59,11 +59,13 @@ import kotlinx.coroutines.launch @Composable fun SettingsScreen( navigateToBrushDesigner: () -> Unit, + navigateToBrushGraph: () -> Unit, modifier: Modifier = Modifier, viewModel: SettingsViewModel = hiltViewModel() ) { val isRoleAvailable by viewModel.isRoleAvailable.collectAsStateWithLifecycle() val isRoleHeld by viewModel.isRoleHeld.collectAsStateWithLifecycle() + val isUsingGraphUi by viewModel.isUsingGraphUi.collectAsStateWithLifecycle() LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -196,7 +198,13 @@ fun SettingsScreen( } FilledTonalButton( - onClick = { navigateToBrushDesigner() } + onClick = { + if (isUsingGraphUi) { + navigateToBrushGraph() + } else { + navigateToBrushDesigner() + } + } ) { Text(stringResource(R.string.settings_launch)) } @@ -219,9 +227,9 @@ fun SettingsScreen( ) } Switch( - checked = false, - onCheckedChange = null, - enabled = false + checked = isUsingGraphUi, + onCheckedChange = { viewModel.setUsingGraphUi(it) }, + enabled = true ) } } diff --git a/app/src/main/java/com/example/cahier/features/home/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/example/cahier/features/home/viewmodel/SettingsViewModel.kt index 8a57d662..5977fee1 100644 --- a/app/src/main/java/com/example/cahier/features/home/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/example/cahier/features/home/viewmodel/SettingsViewModel.kt @@ -48,6 +48,13 @@ class SettingsViewModel @Inject constructor( private val _isRoleHeld = MutableStateFlow(false) val isRoleHeld: StateFlow = _isRoleHeld.asStateFlow() + private val _isUsingGraphUi = MutableStateFlow(true) + val isUsingGraphUi: StateFlow = _isUsingGraphUi.asStateFlow() + + fun setUsingGraphUi(isDefault: Boolean) { + _isUsingGraphUi.value = isDefault + } + private val roleManager: RoleManager? by lazy { context.getSystemService(RoleManager::class.java) } diff --git a/app/src/main/res/drawable/gs_drag_indicator_vd_theme_24.xml b/app/src/main/res/drawable/gs_drag_indicator_vd_theme_24.xml new file mode 100644 index 00000000..b10d45cd --- /dev/null +++ b/app/src/main/res/drawable/gs_drag_indicator_vd_theme_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 701a0446..0c0d64e3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,11 +90,11 @@ Stock Brushes My Palette No saved brushes yet - Save to Palette + Save to Cahier Palette Import Export More options - Save to Cahier Palette + Save to Palette • This brush will appear in the main Cahier toolbox.\n• Large textures are stored in RAM. Avoid saving many texture-heavy brushes to prevent performance lag or memory issues. Brush Name Tip Shape @@ -344,7 +344,7 @@ Self Overlap Function Type Color: - Client Brush Family ID + Brush name Brush developer comment Input Model Delete Nodes @@ -384,7 +384,9 @@ Close Pane Notifications (%1$d) Errors + Error: %1$s Warnings + Warning: %1$s Debug Add Point Remove Point @@ -406,7 +408,7 @@ Polar Target Unknown Color Function - + Edge refers to missing source node Edge refers to missing target node Graph must have exactly one Brush Family node. Found %1$d. @@ -438,7 +440,7 @@ Expected %1$s node, found %2$s Internal error during conversion: %1$s Unsupported behavior node type: %1$s - Failed to load brush: Legacy format not supported yet. + Failed to load brush. Brush loaded successfully Failed to load brush: %1$s Brush exported successfully @@ -448,6 +450,8 @@ Reorganization failed Failed to load brush Cannot delete Family node + Failed to save brush to palette: %1$s + Failed to load brush from palette: %1$s Behavior node %1$s cannot accept input from %2$s Behavior node %1$s cannot accept input from structural node %2$s Coat can only accept input from Tip at the tip port @@ -474,20 +478,20 @@ End Angle Mag - + opacity multiplier replace color - + unknown mouse touch stylus none - + cm size s - + overlap: %1$s period: %1$s gap: %1$s%2$s @@ -506,13 +510,11 @@ normalized direction Y distance traveled time of input (s) - time of input (ms) + time from input to stroke end (s) predicted distance traveled predicted time elapsed (s) - predicted time elapsed (ms) distance remaining time since input (s) - time since input (ms) time since stroke end acceleration acceleration X @@ -673,7 +675,6 @@ Signed Y component of the modeled stroke input\'s current direction of travel, normalized to the range [-1, 1]. It indicates the vertical direction the stroke is moving. Distance traveled by the inputs of the current stroke, starting at 0 at the first input, where one distance unit is equal to the brush size. This is useful for effects that change progressively along the stroke. The time elapsed (in seconds) from when the stroke started to when this part of the stroke was drawn. The value remains fixed for any given part of the stroke once drawn. - The time elapsed (in milliseconds) from when the stroke started. This is deprecated; use SOURCE_TIME_OF_INPUT_IN_SECONDS instead. Distance traveled by the inputs of the current prediction, starting at 0 at the last non-predicted input, in multiples of the brush size. Elapsed time (in seconds) of the prediction, starting at 0 at the last non-predicted input. Stylus tilt along the horizontal axis, in radians. Positive values correspond to tilt toward the right. @@ -702,8 +703,7 @@ Distance remaining to the end of the stroke, as a fraction of the total stroke length. Time elapsed since the stroke ended, in seconds. Useful for post-stroke effects. Unspecified input source. - Elapsed time of the prediction in milliseconds. This is deprecated; use SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS instead. - Time elapsed since input was recorded in milliseconds. This is deprecated; use SOURCE_TIME_SINCE_INPUT_IN_SECONDS instead. + Time elapsed from the current modeled stroke input until the last input in the stroke. Scales the brush tip width. If multiple behaviors target this or size multiplier, they combine multiplicatively. The final width is clamped to a maximum of twice the baseline width. Scales the brush tip height. If multiple behaviors target this or size multiplier, they combine multiplicatively. The final height is clamped to a maximum of twice the baseline height. @@ -775,13 +775,6 @@ Values outside the range repeat in a mirrored fashion, bouncing back and forth between the boundaries. This creates a smooth, oscillating effect. Unspecified out of range behavior. - - Stylus or touch pressure (Deprecated). - Stylus tilt (Deprecated). - Stylus orientation (Deprecated). - Stylus tilt along X and Y axes (Deprecated). - Optional input property (Deprecated). - Multiplies value A by value B. If either value is missing (null), the result is also missing. Adds value A to value B. If either value is missing (null), the result is also missing. @@ -1020,4 +1013,4 @@ Window Size (ms) Upsampling Frequency (Hz) - \ No newline at end of file + diff --git a/app/src/test/java/com/example/cahier/ScreenshotTest.kt b/app/src/test/java/com/example/cahier/ScreenshotTest.kt index 93a83f12..018669be 100644 --- a/app/src/test/java/com/example/cahier/ScreenshotTest.kt +++ b/app/src/test/java/com/example/cahier/ScreenshotTest.kt @@ -30,6 +30,8 @@ class ScreenshotTest { HomePane( navigateToCanvas = { _ -> }, navigateToDrawingCanvas = { _ -> }, + navigateToBrushDesigner = {}, + navigateToBrushGraph = {}, navigateUp = {}, homeScreenViewModel = fakeViewModel ) diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt new file mode 100644 index 00000000..597db7c8 --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt @@ -0,0 +1,966 @@ +/* + * * 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.data + +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 com.example.cahier.developer.brushgraph.data.ValidationSeverity +import com.example.cahier.developer.brushgraph.data.GraphValidator +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.developer.brushgraph.data.BrushFamilyConverter +import com.example.cahier.developer.brushgraph.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import ink.proto.BrushBehavior +import ink.proto.BrushTip +import ink.proto.BrushPaint +import ink.proto.BrushFamily +import com.example.cahier.R +import org.junit.Assert.assertTrue +import org.junit.Test + +class BrushFamilyConverterTest { + + @Test + fun validateAll_disabledNonOperatorNode_isIgnored() { + val disabledNode = GraphNode( + id = "target_node", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ), + isDisabled = true + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "target_node", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, disabledNode), + edges = edges + ) + + val issues = GraphValidator.validateAll(graph) + println("Test 1 issues: $issues") + + // Target node should not report errors because it is disabled. + assertTrue(issues.none { it.nodeId == "target_node" }) + } + + @Test + fun validateAll_disabledOperatorNodeActsAsPassThrough_succeeds() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setSourceNode(BrushBehavior.SourceNode.newBuilder() + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .build()) + .build() + ) + ) + + val dampingNode = GraphNode( + id = "damping", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setDampingNode(BrushBehavior.DampingNode.newBuilder().build()) + .build() + ), + isDisabled = true + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "damping", toPortId = "Input"), + GraphEdge(fromNodeId = "damping", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, dampingNode, sourceNode), + edges = edges + ) + + val issues = GraphValidator.validateAll(graph) + println("Test 2 issues: $issues") + + // Should pass because Damping passes through! + assertTrue(issues.none { it.severity == ValidationSeverity.ERROR }) + } + + @Test + fun validateAll_disabledMultiInputOperatorFirstInput_succeeds() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setSourceNode(BrushBehavior.SourceNode.newBuilder() + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .build()) + .build() + ) + ) + + val binaryOpNode = GraphNode( + id = "binary_op", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setBinaryOpNode(BrushBehavior.BinaryOpNode.newBuilder().build()) + .build() + ), + isDisabled = true + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "binary_op", toPortId = "input_0"), + GraphEdge(fromNodeId = "binary_op", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, binaryOpNode, sourceNode), + edges = edges + ) + + val issues = GraphValidator.validateAll(graph) + println("Test 3 issues: $issues") + + assertTrue(issues.none { it.severity == ValidationSeverity.ERROR }) + } + + @Test + fun validateAll_disabledMultiInputOperatorSecondInput_noMissingSourceError() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setSourceNode(BrushBehavior.SourceNode.newBuilder() + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .build()) + .build() + ) + ) + + val binaryOpNode = GraphNode( + id = "binary_op", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setBinaryOpNode(BrushBehavior.BinaryOpNode.newBuilder().build()) + .build() + ), + isDisabled = true + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "binary_op", toPortId = "input_1"), + GraphEdge(fromNodeId = "binary_op", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, binaryOpNode, sourceNode), + edges = edges + ) + + val issues = GraphValidator.validateAll(graph) + println("Test 4 issues: $issues") + + org.junit.Assert.assertFalse(issues.any { it.message?.contains("Missing source for pass-through connection") == true }) + } + + @Test + fun validateAll_downstreamNodeDisabled_reportsUnusedOutput() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setSourceNode(BrushBehavior.SourceNode.newBuilder().build()) + .build() + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")), + isDisabled = true + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, sourceNode), + edges = edges + ) + + val issues = GraphValidator.validateAll(graph) + + assertTrue(issues.any { it.nodeId == "tip" && it.displayMessage is DisplayText.Resource && it.displayMessage.resId == R.string.bg_err_unused_output }) + assertTrue(issues.any { it.nodeId == "paint" && it.displayMessage is DisplayText.Resource && it.displayMessage.resId == R.string.bg_err_unused_output }) + } + + @Test + fun convertIntoProto_startNodeReachedMultipleTimes_duplicatesSourceNode() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder() + .setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .build()) + .build() + ) + ) + + val dampingNodeA = GraphNode( + id = "dampingA", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setDampingNode(ink.proto.BrushBehavior.DampingNode.newBuilder().build()) + .build() + ) + ) + + val dampingNodeB = GraphNode( + id = "dampingB", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setDampingNode(ink.proto.BrushBehavior.DampingNode.newBuilder().build()) + .build() + ) + ) + + val binaryOpNode = GraphNode( + id = "binary_op", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setBinaryOpNode(ink.proto.BrushBehavior.BinaryOpNode.newBuilder().setOperation(ink.proto.BrushBehavior.BinaryOp.BINARY_OP_SUM)) + .build() + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(ink.proto.BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "dampingA", toPortId = "Input"), + GraphEdge(fromNodeId = "source", toNodeId = "dampingB", toPortId = "Input"), + GraphEdge(fromNodeId = "dampingA", toNodeId = "binary_op", toPortId = "input_0"), + GraphEdge(fromNodeId = "dampingB", toNodeId = "binary_op", toPortId = "input_1"), + GraphEdge(fromNodeId = "binary_op", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, binaryOpNode, dampingNodeA, dampingNodeB, sourceNode), + edges = edges + ) + + val brushFamily = try { + BrushFamilyConverter.convertIntoProto(graph) + } catch (e: GraphValidationException) { + println("Validation failed: ${e.message}") + throw e + } + + val tip = brushFamily.getCoats(0).tip + org.junit.Assert.assertEquals(1, tip.behaviorsCount) + val behavior = tip.getBehaviors(0) + + val sourceNodeCount = behavior.nodesList.count { it.hasSourceNode() } + org.junit.Assert.assertEquals(2, sourceNodeCount) + } + + @Test + fun convertIntoProto_interpolationNodeWithFullSetOfInputs_createsInterpolationNode() { + val valueNode = GraphNode( + id = "value", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val startNode = GraphNode( + id = "start", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setConstantNode(ink.proto.BrushBehavior.ConstantNode.newBuilder().setValue(0f).build()) + .build() + ) + ) + + val endNode = GraphNode( + id = "end", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setConstantNode(ink.proto.BrushBehavior.ConstantNode.newBuilder().setValue(1f).build()) + .build() + ) + ) + + val lerpNode = GraphNode( + id = "lerp", + data = NodeData.Behavior( + node = ink.proto.BrushBehavior.Node.newBuilder() + .setInterpolationNode(ink.proto.BrushBehavior.InterpolationNode.newBuilder().setInterpolation(ink.proto.BrushBehavior.Interpolation.INTERPOLATION_LERP).build()) + .build(), + inputPortIds = listOf("Value", "Start", "End") + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "value", toNodeId = "lerp", toPortId = "Value"), + GraphEdge(fromNodeId = "start", toNodeId = "lerp", toPortId = "Start"), + GraphEdge(fromNodeId = "end", toNodeId = "lerp", toPortId = "End"), + GraphEdge(fromNodeId = "lerp", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, lerpNode, valueNode, startNode, endNode), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val tip = brushFamily.getCoats(0).tip + val behavior = tip.getBehaviors(0) + + org.junit.Assert.assertEquals(5, behavior.nodesCount) + org.junit.Assert.assertTrue(behavior.getNodes(3).hasInterpolationNode()) + } + + @Test + fun convertIntoProto_coatWithMultiplePaints_createsMultiplePaints() { + val valueNode = GraphNode( + id = "value", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode1 = GraphNode( + id = "paint1", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val paintNode2 = GraphNode( + id = "paint2", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0", "paint_1")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "value", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "Input"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint1", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "paint2", toNodeId = "coat", toPortId = "paint_1"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode1, paintNode2, targetNode, valueNode), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val coat = brushFamily.getCoats(0) + org.junit.Assert.assertEquals(2, coat.paintPreferencesCount) + } + + @Test + fun convertIntoProto_tipWithMultipleBehaviors_createsMultipleBehaviors() { + val valueNode1 = GraphNode( + id = "value1", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val targetNode1 = GraphNode( + id = "target1", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val valueNode2 = GraphNode( + id = "value2", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_TILT_IN_RADIANS).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val targetNode2 = GraphNode( + id = "target2", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("0", "1")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "value1", toNodeId = "target1", toPortId = "Input"), + GraphEdge(fromNodeId = "target1", toNodeId = "tip", toPortId = "0"), + GraphEdge(fromNodeId = "value2", toNodeId = "target2", toPortId = "Input"), + GraphEdge(fromNodeId = "target2", toNodeId = "tip", toPortId = "1"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode1, targetNode2, valueNode1, valueNode2), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val tip = brushFamily.getCoats(0).tip + org.junit.Assert.assertEquals(2, tip.behaviorsCount) + } + + @Test + fun convertIntoProto_binaryOpWithMultipleInputs_chainsInputs() { + val sourceNode1 = GraphNode( + id = "source1", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val sourceNode2 = GraphNode( + id = "source2", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val sourceNode3 = GraphNode( + id = "source3", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val binaryOpNode = GraphNode( + id = "binOp", + data = NodeData.Behavior( + node = ink.proto.BrushBehavior.Node.newBuilder() + .setBinaryOpNode(ink.proto.BrushBehavior.BinaryOpNode.newBuilder().build()) + .build(), + inputPortIds = listOf("input_0", "input_1", "input_2") + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("Input")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source1", toNodeId = "binOp", toPortId = "input_0"), + GraphEdge(fromNodeId = "source2", toNodeId = "binOp", toPortId = "input_1"), + GraphEdge(fromNodeId = "source3", toNodeId = "binOp", toPortId = "input_2"), + GraphEdge(fromNodeId = "binOp", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "Input"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, binaryOpNode, sourceNode1, sourceNode2, sourceNode3), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val coat = brushFamily.getCoats(0) + val tip = coat.tip + + org.junit.Assert.assertEquals(1, tip.behaviorsCount) + val behavior = tip.getBehaviors(0) + org.junit.Assert.assertEquals(6, behavior.nodesCount) + + org.junit.Assert.assertTrue(behavior.getNodes(0).hasSourceNode()) + org.junit.Assert.assertTrue(behavior.getNodes(1).hasSourceNode()) + org.junit.Assert.assertTrue(behavior.getNodes(2).hasBinaryOpNode()) + org.junit.Assert.assertTrue(behavior.getNodes(3).hasSourceNode()) + org.junit.Assert.assertTrue(behavior.getNodes(4).hasBinaryOpNode()) + org.junit.Assert.assertTrue(behavior.getNodes(5).hasTargetNode()) + } + + @Test + fun convertIntoProto_passThroughWithMultipleInputs_propagatesInputs() { + val source1 = GraphNode( + id = "source1", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val source2 = GraphNode( + id = "source2", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val disabledResponse = GraphNode( + id = "disabled_response", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setBinaryOpNode(ink.proto.BrushBehavior.BinaryOpNode.newBuilder().build()) + .build() + ), + isDisabled = true + ) + + val target = GraphNode( + id = "target", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(ink.proto.BrushBehavior.TargetNode.newBuilder().build()) + .build() + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("Input")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source1", toNodeId = "disabled_response", toPortId = "input_0"), + GraphEdge(fromNodeId = "source2", toNodeId = "disabled_response", toPortId = "input_1"), + GraphEdge(fromNodeId = "disabled_response", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "Input"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, target, disabledResponse, source1, source2), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val coat = brushFamily.getCoats(0) + val tip = coat.tip + + org.junit.Assert.assertEquals(2, tip.behaviorsCount) + + val behavior1 = tip.getBehaviors(0) + val behavior2 = tip.getBehaviors(1) + + org.junit.Assert.assertEquals(2, behavior1.nodesCount) + org.junit.Assert.assertEquals(2, behavior2.nodesCount) + + org.junit.Assert.assertTrue(behavior1.getNodes(0).hasSourceNode()) + org.junit.Assert.assertTrue(behavior1.getNodes(1).hasTargetNode()) + + org.junit.Assert.assertTrue(behavior2.getNodes(0).hasSourceNode()) + org.junit.Assert.assertTrue(behavior2.getNodes(1).hasTargetNode()) + } + + @Test + fun convertIntoProto_targetNodeWithComment_serializesCommentToBehavior() { + val sourceNode = GraphNode( + id = "source", + data = NodeData.Behavior( + ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f).build()) + .build() + ) + ) + + val targetNode = GraphNode( + id = "target", + data = NodeData.Behavior( + node = ink.proto.BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build(), + developerComment = "Target Metadata Comment" + ) + ) + + val tipNode = GraphNode( + id = "tip", + data = NodeData.Tip(tip = ink.proto.BrushTip.newBuilder().build(), behaviorPortIds = listOf("behavior_0")) + ) + + val paintNode = GraphNode( + id = "paint", + data = NodeData.Paint(ink.proto.BrushPaint.newBuilder().build()) + ) + + val coatNode = GraphNode( + id = "coat", + data = NodeData.Coat(paintPortIds = listOf("paint_0")) + ) + + val familyNode = GraphNode( + id = "family", + data = NodeData.Family(coatPortIds = listOf("coat_0")) + ) + + val edges = listOf( + GraphEdge(fromNodeId = "source", toNodeId = "target", toPortId = "Input"), + GraphEdge(fromNodeId = "target", toNodeId = "tip", toPortId = "behavior_0"), + GraphEdge(fromNodeId = "tip", toNodeId = "coat", toPortId = "tip"), + GraphEdge(fromNodeId = "paint", toNodeId = "coat", toPortId = "paint_0"), + GraphEdge(fromNodeId = "coat", toNodeId = "family", toPortId = "coat_0") + ) + + val graph = BrushGraph( + nodes = listOf(familyNode, coatNode, tipNode, paintNode, targetNode, sourceNode), + edges = edges + ) + + val brushFamily = BrushFamilyConverter.convertIntoProto(graph) + + val tip = brushFamily.getCoats(0).tip + org.junit.Assert.assertEquals(1, tip.behaviorsCount) + org.junit.Assert.assertEquals("Target Metadata Comment", tip.getBehaviors(0).developerComment) + } +} diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverterTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverterTest.kt new file mode 100644 index 00000000..194147cd --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverterTest.kt @@ -0,0 +1,260 @@ +/* + * * 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.data + +import com.example.cahier.developer.brushgraph.data.BrushGraphConverter +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.NodeData +import ink.proto.BrushBehavior +import ink.proto.BrushTip +import ink.proto.BrushPaint +import ink.proto.BrushFamily +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import com.example.cahier.R +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +class BrushGraphConverterTest { + + @Test + fun fromProtoBrushFamily_identicalNodes_areDeduplicated() { + val behaviorProto = BrushBehavior.newBuilder() + .addNodes(BrushBehavior.Node.newBuilder().setSourceNode(BrushBehavior.SourceNode.newBuilder().setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).build()).build()) + .addNodes(BrushBehavior.Node.newBuilder().setTargetNode(BrushBehavior.TargetNode.newBuilder().build()).build()) + .addNodes(BrushBehavior.Node.newBuilder().setSourceNode(BrushBehavior.SourceNode.newBuilder().setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).build()).build()) + .addNodes(BrushBehavior.Node.newBuilder().setTargetNode(BrushBehavior.TargetNode.newBuilder().build()).build()) + .build() + + val tipProto = BrushTip.newBuilder() + .addBehaviors(behaviorProto) + .build() + + val familyProto = BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().setTip(tipProto).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val behaviorNodes = graph.nodes.filter { it.data is NodeData.Behavior } + assertEquals(2, behaviorNodes.size) + } + @Test + fun fromProtoBrushFamily_identicalNodesAcrossBehaviors_areDeduplicated() { + val behaviorProto1 = BrushBehavior.newBuilder() + .addNodes(BrushBehavior.Node.newBuilder().setSourceNode(BrushBehavior.SourceNode.newBuilder().setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).build()).build()) + .addNodes(BrushBehavior.Node.newBuilder().setTargetNode(BrushBehavior.TargetNode.newBuilder().build()).build()) + .build() + + val behaviorProto2 = BrushBehavior.newBuilder() + .addNodes(BrushBehavior.Node.newBuilder().setSourceNode(BrushBehavior.SourceNode.newBuilder().setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).build()).build()) + .addNodes(BrushBehavior.Node.newBuilder().setTargetNode(BrushBehavior.TargetNode.newBuilder().build()).build()) + .build() + + val tipProto = BrushTip.newBuilder() + .addBehaviors(behaviorProto1) + .addBehaviors(behaviorProto2) + .build() + + val familyProto = BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().setTip(tipProto).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val behaviorNodes = graph.nodes.filter { it.data is NodeData.Behavior } + assertEquals(2, behaviorNodes.size) + + val tipNode = graph.nodes.find { it.data is NodeData.Tip }!! + val edgesToTip = graph.edges.filter { it.toNodeId == tipNode.id } + assertEquals(1, edgesToTip.size) + + val tipData = tipNode.data as NodeData.Tip + assertEquals(1, tipData.behaviorPortIds.size) + } + + @Test + fun fromProtoBrushFamily_polarTargetNodes_areDeduplicated() { + val sourceNode = ink.proto.BrushBehavior.Node.newBuilder() + .setSourceNode(ink.proto.BrushBehavior.SourceNode.newBuilder().setSource(ink.proto.BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE).build()) + .build() + + val polarTargetNode = ink.proto.BrushBehavior.Node.newBuilder() + .setPolarTargetNode(ink.proto.BrushBehavior.PolarTargetNode.newBuilder().build()) + .build() + + val behaviorProto1 = ink.proto.BrushBehavior.newBuilder() + .addNodes(sourceNode) + .addNodes(sourceNode) + .addNodes(polarTargetNode) + .build() + + val tipProto = ink.proto.BrushTip.newBuilder() + .addBehaviors(behaviorProto1) + .addBehaviors(behaviorProto1) + .build() + + val familyProto = ink.proto.BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().setTip(tipProto).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val behaviorNodes = graph.nodes.filter { it.data is NodeData.Behavior } + assertEquals(2, behaviorNodes.size) + } + @Test + fun fromProtoBrushFamily_textureLayerNodes_areDeduplicated() { + val textureLayer1 = BrushPaint.TextureLayer.newBuilder() + .setClientTextureId("texture_1") + .build() + + val paintProto1 = BrushPaint.newBuilder() + .addTextureLayers(textureLayer1) + .build() + + val paintProto2 = BrushPaint.newBuilder() + .addTextureLayers(textureLayer1) + .build() + + val familyProto = BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().addPaintPreferences(paintProto1).addPaintPreferences(paintProto2).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val textureNodes = graph.nodes.filter { it.data is NodeData.TextureLayer } + assertEquals(1, textureNodes.size) + } + + @Test + fun fromProtoBrushFamily_colorFunctionNodes_areDeduplicated() { + val colorFunction1 = ink.proto.ColorFunction.getDefaultInstance() + + val paintProto1 = BrushPaint.newBuilder() + .addColorFunctions(colorFunction1) + .build() + + val paintProto2 = BrushPaint.newBuilder() + .addColorFunctions(colorFunction1) + .build() + + val familyProto = BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().addPaintPreferences(paintProto1).addPaintPreferences(paintProto2).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val colorNodes = graph.nodes.filter { it.data is NodeData.ColorFunction } + assertEquals(1, colorNodes.size) + } + + @Test + fun fromProtoBrushFamily_allCustomBrushesRoundTrip_preservesContent() { + val brushResources = listOf( + R.raw.calligraphy, + R.raw.flag_banner, + R.raw.graffiti, + R.raw.groovy, + R.raw.holiday_lights, + R.raw.lace, + R.raw.music, + R.raw.shadow, + R.raw.twisted_yarn, + R.raw.wet_paint + ) + + for (resId in brushResources) { + val stream = RuntimeEnvironment.getApplication().resources.openRawResource(resId) + val gis = java.util.zip.GZIPInputStream(stream) + val originalProto = ink.proto.BrushFamily.parseFrom(gis) + + val graph = BrushGraphConverter.fromProtoBrushFamily(originalProto) + val roundTrippedProto = BrushFamilyConverter.convertIntoProto(graph) + + val resName = RuntimeEnvironment.getApplication().resources.getResourceEntryName(resId) + + // Won't be identical, but we check for rough functional equivalency + assertEquals("Coats count mismatch for $resName", originalProto.coatsCount, roundTrippedProto.coatsCount) + assertEquals("Client ID mismatch for $resName", originalProto.clientBrushFamilyId, roundTrippedProto.clientBrushFamilyId) + + for (i in 0 until originalProto.coatsCount) { + val originalCoat = originalProto.getCoats(i) + val roundTrippedCoat = roundTrippedProto.getCoats(i) + + val originalTargets = collectTargets(originalCoat.tip) + val roundTrippedTargets = collectTargets(roundTrippedCoat.tip) + + assertEquals("Targets mismatch for brush resource $resName coat $i", originalTargets, roundTrippedTargets) + + assertEquals("Paint preferences mismatch for brush resource $resName coat $i", originalCoat.paintPreferencesList, roundTrippedCoat.paintPreferencesList) + } + } + } + + @Test + fun fromProtoBrushFamily_targetNodesWithDifferentComments_areNotDeduplicated() { + val targetNodeProto = BrushBehavior.Node.newBuilder() + .setTargetNode(BrushBehavior.TargetNode.newBuilder().build()) + .build() + + val behaviorProto1 = BrushBehavior.newBuilder() + .addNodes(targetNodeProto) + .setDeveloperComment("Comment A") + .build() + + val behaviorProto2 = BrushBehavior.newBuilder() + .addNodes(targetNodeProto) + .setDeveloperComment("Comment B") + .build() + + val tipProto = BrushTip.newBuilder() + .addBehaviors(behaviorProto1) + .addBehaviors(behaviorProto2) + .build() + + val familyProto = BrushFamily.newBuilder() + .addCoats(ink.proto.BrushCoat.newBuilder().setTip(tipProto).build()) + .build() + + val graph = BrushGraphConverter.fromProtoBrushFamily(familyProto) + + val behaviorNodes = graph.nodes.filter { it.data is NodeData.Behavior } + assertEquals(2, behaviorNodes.size) + assertTrue(behaviorNodes.any { (it.data as NodeData.Behavior).developerComment == "Comment A" }) + assertTrue(behaviorNodes.any { (it.data as NodeData.Behavior).developerComment == "Comment B" }) + } + + private fun collectTargets(tip: ink.proto.BrushTip): Set { + val targets = mutableSetOf() + for (behavior in tip.behaviorsList) { + for (node in behavior.nodesList) { + if (node.hasTargetNode()) { + targets.add(node.targetNode.toString().trim()) + } else if (node.hasPolarTargetNode()) { + targets.add(node.polarTargetNode.toString().trim()) + } + } + } + return targets + } +} diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt new file mode 100644 index 00000000..3ae1756b --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepositoryTest.kt @@ -0,0 +1,312 @@ +package com.example.cahier.developer.brushgraph.data + +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.developer.brushdesigner.data.FakeCustomBrushDao +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import ink.proto.BrushBehavior as ProtoBrushBehavior +import ink.proto.BrushTip as ProtoBrushTip + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class BrushGraphRepositoryTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var fakeDao: FakeCustomBrushDao + private lateinit var mockTextureStore: CahierTextureBitmapStore + private lateinit var repository: DefaultBrushGraphRepository + private lateinit var repoScope: kotlinx.coroutines.CoroutineScope + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + fakeDao = FakeCustomBrushDao() + mockTextureStore = mock(CahierTextureBitmapStore::class.java) + org.mockito.Mockito.`when`(mockTextureStore.generation).thenReturn(MutableStateFlow(0)) + repoScope = kotlinx.coroutines.CoroutineScope(testDispatcher + Job()) + repository = DefaultBrushGraphRepository(fakeDao, mockTextureStore, repoScope) + } + + @After + fun tearDown() { + repoScope.cancel() + Dispatchers.resetMain() + } + + @Test + fun initialState_isDefaultGraph() = testScope.runTest { + val graph = repository.graph.first() + assertNotNull(graph) + assertTrue(graph.nodes.any { it.data is NodeData.Family }) + } + + @Test + fun addNode_updatesGraph() = testScope.runTest { + val initialNodeCount = repository.graph.first().nodes.size + + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + val nodeId = repository.addNode(nodeData) + + val updatedGraph = repository.graph.first() + assertEquals(initialNodeCount + 1, updatedGraph.nodes.size) + assertTrue(updatedGraph.nodes.any { it.id == nodeId }) + } + + @Test + fun deleteNode_updatesGraph() = testScope.runTest { + val initialNodeCount = repository.graph.first().nodes.size + + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + val nodeId = repository.addNode(nodeData) + + val graphAfterAdd = repository.graph.first() + assertEquals(initialNodeCount + 1, graphAfterAdd.nodes.size) + assertTrue(graphAfterAdd.nodes.any { it.id == nodeId }) + + repository.deleteNode(nodeId) + + val graphAfterDelete = repository.graph.first() + assertEquals(initialNodeCount, graphAfterDelete.nodes.size) + assertFalse(graphAfterDelete.nodes.any { it.id == nodeId }) + } + + @Test + fun validate_detectsWarnings() = testScope.runTest { + val behaviorNode = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.getDefaultInstance()) + .build(), + inputPortIds = listOf("input1", "input2") + ) + repository.addNode(behaviorNode) + + // Orphaned nodes result in warnings, not errors, so the graph is still technically valid. + assertTrue(repository.validate()) + + val issues = repository.graphIssues.first() + assertTrue(issues.any { it.severity == ValidationSeverity.WARNING }) + } + + @Test + fun validate_detectsErrors() = testScope.runTest { + val coatId = repository.graph.first().nodes.find { it.data is NodeData.Coat } + repository.deleteNode(coatId?.id!!) + + // No coat on the family is an error + assertFalse(repository.validate()) + + val issues = repository.graphIssues.first() + assertTrue(issues.any { it.severity == ValidationSeverity.ERROR }) + } + + @Test + fun setGraph_updatesGraph() = testScope.runTest { + val newGraph = BrushGraph(nodes = listOf(GraphNode(id = "node1", data = NodeData.Family()))) + repository.setGraph(newGraph) + assertEquals(newGraph, repository.graph.first()) + } + + @Test + fun clearGraph_resetsToDefault() = testScope.runTest { + assertTrue(repository.graph.first().nodes.size > 1) + + val newGraph = BrushGraph(nodes = listOf(GraphNode(id = "node1", data = NodeData.Family()))) + repository.setGraph(newGraph) + + assertEquals(repository.graph.first().nodes.size, 1) + + repository.clearGraph() + + val graph = repository.graph.first() + assertTrue(graph.nodes.size > 1) + } + + @Test + fun postDebug_addsIssue() = testScope.runTest { + assertTrue(repository.graphIssues.first().isEmpty()) + + val text = DisplayText.Literal("debug message") + repository.postDebug(text) + + val issues = repository.graphIssues.first() + assertTrue(issues.any { it.displayMessage == text && it.severity == ValidationSeverity.DEBUG }) + } + + @Test + fun clearIssues_removesIssues() = testScope.runTest { + repository.postDebug(DisplayText.Literal("debug")) + assertTrue(repository.graphIssues.first().isNotEmpty()) + + repository.clearIssues() + assertTrue(repository.graphIssues.first().isEmpty()) + } + + @Test + fun addEdge_updatesGraph() = testScope.runTest { + val node1 = repository.addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + val node2 = repository.addNode(NodeData.Coat()) + + repository.addEdge(node1, node2, "tip") + + val graph = repository.graph.first() + assertTrue(graph.edges.any { it.fromNodeId == node1 && it.toNodeId == node2 && it.toPortId == "tip" }) + } + + @Test + fun deleteEdge_updatesGraph() = testScope.runTest { + val node1 = repository.addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + val node2 = repository.addNode(NodeData.Coat()) + repository.addEdge(node1, node2, "tip") + + val edge = + repository.graph.first().edges.find { it.fromNodeId == node1 && it.toNodeId == node2 }!! + + assertTrue(repository.graph.first().edges.contains(edge)) + + repository.deleteEdge(edge) + + val graph = repository.graph.first() + assertFalse(graph.edges.contains(edge)) + } + + @Test + fun setEdgeDisabled_updatesGraph() = testScope.runTest { + val node1 = repository.addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + val node2 = repository.addNode(NodeData.Coat()) + repository.addEdge(node1, node2, "tip") + + val edge = + repository.graph.first().edges.find { it.fromNodeId == node1 && it.toNodeId == node2 }!! + repository.setEdgeDisabled(edge, true) + + val graph = repository.graph.first() + val updatedEdge = graph.edges.find { it.fromNodeId == node1 && it.toNodeId == node2 } + assertTrue(updatedEdge?.isDisabled == true) + } + + @Test + fun updateNodeData_updatesGraph() = testScope.runTest { + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + val nodeId = repository.addNode(nodeData) + + val newData = NodeData.Tip( + ProtoBrushTip.newBuilder().addBehaviors(ProtoBrushBehavior.getDefaultInstance()).build() + ) + repository.updateNodeData(nodeId, newData) + + val graph = repository.graph.first() + val node = graph.nodes.find { it.id == nodeId }!! + assertEquals(newData, node.data) + } + + @Test + fun setNodeDisabled_updatesGraph() = testScope.runTest { + val nodeData = NodeData.Tip(ProtoBrushTip.getDefaultInstance()) + val nodeId = repository.addNode(nodeData) + + repository.setNodeDisabled(nodeId, true) + + val graph = repository.graph.first() + val node = graph.nodes.find { it.id == nodeId }!! + assertTrue(node.isDisabled) + } + + @Test + fun deleteSelectedNodes_updatesGraph() = testScope.runTest { + val node1 = repository.addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + val node2 = repository.addNode(NodeData.Coat()) + + val initialCount = repository.graph.first().nodes.size + + repository.deleteSelectedNodes(setOf(node1, node2)) + + val graph = repository.graph.first() + assertEquals(initialCount - 2, graph.nodes.size) + assertFalse(graph.nodes.any { it.id == node1 || it.id == node2 }) + } + + @Test + fun duplicateSelectedNodes_updatesGraph() = testScope.runTest { + val node1 = repository.addNode(NodeData.Tip(ProtoBrushTip.getDefaultInstance())) + + val initialCount = repository.graph.first().nodes.size + + val idMap = repository.duplicateSelectedNodes(setOf(node1)) + + val graph = repository.graph.first() + assertEquals(initialCount + 1, graph.nodes.size) + assertTrue(idMap.containsKey(node1)) + val duplicatedId = idMap[node1]!! + assertTrue(graph.nodes.any { it.id == duplicatedId }) + } + + @Test + fun addNodeBetween_updatesGraph() = testScope.runTest { + val behavior1 = NodeData.Behavior(ProtoBrushBehavior.Node.getDefaultInstance()) + val behavior2 = NodeData.Behavior( + ProtoBrushBehavior.Node.getDefaultInstance(), + inputPortIds = listOf("input1") + ) + val node1 = repository.addNode(behavior1) + val node2 = repository.addNode(behavior2) + repository.addEdge(node1, node2, "input1") + + val edge = + repository.graph.first().edges.find { it.fromNodeId == node1 && it.toNodeId == node2 }!! + val newNodeId = repository.addNodeBetween(edge) + + assertNotNull(newNodeId) + val graph = repository.graph.first() + + val edge1 = graph.edges.find { it.fromNodeId == node1 && it.toNodeId == newNodeId } + val edge2 = graph.edges.find { it.fromNodeId == newNodeId && it.toNodeId == node2 } + assertNotNull(edge1) + assertNotNull(edge2) + assertFalse(graph.edges.contains(edge)) + } + + @Test + fun reorderPorts_updatesGraph() = testScope.runTest { + val familyNode = repository.graph.first().nodes.find { it.data is NodeData.Family }!! + + val coat1 = repository.addNode(NodeData.Coat()) + val coat2 = repository.addNode(NodeData.Coat()) + + repository.addEdge(coat1, familyNode.id, "add_coat") + repository.addEdge(coat2, familyNode.id, "add_coat") + + val updatedFamilyNode = repository.graph.first().nodes.find { it.id == familyNode.id }!! + val updatedData = updatedFamilyNode.data as NodeData.Family + val portIds = updatedData.coatPortIds + assertEquals(3, portIds.size) + + repository.reorderPorts(familyNode.id, 0, 1) + + val reorderedFamilyNode = repository.graph.first().nodes.find { it.id == familyNode.id }!! + val reorderedData = reorderedFamilyNode.data as NodeData.Family + assertEquals(portIds[1], reorderedData.coatPortIds[0]) + assertEquals(portIds[0], reorderedData.coatPortIds[1]) + } + +} diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/ui/NodeFieldsTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/ui/NodeFieldsTest.kt new file mode 100644 index 00000000..371fe082 --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/ui/NodeFieldsTest.kt @@ -0,0 +1,109 @@ +/* + * * 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 ink.proto.BrushBehavior as ProtoBrushBehavior +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse +import org.junit.Test +import com.example.cahier.developer.brushgraph.ui.fields.SOURCES_INPUT +import com.example.cahier.developer.brushgraph.ui.fields.SOURCES_MOVEMENT +import com.example.cahier.developer.brushgraph.ui.fields.SOURCES_DISTANCE +import com.example.cahier.developer.brushgraph.ui.fields.SOURCES_TIME +import com.example.cahier.developer.brushgraph.ui.fields.SOURCES_ACCELERATION +import com.example.cahier.developer.brushgraph.ui.fields.TARGETS_SIZE_SHAPE +import com.example.cahier.developer.brushgraph.ui.fields.TARGETS_POSITION +import com.example.cahier.developer.brushgraph.ui.fields.TARGETS_COLOR_OPACITY +import com.example.cahier.developer.brushgraph.ui.fields.NODE_TYPES_START +import com.example.cahier.developer.brushgraph.ui.fields.NODE_TYPES_OPERATOR +import com.example.cahier.developer.brushgraph.ui.fields.NODE_TYPES_TERMINAL +import com.example.cahier.developer.brushgraph.ui.fields.isAngle + +class NodeFieldsTest { + + @Test + fun sources_allValues_areCategorized() { + val allSources = ProtoBrushBehavior.Source.values() + .filter { it != ProtoBrushBehavior.Source.SOURCE_UNSPECIFIED && it.ordinal >= 0 } + .toSet() + + val categorizedSources = ( + SOURCES_INPUT + + SOURCES_MOVEMENT + + SOURCES_DISTANCE + + SOURCES_TIME + + SOURCES_ACCELERATION + ).toSet() + + assertEquals("Not all sources are accounted for!", allSources, categorizedSources) + } + + @Test + fun targets_allValues_areCategorized() { + val allTargets = ProtoBrushBehavior.Target.values() + .filter { it != ProtoBrushBehavior.Target.TARGET_UNSPECIFIED && it.ordinal >= 0 } + .toSet() + + val categorizedTargets = ( + TARGETS_SIZE_SHAPE + + TARGETS_POSITION + + TARGETS_COLOR_OPACITY + ).toSet() + + assertEquals("Not all targets are accounted for!", allTargets, categorizedTargets) + } + + @Test + fun nodeTypes_allValues_areCategorized() { + val allBehaviorNodes = listOf( + "Source", "Constant", "Noise", "ToolTypeFilter", "Damping", + "Response", "Integral", "BinaryOp", "Interpolation", "Target", "PolarTarget" + ).toSet() + + val categorizedNodeTypes = ( + NODE_TYPES_START + + NODE_TYPES_OPERATOR + + NODE_TYPES_TERMINAL + ).map { it.name.removeSuffix("_NODE").split("_").joinToString("") { part -> part.lowercase().replaceFirstChar { it.uppercase() } } }.toSet() + + assertEquals("Not all behavior node types are accounted for!", allBehaviorNodes, categorizedNodeTypes) + } + + @Test + fun source_isAngle_returnsTrueForAngleSources() { + assertTrue(ProtoBrushBehavior.Source.SOURCE_TILT_IN_RADIANS.isAngle()) + assertTrue(ProtoBrushBehavior.Source.SOURCE_TILT_X_IN_RADIANS.isAngle()) + assertTrue(ProtoBrushBehavior.Source.SOURCE_TILT_Y_IN_RADIANS.isAngle()) + assertTrue(ProtoBrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS.isAngle()) + assertTrue(ProtoBrushBehavior.Source.SOURCE_ORIENTATION_IN_RADIANS.isAngle()) + assertTrue(ProtoBrushBehavior.Source.SOURCE_DIRECTION_ABOUT_ZERO_IN_RADIANS.isAngle()) + assertTrue(ProtoBrushBehavior.Source.SOURCE_ORIENTATION_ABOUT_ZERO_IN_RADIANS.isAngle()) + + assertFalse(ProtoBrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE.isAngle()) + assertFalse(ProtoBrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND.isAngle()) + } + + @Test + fun target_isAngle_returnsTrueForAngleTargets() { + assertTrue(ProtoBrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS.isAngle()) + assertTrue(ProtoBrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS.isAngle()) + assertTrue(ProtoBrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS.isAngle()) + + assertFalse(ProtoBrushBehavior.Target.TARGET_WIDTH_MULTIPLIER.isAngle()) + assertFalse(ProtoBrushBehavior.Target.TARGET_OPACITY_MULTIPLIER.isAngle()) + } +} diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/ui/TooltipsTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/ui/TooltipsTest.kt new file mode 100644 index 00000000..41556a75 --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/ui/TooltipsTest.kt @@ -0,0 +1,236 @@ +/* + * * 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 com.example.cahier.developer.brushgraph.data.NodeData +import ink.proto.BrushBehavior +import ink.proto.BrushPaint +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction +import ink.proto.StepPosition as ProtoStepPosition +import com.example.cahier.R +import org.junit.Assert.assertTrue +import org.junit.Test + +class TooltipsTest { + + /** Set of tests that simply verifies that all the values for various types have tooltips written for them. */ + + @Test + fun nodeDataTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + val nodes = listOf( + NodeData.Tip(ink.proto.BrushTip.getDefaultInstance()), + NodeData.Paint(ink.proto.BrushPaint.getDefaultInstance()), + NodeData.TextureLayer(ink.proto.BrushPaint.TextureLayer.getDefaultInstance()), + NodeData.ColorFunction(ink.proto.ColorFunction.getDefaultInstance()), + NodeData.Coat(), + NodeData.Family(), + + // Behavior nodes + NodeData.Behavior(BrushBehavior.Node.newBuilder().setSourceNode(BrushBehavior.SourceNode.getDefaultInstance()).build()), + NodeData.Behavior(BrushBehavior.Node.newBuilder().setConstantNode(BrushBehavior.ConstantNode.getDefaultInstance()).build()), + NodeData.Behavior(BrushBehavior.Node.newBuilder().setNoiseNode(BrushBehavior.NoiseNode.getDefaultInstance()).build()), + NodeData.Behavior(BrushBehavior.Node.newBuilder().setToolTypeFilterNode(BrushBehavior.ToolTypeFilterNode.getDefaultInstance()).build()), + NodeData.Behavior(BrushBehavior.Node.newBuilder().setDampingNode(BrushBehavior.DampingNode.getDefaultInstance()).build()), + NodeData.Behavior(BrushBehavior.Node.newBuilder().setResponseNode(BrushBehavior.ResponseNode.getDefaultInstance()).build()), + NodeData.Behavior(BrushBehavior.Node.newBuilder().setBinaryOpNode(BrushBehavior.BinaryOpNode.getDefaultInstance()).build()), + NodeData.Behavior(BrushBehavior.Node.newBuilder().setInterpolationNode(BrushBehavior.InterpolationNode.getDefaultInstance()).build()), + NodeData.Behavior(BrushBehavior.Node.newBuilder().setIntegralNode(BrushBehavior.IntegralNode.getDefaultInstance()).build()), + NodeData.Behavior(BrushBehavior.Node.newBuilder().setTargetNode(BrushBehavior.TargetNode.getDefaultInstance()).build()), + NodeData.Behavior(BrushBehavior.Node.newBuilder().setPolarTargetNode(BrushBehavior.PolarTargetNode.getDefaultInstance()).build()) + ) + + for (node in nodes) { + val tooltip = node.getTooltip() + assertTrue("Tooltip should be unique: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun sourceEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushBehavior.Source.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for Source.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun targetEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushBehavior.Target.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for Target.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun wrapEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushPaint.TextureLayer.Wrap.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for Wrap.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun sizeUnitEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushPaint.TextureLayer.SizeUnit.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for SizeUnit.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun originEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushPaint.TextureLayer.Origin.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for Origin.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun mappingEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushPaint.TextureLayer.Mapping.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for Mapping.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun blendModeEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushPaint.TextureLayer.BlendMode.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for BlendMode.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun selfOverlapEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushPaint.SelfOverlap.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for SelfOverlap.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun polarTargetEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushBehavior.PolarTarget.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for PolarTarget.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun outOfRangeEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushBehavior.OutOfRange.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for OutOfRange.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun binaryOpEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushBehavior.BinaryOp.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for BinaryOp.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun progressDomainEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushBehavior.ProgressDomain.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for ProgressDomain.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun interpolationEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushBehavior.Interpolation.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for Interpolation.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun predefinedEasingFunctionEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in ProtoPredefinedEasingFunction.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for ProtoPredefinedEasingFunction.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun stepPositionEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in ProtoStepPosition.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for ProtoStepPosition.$value: $tooltip", tooltips.add(tooltip)) + } + } + + @Test + fun inputModelTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + val models = arrayOf( + R.string.bg_model_sliding_window, + R.string.bg_model_passthrough + ) + for (modelResId in models) { + val tooltip = getInputModelTooltip(modelResId) + assertTrue("Tooltip should be unique for InputModel.$modelResId: $tooltip", tooltips.add(tooltip)) + } + } + @Test + fun colorFunctionTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + val options = arrayOf( + R.string.bg_opacity_multiplier, + R.string.bg_replace_color + ) + for (optionResId in options) { + val tooltip = getColorFunctionTooltip(optionResId) + assertTrue("Tooltip should be unique for ColorFunction.$optionResId: $tooltip", tooltips.add(tooltip)) + } + } +} diff --git a/app/src/testShared/java/com/example/cahier/developer/brushdesigner/data/FakeCustomBrushDao.kt b/app/src/testShared/java/com/example/cahier/developer/brushdesigner/data/FakeCustomBrushDao.kt new file mode 100644 index 00000000..285617fa --- /dev/null +++ b/app/src/testShared/java/com/example/cahier/developer/brushdesigner/data/FakeCustomBrushDao.kt @@ -0,0 +1,37 @@ +package com.example.cahier.developer.brushdesigner.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flowOf + +class FakeCustomBrushDao : CustomBrushDao { + private val brushes = mutableMapOf() + private val autoSaveFlow = MutableStateFlow(null) + + override fun getAllCustomBrushes(autosaveKey: String): Flow> { + return flowOf(brushes.values.filter { it.name != autosaveKey }) + } + + override fun getAutoSaveBrush(autosaveKey: String): Flow { + return autoSaveFlow.asStateFlow() + } + + override suspend fun saveCustomBrush(brush: CustomBrushEntity) { + brushes[brush.name] = brush + if (brush.name == "__autosave__") { + autoSaveFlow.value = brush + } + } + + override suspend fun deleteCustomBrush(name: String) { + brushes.remove(name) + if (name == "__autosave__") { + autoSaveFlow.value = null + } + } + + override suspend fun getAllCustomBrushesSync(autosaveKey: String): List { + return brushes.values.filter { it.name != autosaveKey } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb643ac4..6c223bc0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,6 +102,8 @@ roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi roborazzi-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" } protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" } compose-color-picker-android = { module = "com.godaddy.android.colorpicker:compose-color-picker-android", version.ref = "composeColorPickerAndroid" } +androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }