diff --git a/.gitignore b/.gitignore index 40301836..9898f10d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ .cxx local.properties ink-proto/bin/ +gradle/gradle-daemon-jvm.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 132c61f3..3937ad87 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -82,6 +82,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/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt b/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt index d5b8f12b..58177c89 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 @@ -112,6 +112,8 @@ class BrushDesignerViewModelTest { viewModel.saveToPalette(brushName).join() + kotlinx.coroutines.delay(500) + val savedBrushes = customBrushDao.getAllCustomBrushes().first() assertTrue(savedBrushes.any { it.name == brushName }) } 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..a6b858b0 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 @@ -34,11 +34,26 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import javax.inject.Qualifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@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 { 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..6f52541c 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,11 @@ import androidx.annotation.DrawableRes import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.ink.brush.TextureBitmapStore import com.example.cahier.R +import javax.inject.Inject +import dagger.hilt.android.qualifiers.ApplicationContext @OptIn(ExperimentalInkCustomBrushApi::class) -class CahierTextureBitmapStore(context: Context) : TextureBitmapStore { +class CahierTextureBitmapStore @Inject constructor(@ApplicationContext context: Context) : TextureBitmapStore { private val resources = context.resources private val textureResources: Map = mapOf( @@ -43,6 +45,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:") 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..315d9f7e 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,15 @@ 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") + 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/BehaviorsTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt new file mode 100644 index 00000000..3b0f0edb --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt @@ -0,0 +1,284 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Arrangement +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.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import com.example.cahier.developer.brushdesigner.viewmodel.PrefabBehaviors +import ink.proto.BrushBehavior +import ink.proto.BrushFamily as ProtoBrushFamily + +/** + * Tab 2: Dynamics & Behaviors controls — editable behavior stack with nested + * node graph editor, standard dynamics presets, and advanced dynamics presets. + * + * Uses [EditableListWidget] for managing behaviors (outer list) and nodes + * within each behavior (inner nested list), with [NodeEditor] for + * type-specific node editing. + * + * Stateless: receives data and callbacks, does not access ViewModel. + */ +@Composable +internal fun BehaviorsTabContent( + activeProto: ProtoBrushFamily, + selectedCoatIndex: Int, + onUpdateBehaviors: (List) -> Unit, + onClearBehaviors: () -> Unit, + onAddBehavior: (List) -> Unit, +) { + val behaviors = activeProto + .coatsList.getOrNull(selectedCoatIndex)?.tip?.behaviorsList + ?: emptyList() + + Text( + stringResource(R.string.brush_designer_dynamics_behaviors), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + + EditableListWidget( + title = stringResource(R.string.brush_designer_behavior_stack), + items = behaviors, + defaultItem = BrushBehavior.newBuilder() + .addNodes( + BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceValueRangeStart(0f) + .setSourceValueRangeEnd(1f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ) + ) + .addNodes( + BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_SIZE_MULTIPLIER) + .setTargetModifierRangeStart(0.5f) + .setTargetModifierRangeEnd(1.5f) + ) + ) + .build(), + onItemsChanged = onUpdateBehaviors, + itemHeader = { behavior -> + val source = behavior.nodesList + .find { it.hasSourceNode() } + ?.sourceNode?.source?.name?.replace("SOURCE_", "") + ?: "INPUT" + val target = behavior.nodesList + .find { it.hasTargetNode() } + ?.targetNode?.target?.name?.replace("TARGET_", "") + ?: "OUTPUT" + "$source ➔ $target" + }, + editorContent = { behavior, onBehaviorChanged -> + BehaviorNodeGraphEditor( + behavior = behavior, + onBehaviorChanged = onBehaviorChanged + ) + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + StandardDynamicsSection(onAddBehavior = onAddBehavior) + + Spacer(modifier = Modifier.height(16.dp)) + + AdvancedDynamicsSection( + onAddBehavior = onAddBehavior + ) +} + +/** + * Nested node graph editor for a single [BrushBehavior]. + * Uses a nested [EditableListWidget] to manage the ordered list of nodes. + */ +@Composable +private fun BehaviorNodeGraphEditor( + behavior: BrushBehavior, + onBehaviorChanged: (BrushBehavior) -> Unit +) { + Text( + stringResource(R.string.brush_designer_nodes_in_behavior), + style = MaterialTheme.typography.labelLarge + ) + + EditableListWidget( + title = "", + items = behavior.nodesList, + defaultItem = BrushBehavior.Node.newBuilder() + .setConstantNode( + BrushBehavior.ConstantNode.newBuilder().setValue(1f) + ) + .build(), + onItemsChanged = { newNodes -> + onBehaviorChanged( + behavior.toBuilder().clearNodes().addAllNodes(newNodes).build() + ) + }, + itemHeader = { node -> + node.nodeCase.name.replace("_NODE", "") + }, + editorContent = { node, onNodeChanged -> + NodeEditor(node = node, onNodeChanged = onNodeChanged) + } + ) +} + +@Composable +private fun StandardDynamicsSection( + onAddBehavior: (List) -> Unit +) { + Text( + stringResource(R.string.brush_designer_standard_dynamics), + style = MaterialTheme.typography.labelLarge + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(modifier = Modifier.weight(1f), onClick = { + val s = BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) + .setSourceValueRangeStart(0f).setSourceValueRangeEnd(1f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ).build() + val t = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_SIZE_MULTIPLIER) + .setTargetModifierRangeStart(0.5f).setTargetModifierRangeEnd(1.5f) + ).build() + onAddBehavior(listOf(s, t)) + }) { Text(stringResource(R.string.brush_designer_pressure_size)) } + + Button(modifier = Modifier.weight(1f), onClick = { + val s = BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource( + BrushBehavior.Source + .SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND + ) + .setSourceValueRangeStart(0f).setSourceValueRangeEnd(50f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ).build() + val t = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_SIZE_MULTIPLIER) + .setTargetModifierRangeStart(0.2f).setTargetModifierRangeEnd(2.0f) + ).build() + onAddBehavior(listOf(s, t)) + }) { Text(stringResource(R.string.brush_designer_speed_size)) } + } + + Button(modifier = Modifier.fillMaxWidth(), onClick = { + val s = BrushBehavior.Node.newBuilder().setSourceNode( + BrushBehavior.SourceNode.newBuilder() + .setSource( + BrushBehavior.Source + .SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND + ) + .setSourceValueRangeStart(5f).setSourceValueRangeEnd(40f) + .setSourceOutOfRangeBehavior( + BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP + ) + ).build() + val t = BrushBehavior.Node.newBuilder().setTargetNode( + BrushBehavior.TargetNode.newBuilder() + .setTarget(BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER) + .setTargetModifierRangeStart(1.0f).setTargetModifierRangeEnd(0.1f) + ).build() + onAddBehavior(listOf(s, t)) + }) { Text(stringResource(R.string.brush_designer_speed_opacity)) } + } +} + +@Composable +private fun AdvancedDynamicsSection( + onAddBehavior: (List) -> Unit +) { + Text( + stringResource(R.string.brush_designer_advanced_dynamics), + style = MaterialTheme.typography.labelLarge + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = { onAddBehavior(PrefabBehaviors.pressureToSize()) } + ) { + Icon( + painterResource(R.drawable.brush_24px), + null, + Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.brush_designer_smooth_pressure_size)) + } + + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = { onAddBehavior(PrefabBehaviors.slantJitter()) } + ) { + Icon( + painterResource(R.drawable.texture_24px), + null, + Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.brush_designer_pencil_jitter)) + } + + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + onClick = { onAddBehavior(PrefabBehaviors.speedToOpacity()) } + ) { + Icon( + painterResource(R.drawable.opacity_24px), + null, + Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.brush_designer_smooth_speed_opacity)) + } + } +} 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..c8dd8bc7 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 @@ -42,6 +42,11 @@ import androidx.compose.ui.unit.dp import com.example.cahier.R import com.godaddy.android.colorpicker.ClassicColorPicker import com.godaddy.android.colorpicker.HsvColor +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.DropdownMenuItem /** * A reusable slider control for brush property editing. @@ -124,3 +129,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, + modifier: Modifier = Modifier, + displayName: @Composable (T) -> String, + onSelected: (T) -> Unit +) { + 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 0c6cce2f..4f6c1f1c 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 @@ -19,19 +19,42 @@ package com.example.cahier.developer.brushdesigner.ui import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetValue +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDragHandle import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold @@ -50,13 +73,16 @@ 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.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.ink.brush.ExperimentalInkCustomBrushApi import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.example.cahier.R import com.example.cahier.developer.brushdesigner.viewmodel.BrushDesignerViewModel +import ink.proto.BrushTip as ProtoBrushTip /** * Main entry point for the Brush Designer feature. @@ -92,6 +118,7 @@ fun BrushDesignerScreen( val savedBrushes by viewModel.savedPaletteBrushes.collectAsStateWithLifecycle() val activeProto by viewModel.activeBrushProto.collectAsStateWithLifecycle() + val selectedCoatIndex by viewModel.selectedCoatIndex.collectAsStateWithLifecycle() val activeBrush by viewModel.activeBrush.collectAsStateWithLifecycle() val testStrokes by viewModel.testStrokes.collectAsStateWithLifecycle() val brushColor by viewModel.brushColor.collectAsStateWithLifecycle() @@ -145,8 +172,11 @@ fun BrushDesignerScreen( scaffoldState = bottomSheetState, sheetPeekHeight = 200.dp, sheetContent = { - ControlsPlaceholder( - modifier = Modifier.fillMaxWidth() + ControlsPane( + modifier = Modifier.fillMaxWidth(), + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + viewModel = viewModel ) } ) { @@ -183,8 +213,11 @@ fun BrushDesignerScreen( ) }, listPane = { - ControlsPlaceholder( - modifier = Modifier.fillMaxSize() + ControlsPane( + modifier = Modifier.fillMaxSize(), + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + viewModel = viewModel ) }, detailPane = { @@ -212,23 +245,403 @@ fun BrushDesignerScreen( } /** - * Placeholder for the controls pane — will be replaced with the full - * tabbed editor (Tip Shape / Paint / Behaviors) in a follow-up PR. + * The controls panel containing coat management, metadata fields, + * input model selector, and tabbed content (Tip Shape / Paint / Behaviors). + * + * Note: Still accepts ViewModel for delegation to tab content and input model + * sections. All state (activeProto, selectedCoatIndex) is hoisted from the screen. */ +@OptIn(ExperimentalInkCustomBrushApi::class, ExperimentalMaterial3Api::class) @Composable -private fun ControlsPlaceholder(modifier: Modifier = Modifier) { +private fun ControlsPane( + modifier: Modifier = Modifier, + activeProto: ink.proto.BrushFamily, + selectedCoatIndex: Int, + viewModel: BrushDesignerViewModel +) { + var textFieldsLocked by remember { mutableStateOf(false) } + var selectedTab by remember { mutableStateOf(BrushDesignerTab.TipShape) } + + val currentTip = activeProto + .coatsList.getOrNull(selectedCoatIndex)?.tip ?: ProtoBrushTip.getDefaultInstance() + val inputModel = activeProto.inputModel + + var showTextureDialog by remember { mutableStateOf(false) } + var pendingTextureUri by remember { mutableStateOf(null) } + var textureIdInput by remember { mutableStateOf("") } + + val texturePickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + pendingTextureUri = it + showTextureDialog = true + } + } + + if (showTextureDialog) { + TextureNameDialog( + textureIdInput = textureIdInput, + onTextureIdChange = { textureIdInput = it }, + onConfirm = { + if (textureIdInput.isNotBlank() && pendingTextureUri != null) { + viewModel.addCustomTexture(pendingTextureUri!!, textureIdInput) + showTextureDialog = false + textureIdInput = "" + } + }, + onDismiss = { showTextureDialog = false } + ) + } + Column( - modifier = modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = stringResource(R.string.brush_designer_title), - style = MaterialTheme.typography.titleMedium + CoatLayersSection( + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + onSelectCoat = { viewModel.setSelectedCoat(it) }, + onAddCoat = { viewModel.addNewCoat() }, + onDeleteCoat = { viewModel.deleteSelectedCoat() } + ) + + HorizontalDivider() + 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) } + ) + + HorizontalDivider() + + InputModelSection( + inputModel = inputModel, + onUpdateInputModelToSpring = { viewModel.updateInputModelToSpring() }, + onUpdateInputModelToNaive = { viewModel.updateInputModelToNaive() }, + onUpdateSlidingWindowModel = { ms, + hz -> + viewModel.updateSlidingWindowModel(ms, hz) + } + ) + + HorizontalDivider() + + TabRow(selectedTabIndex = selectedTab.ordinal) { + BrushDesignerTab.entries.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { selectedTab = tab }, + text = { Text(stringResource(tab.labelResId)) } + ) + } + } + + when (selectedTab) { + BrushDesignerTab.TipShape -> TipShapeTabContent( + currentTip = currentTip, + activeBrush = viewModel.getActiveBrush(), + textureStore = viewModel.getTextureStore(), + onUpdateTip = { block -> viewModel.updateTip(block) } + ) + + BrushDesignerTab.Paint -> PaintTabContent( + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + onUpdatePaintPreferences = { viewModel.updatePaintPreferences(it) }, + onUpdateSelfOverlap = { viewModel.updateSelfOverlap(it) }, + texturePickerLauncher = texturePickerLauncher, + getTextureBitmap = { viewModel.getTextureBitmap(it) } + ) + + BrushDesignerTab.Behaviors -> BehaviorsTabContent( + activeProto = activeProto, + selectedCoatIndex = selectedCoatIndex, + onUpdateBehaviors = { viewModel.updateBehaviorsList(it) }, + onClearBehaviors = { viewModel.clearBehaviors() }, + onAddBehavior = { nodes -> viewModel.addBehavior(nodes) }, + ) + } + } +} + +@Composable +internal fun CoatLayersSection( + activeProto: ink.proto.BrushFamily, + selectedCoatIndex: Int, + onSelectCoat: (Int) -> Unit, + onAddCoat: () -> Unit, + onDeleteCoat: () -> Unit +) { + Text( + stringResource(R.string.brush_designer_brush_layers), + style = MaterialTheme.typography.titleMedium + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + activeProto.coatsList.forEachIndexed { index, _ -> + FilterChip( + selected = selectedCoatIndex == index, + onClick = { onSelectCoat(index) }, + label = { Text(stringResource(R.string.brush_designer_coat_label, index + 1)) } + ) + } + + IconButton(onClick = onAddCoat) { + Icon( + painterResource(R.drawable.add_24px), + contentDescription = stringResource(R.string.brush_designer_add_layer) + ) + } + + if (activeProto.coatsList.size > 1) { + IconButton(onClick = onDeleteCoat) { + Icon( + painterResource(R.drawable.delete_24px), + contentDescription = stringResource(R.string.brush_designer_delete_layer), + tint = MaterialTheme.colorScheme.error + ) + } + } + } +} + +@Composable +internal fun MetadataSection( + clientId: String, + developerComment: String, + textFieldsLocked: Boolean, + onToggleLock: (Boolean) -> Unit, + onClientIdChange: (String) -> Unit, + onCommentChange: (String) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = textFieldsLocked, + onCheckedChange = onToggleLock ) Text( - text = stringResource(R.string.brush_designer_controls_placeholder), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + stringResource(R.string.brush_designer_lock_fields), + style = MaterialTheme.typography.bodyMedium ) } + + 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, + label = { Text(stringResource(R.string.brush_designer_developer_comment)) }, + enabled = !textFieldsLocked, + modifier = Modifier.fillMaxWidth(), + minLines = 3 + ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InputModelSection( + inputModel: ink.proto.BrushFamily.InputModel, + onUpdateInputModelToSpring: () -> Unit, + onUpdateInputModelToNaive: () -> Unit, + onUpdateSlidingWindowModel: (Long, Int) -> Unit +) { + Text( + stringResource(R.string.brush_designer_input_model), + style = MaterialTheme.typography.titleMedium + ) + + var expandedModelMenu by remember { mutableStateOf(false) } + val currentModelString = when { + inputModel.hasSpringModel() -> stringResource(R.string.brush_designer_spring_model) + inputModel.hasExperimentalNaiveModel() -> + stringResource(R.string.brush_designer_naive_model) + + inputModel.hasSlidingWindowModel() -> + stringResource(R.string.brush_designer_sliding_window) + + else -> stringResource(R.string.brush_designer_sliding_window_default) + } + + ExposedDropdownMenuBox( + expanded = expandedModelMenu, + onExpandedChange = { expandedModelMenu = it }, + ) { + OutlinedTextField( + value = currentModelString, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.brush_designer_model_type)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedModelMenu) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + + DropdownMenu( + expanded = expandedModelMenu, + onDismissRequest = { expandedModelMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_spring_model)) }, + onClick = { + onUpdateInputModelToSpring() + expandedModelMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_naive_model)) }, + onClick = { + onUpdateInputModelToNaive() + expandedModelMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.brush_designer_sliding_window)) }, + onClick = { + onUpdateSlidingWindowModel(20L, 180) + expandedModelMenu = false + } + ) + } + } + + if (inputModel.hasSlidingWindowModel() || (!inputModel.hasSpringModel() + && !inputModel.hasExperimentalNaiveModel()) + ) { + SlidingWindowControls( + inputModel = inputModel, + onUpdateSlidingWindowModel = onUpdateSlidingWindowModel + ) + } +} + +@Composable +internal fun SlidingWindowControls( + inputModel: ink.proto.BrushFamily.InputModel, + onUpdateSlidingWindowModel: (Long, Int) -> Unit +) { + 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 + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + BrushSliderControl( + label = stringResource(R.string.brush_designer_window_size_ms), + value = windowMs.toFloat(), + valueRange = 1f..100f, + onValueChange = { newValue -> + onUpdateSlidingWindowModel(newValue.toLong(), upsamplingHz) + } + ) + BrushSliderControl( + label = stringResource(R.string.brush_designer_upsampling_frequency_hz), + value = upsamplingHz.toFloat(), + valueRange = 0f..500f, + onValueChange = { newValue -> + onUpdateSlidingWindowModel(windowMs, newValue.toInt()) + } + ) + } + } +} + +@Composable +internal fun TextureNameDialog( + textureIdInput: String, + onTextureIdChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.brush_designer_name_texture_title)) }, + text = { + OutlinedTextField( + value = textureIdInput, + onValueChange = onTextureIdChange, + label = { Text(stringResource(R.string.brush_designer_texture_id_hint)) }, + singleLine = true + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.brush_designer_load)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.brush_designer_cancel)) + } + } + ) +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 200) +@Composable +private fun BrushSliderControlPreview() { + MaterialTheme { + Column(modifier = Modifier.padding(16.dp)) { + BrushSliderControl( + label = stringResource(R.string.brush_designer_scale_x), + value = 1.5f, + valueRange = 0.1f..5f, + onValueChange = {} + ) + } + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 300) +@Composable +private fun TipShapeTabPreview() { + MaterialTheme { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TipShapeTabContent( + currentTip = ProtoBrushTip.getDefaultInstance(), + activeBrush = null, + textureStore = null, + onUpdateTip = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt new file mode 100644 index 00000000..b6878ed6 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt @@ -0,0 +1,224 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +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.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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.cahier.R + +/** + * Wrapper for A/B testing: tracks whether each item is enabled or disabled + * without removing it from the list. + */ +data class CheckableItem(val item: T, val enabled: Boolean = true) + +/** + * A generic list editor that supports add, delete, duplicate, and A/B + * testing (enable/disable toggle) for ordered lists of proto items. + * + * Stateless: the canonical item list is passed in via [items], and all + * changes are emitted via [onItemsChanged] with only the enabled items. + * + * @param title section header text + * @param items current list of items (from proto) + * @param defaultItem factory default for new items + * @param onItemsChanged callback with the updated full list of items + * @param itemHeader display label for each item in collapsed view + * @param editorContent expanded editor for the selected item + */ +@Composable +internal fun EditableListWidget( + title: String, + items: List, + defaultItem: T, + onItemsChanged: (List) -> Unit, + itemHeader: @Composable (T) -> String, + editorContent: @Composable (item: T, onItemChanged: (T) -> Unit) -> Unit +) { + var itemStates by remember { mutableStateOf(items + .map { CheckableItem(it, true) }) } + + + if (itemStates.size != items.size || + itemStates.zip(items).any { (s, i) -> s.item != i }) { + itemStates = items.mapIndexed { index, item -> + CheckableItem(item, itemStates + .getOrNull(index)?.enabled ?: true) + } + } + var selectedIndex by remember { mutableStateOf(null) } + + fun emitEnabledItems() { + onItemsChanged(itemStates.filter { it.enabled }.map { it.item }) + } + + Column(modifier = Modifier.fillMaxWidth()) { + if (title.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + IconButton(onClick = { + itemStates = itemStates + CheckableItem(defaultItem, true) + selectedIndex = itemStates.size + emitEnabledItems() + }) { + Icon( + painterResource(R.drawable.add_24px), + contentDescription = stringResource( + R.string.brush_designer_add_item, title + ), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + itemStates.forEachIndexed { index, state -> + val isSelected = selectedIndex == index + Card( + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + onClick = { selectedIndex = if (isSelected) null else index } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = state.enabled, + onCheckedChange = { checked -> + val newList = itemStates.toMutableList() + newList[index] = state.copy(enabled = checked) + itemStates = newList + emitEnabledItems() + } + ) + + Text( + text = itemHeader(state.item), + modifier = Modifier.weight(1f), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium + ) + + IconButton(onClick = { + val newList = itemStates.toMutableList() + newList.add(index + 1, state.copy()) + itemStates = newList + emitEnabledItems() + }) { + Icon( + painterResource(R.drawable.content_copy_24px), + contentDescription = stringResource( + R.string.brush_designer_duplicate_item + ) + ) + } + + IconButton(onClick = { + val newList = itemStates.toMutableList() + newList.removeAt(index) + itemStates = newList + if (selectedIndex == index) selectedIndex = null + else if (selectedIndex != null && selectedIndex!! > index) { + selectedIndex = selectedIndex!! - 1 + } + emitEnabledItems() + }) { + Icon( + painterResource(R.drawable.delete_24px), + contentDescription = stringResource( + R.string.brush_designer_delete_item + ), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + + selectedIndex?.let { index -> + if (index < itemStates.size) { + val currentItem = itemStates[index].item + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + border = BorderStroke( + 1.dp, MaterialTheme.colorScheme.outlineVariant + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource( + R.string.brush_designer_editing_item, index + 1 + ), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge + ) + Spacer(Modifier.height(8.dp)) + + editorContent(currentItem) { updatedItem -> + val newList = itemStates.toMutableList() + newList[index] = itemStates[index].copy(item = updatedItem) + itemStates = newList + emitEnabledItems() + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt new file mode 100644 index 00000000..be135437 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt @@ -0,0 +1,158 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.Canvas +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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import ink.proto.BrushBehavior + +/** + * A Canvas-based composable that visually plots the easing/response function + * of a [BrushBehavior.ResponseNode]. + * + * Supports: + * - Cubic Bézier curves (cubicBezierResponseCurve) + * - Linear piecewise curves (linearResponseCurve) + * - Predefined easing functions (rendered as a straight line placeholder) + * - Steps response curves (rendered as a staircase) + */ +@Composable +internal fun MathCurvePreview( + responseNode: BrushBehavior.ResponseNode, + modifier: Modifier = Modifier +) { + val lineColor = MaterialTheme.colorScheme.primary + val gridColor = MaterialTheme.colorScheme.outlineVariant + + Card( + modifier = modifier + .fillMaxWidth() + .height(150.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Canvas( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + val w = size.width + val h = size.height + + drawLine( + gridColor, + start = Offset(0f, h), + end = Offset(w, h), + strokeWidth = 2f + ) + drawLine( + gridColor, + start = Offset(0f, 0f), + end = Offset(0f, h), + strokeWidth = 2f + ) + + for (i in 1..3) { + val frac = i / 4f + drawLine( + gridColor.copy(alpha = 0.3f), + start = Offset(0f, h * (1f - frac)), + end = Offset(w, h * (1f - frac)), + strokeWidth = 1f + ) + drawLine( + gridColor.copy(alpha = 0.3f), + start = Offset(w * frac, 0f), + end = Offset(w * frac, h), + strokeWidth = 1f + ) + } + + val path = Path() + val curveCase = responseNode.responseCurveCase + + when (curveCase) { + BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> { + val cb = responseNode.cubicBezierResponseCurve + path.moveTo(0f, h) + path.cubicTo( + cb.x1 * w, h - (cb.y1 * h), + cb.x2 * w, h - (cb.y2 * h), + w, 0f + ) + } + + BrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> { + val linear = responseNode.linearResponseCurve + val xs = linear.xList + val ys = linear.yList + if (xs.isNotEmpty() && ys.isNotEmpty()) { + path.moveTo(xs[0] * w, h - (ys[0] * h)) + for (i in 1 until minOf(xs.size, ys.size)) { + path.lineTo(xs[i] * w, h - (ys[i] * h)) + } + } else { + path.moveTo(0f, h) + path.lineTo(w, 0f) + } + } + + BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> { + val steps = responseNode.stepsResponseCurve + val stepCount = steps.stepCount + if (stepCount > 0) { + val stepWidth = w / stepCount + val stepHeight = h / stepCount + path.moveTo(0f, h) + for (i in 0 until stepCount) { + val y = h - ((i + 1) * stepHeight) + path.lineTo(i * stepWidth, y) + path.lineTo((i + 1) * stepWidth, y) + } + } else { + path.moveTo(0f, h) + path.lineTo(w, 0f) + } + } + + else -> { + path.moveTo(0f, h) + path.lineTo(w, 0f) + } + } + + drawPath( + path, + color = lineColor, + style = Stroke(width = 4f) + ) + } + } +} 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 new file mode 100644 index 00000000..fd755bb9 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt @@ -0,0 +1,536 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +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 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]. + * + * Each editor is stateless: it receives the current node and emits + * the updated node via [onNodeChanged]. + */ +@Composable +internal fun NodeEditor( + node: BrushBehavior.Node, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + when (node.nodeCase) { + BrushBehavior.Node.NodeCase.SOURCE_NODE -> + SourceNodeEditor(node.sourceNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.RESPONSE_NODE -> + ResponseNodeEditor(node.responseNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.DAMPING_NODE -> + DampingNodeEditor(node.dampingNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.NOISE_NODE -> + NoiseNodeEditor(node.noiseNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.TARGET_NODE -> + TargetNodeEditor(node.targetNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.CONSTANT_NODE -> + ConstantNodeEditor(node.constantNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.BINARY_OP_NODE -> + BinaryOpNodeEditor(node.binaryOpNode, onNodeChanged) + + BrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> + InterpolationNodeEditor(node.interpolationNode, onNodeChanged) + + else -> Text( + "Unsupported node type: ${node.nodeCase}", + style = MaterialTheme.typography.bodySmall + ) + } +} + +@Composable +internal fun SourceNodeEditor( + source: BrushBehavior.SourceNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_source), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + val sources = BrushBehavior.Source.entries.filter { + it != BrushBehavior.Source.SOURCE_UNSPECIFIED + } + EnumDropdown( + label = stringResource(R.string.brush_designer_node_source_input), + currentValue = source.source, + values = sources, + displayName = { it.name.replace("SOURCE_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSource(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_range_start), + value = source.sourceValueRangeStart, + limits = NumericLimits(-100f, 100f, 0.1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSourceValueRangeStart(it) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_range_end), + value = source.sourceValueRangeEnd, + limits = NumericLimits(-100f, 100f, 0.1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSourceValueRangeEnd(it) + ).build() + ) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_out_of_range), + currentValue = source.sourceOutOfRangeBehavior, + values = BrushBehavior.OutOfRange.entries.filter { + it != BrushBehavior.OutOfRange.OUT_OF_RANGE_UNSPECIFIED + }, + displayName = { it.name.replace("OUT_OF_RANGE_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setSourceNode( + source.toBuilder().setSourceOutOfRangeBehavior(selected) + ).build() + ) + } + ) + } +} + +@Composable +internal fun ResponseNodeEditor( + response: BrushBehavior.ResponseNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_response_curve), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + MathCurvePreview( + responseNode = response, + modifier = Modifier.padding(vertical = 8.dp) + ) + + val currentType = when (response.responseCurveCase) { + BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> + ResponseCurveType.CubicBezier + BrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> + ResponseCurveType.Linear + BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> + ResponseCurveType.Steps + else -> ResponseCurveType.Predefined + } + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_curve_type), + currentValue = currentType, + values = ResponseCurveType.entries.toList(), + displayName = { it.displayName }, + onSelected = { selected -> + val newResponseBuilder = response.toBuilder() + when (selected) { + ResponseCurveType.Predefined -> + newResponseBuilder.setPredefinedResponseCurve( + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE + ) + ResponseCurveType.CubicBezier -> + newResponseBuilder.setCubicBezierResponseCurve( + ink.proto.CubicBezierEasingFunction.newBuilder() + .setX1(0.25f).setY1(0.1f).setX2(0.25f).setY2(1f) + ) + ResponseCurveType.Linear -> + newResponseBuilder.setLinearResponseCurve( + ink.proto.LinearEasingFunction.newBuilder() + ) + ResponseCurveType.Steps -> + newResponseBuilder.setStepsResponseCurve( + ink.proto.StepsEasingFunction.newBuilder().setStepCount(4) + ) + } + onNodeChanged( + BrushBehavior.Node.newBuilder() + .setResponseNode(newResponseBuilder).build() + ) + } + ) + + when (response.responseCurveCase) { + BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> { + val cb = response.cubicBezierResponseCurve + NumericField( + title = stringResource(R.string.brush_designer_node_control_x1), + value = cb.x1, + limits = NumericLimits(0f, 1f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setX1(it) + ) + ).build() + ) + } + NumericField( + title = stringResource(R.string.brush_designer_node_control_y1), + value = cb.y1, + limits = NumericLimits(-1f, 2f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setY1(it) + ) + ).build() + ) + } + NumericField( + title = stringResource(R.string.brush_designer_node_control_x2), + value = cb.x2, + limits = NumericLimits(0f, 1f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setX2(it) + ) + ).build() + ) + } + NumericField( + title = stringResource(R.string.brush_designer_node_control_y2), + value = cb.y2, + limits = NumericLimits(-1f, 2f, 0.05f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setCubicBezierResponseCurve( + cb.toBuilder().setY2(it) + ) + ).build() + ) + } + } + + BrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE -> { + EnumDropdown( + label = stringResource(R.string.brush_designer_node_predefined_curve), + currentValue = response.predefinedResponseCurve, + values = ProtoPredefinedEasingFunction.entries.toList(), + displayName = { + it.name.replace("PREDEFINED_EASING_FUNCTION_", "") + }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setPredefinedResponseCurve(selected) + ).build() + ) + } + ) + } + + BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> { + val steps = response.stepsResponseCurve + NumericField( + title = stringResource(R.string.brush_designer_node_step_count), + value = steps.stepCount.toFloat(), + limits = NumericLimits(1f, 20f, 1f) + ) { + onNodeChanged( + BrushBehavior.Node.newBuilder().setResponseNode( + response.toBuilder().setStepsResponseCurve( + steps.toBuilder().setStepCount(it.toInt()) + ) + ).build() + ) + } + } + + else -> { + Text( + "Linear curves: edit points via export/import.", + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} + +@Composable +internal fun DampingNodeEditor( + damping: BrushBehavior.DampingNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_damping), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_damping_source), + currentValue = damping.dampingSource, + values = BrushBehavior.ProgressDomain.entries.filter { + it != BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_UNSPECIFIED + }, + displayName = { it.name.replace("PROGRESS_DOMAIN_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setDampingNode( + damping.toBuilder().setDampingSource(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_gap_seconds), + value = damping.dampingGap, + limits = NumericLimits(0.01f, 2f, 0.01f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setDampingNode( + damping.toBuilder().setDampingGap(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun NoiseNodeEditor( + noise: BrushBehavior.NoiseNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text( + text = stringResource(R.string.brush_designer_node_noise), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_seed), + value = noise.seed.toFloat(), + limits = NumericLimits(-10000f, 10000f, 1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setNoiseNode( + noise.toBuilder().setSeed(it.toInt()) + ).build() + ) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_vary_over), + currentValue = noise.varyOver, + values = BrushBehavior.ProgressDomain.entries.filter { + it != BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_UNSPECIFIED + }, + displayName = { it.name.replace("PROGRESS_DOMAIN_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setNoiseNode( + noise.toBuilder().setVaryOver(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_base_period), + value = noise.basePeriod, + limits = NumericLimits(0.01f, 5f, 0.01f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setNoiseNode( + noise.toBuilder().setBasePeriod(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun TargetNodeEditor( + target: BrushBehavior.TargetNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_target), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_target_output), + currentValue = target.target, + values = BrushBehavior.Target.entries.filter { + it != BrushBehavior.Target.TARGET_UNSPECIFIED + }, + displayName = { it.name.replace("TARGET_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setTargetNode( + target.toBuilder().setTarget(selected) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_modifier_range_start), + value = target.targetModifierRangeStart, + limits = NumericLimits(-10f, 10f, 0.05f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setTargetNode( + target.toBuilder().setTargetModifierRangeStart(it) + ).build() + ) + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_node_modifier_range_end), + value = target.targetModifierRangeEnd, + limits = NumericLimits(-10f, 10f, 0.05f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setTargetNode( + target.toBuilder().setTargetModifierRangeEnd(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun ConstantNodeEditor( + constant: BrushBehavior.ConstantNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_constant_value), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + NumericField( + title = stringResource(R.string.brush_designer_node_value), + value = constant.value, + limits = NumericLimits(-100f, 100f, 0.1f), + onValueChanged = { + onNodeChanged( + BrushBehavior.Node.newBuilder().setConstantNode( + constant.toBuilder().setValue(it) + ).build() + ) + } + ) + } +} + +@Composable +internal fun BinaryOpNodeEditor( + binaryOp: BrushBehavior.BinaryOpNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_binary_operation), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_operation), + currentValue = binaryOp.operation, + values = BrushBehavior.BinaryOp.entries.filter { + it != BrushBehavior.BinaryOp.BINARY_OP_UNSPECIFIED + }, + displayName = { it.name.replace("BINARY_OP_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setBinaryOpNode( + binaryOp.toBuilder().setOperation(selected) + ).build() + ) + } + ) + } +} + +@Composable +internal fun InterpolationNodeEditor( + interpolation: BrushBehavior.InterpolationNode, + onNodeChanged: (BrushBehavior.Node) -> Unit +) { + Column { + Text(stringResource(R.string.brush_designer_node_interpolation), style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary) + + EnumDropdown( + label = stringResource(R.string.brush_designer_node_interpolation), + currentValue = interpolation.interpolation, + values = BrushBehavior.Interpolation.entries.filter { + it != BrushBehavior.Interpolation.INTERPOLATION_UNSPECIFIED + }, + displayName = { it.name.replace("INTERPOLATION_", "") }, + onSelected = { selected -> + onNodeChanged( + BrushBehavior.Node.newBuilder().setInterpolationNode( + interpolation.toBuilder().setInterpolation(selected) + ).build() + ) + } + ) + } +} + +/** Response curve types available in the curve type selector dropdown. */ +private enum class ResponseCurveType(val displayName: String) { + Predefined("Predefined"), + CubicBezier("Cubic Bézier"), + Linear("Linear"), + Steps("Steps"); +} 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..1b093dd4 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 @@ -125,6 +125,7 @@ internal fun NumericField( title: String, value: Float, limits: NumericLimits, + onValueChangeFinished: (() -> Unit)? = null, onValueChanged: (Float) -> Unit ) { val displayValue = limits.fromRealValue(value) @@ -191,7 +192,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 new file mode 100644 index 00000000..81d3213e --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt @@ -0,0 +1,620 @@ +/* + * 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.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 +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.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.cahier.R +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushPaint as ProtoBrushPaint +import ink.proto.ColorFunction as ProtoColorFunction +import ink.proto.Color as ProtoColor + +/** + * Tab 1: Paint & texture controls — multi-layer textures, multi-function colors, + * self overlap, and texture import. + * + * Uses [EditableListWidget] for managing multiple texture layers and color + * functions with add/remove/duplicate/toggle support. + * + * Stateless: receives data and callbacks, does not access ViewModel. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PaintTabContent( + activeProto: ProtoBrushFamily, + selectedCoatIndex: Int, + onUpdatePaintPreferences: (List) -> Unit, + onUpdateSelfOverlap: (ProtoBrushPaint.SelfOverlap) -> Unit, + texturePickerLauncher: androidx.activity.result.ActivityResultLauncher< + androidx.activity.result.PickVisualMediaRequest>, + getTextureBitmap: (String) -> Bitmap? +) { + val paintPrefs = + activeProto.coatsList.getOrNull(selectedCoatIndex)?.paintPreferencesList + ?: emptyList() + + val availableTextures = activeProto.textureIdToBitmapMap.keys.toList() + + Text( + text = stringResource(R.string.brush_designer_paint_texture), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + + EditableListWidget( + title = stringResource(R.string.brush_designer_paint_preferences), + items = paintPrefs, + defaultItem = ProtoBrushPaint.newBuilder() + .addTextureLayers( + ProtoBrushPaint.TextureLayer.newBuilder() + .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) + .setSizeX(1f).setSizeY(1f) + .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) + .setBlendMode(ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER) + ) + .build(), + onItemsChanged = onUpdatePaintPreferences, + itemHeader = { paint -> + val texCount = paint.textureLayersCount + val colorCount = paint.colorFunctionsCount + "$texCount texture(s), $colorCount color fn(s)" + }, + editorContent = { paint, onPaintChanged -> + PaintPreferenceEditor( + paint = paint, + availableTextures = availableTextures, + onPaintChanged = onPaintChanged, + getTextureBitmap = getTextureBitmap + ) + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SelfOverlapSelector( + currentPaint = paintPrefs.firstOrNull() ?: ProtoBrushPaint.getDefaultInstance(), + onOverlapSelected = onUpdateSelfOverlap + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + + TextureImportSection( + textureCount = activeProto.textureIdToBitmapMap.size, + texturePickerLauncher = texturePickerLauncher + ) +} + +/** + * Editor for a single paint preference, containing its own texture layers + * and color functions via nested [EditableListWidget]. + */ +@Composable +private fun PaintPreferenceEditor( + paint: ProtoBrushPaint, + availableTextures: List, + onPaintChanged: (ProtoBrushPaint) -> Unit, + getTextureBitmap: (String) -> Bitmap? +) { + EditableListWidget( + title = stringResource(R.string.brush_designer_texture_layers), + items = paint.textureLayersList, + defaultItem = ProtoBrushPaint.TextureLayer.newBuilder() + .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) + .setSizeX(1f).setSizeY(1f) + .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) + .setBlendMode(ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER) + .build(), + onItemsChanged = { newLayers -> + onPaintChanged( + paint.toBuilder().clearTextureLayers() + .also { b -> newLayers.forEach { b.addTextureLayers(it) } } + .build() + ) + }, + itemHeader = { layer -> + val texId = + layer.clientTextureId.ifEmpty { stringResource(R.string.brush_designer_empty) } + val blend = layer.blendMode.name.replace("BLEND_MODE_", "") + "$texId ($blend)" + }, + editorContent = { layer, onLayerChanged -> + TextureLayerEditor( + layer = layer, + availableTextures = availableTextures, + onLayerChanged = onLayerChanged, + getTextureBitmap = getTextureBitmap + ) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + if (paint.colorFunctionsList.isEmpty()) { + Text( + text = stringResource(R.string.brush_designer_no_color_functions), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + EditableListWidget( + title = stringResource(R.string.brush_designer_color_functions), + items = paint.colorFunctionsList, + defaultItem = ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build(), + onItemsChanged = { newFuncs -> + onPaintChanged( + paint.toBuilder().clearColorFunctions() + .also { b -> newFuncs.forEach { b.addColorFunctions(it) } } + .build() + ) + }, + itemHeader = { func -> + when (func.functionCase) { + ProtoColorFunction.FunctionCase.OPACITY_MULTIPLIER -> + stringResource( + R.string.brush_designer_opacity_multiplier, + func.opacityMultiplier + ) + + ProtoColorFunction.FunctionCase.REPLACE_COLOR -> + stringResource(R.string.brush_designer_replace_color) + + else -> stringResource(R.string.brush_designer_unknown) + } + }, + editorContent = { func, onFuncChanged -> + ColorFunctionEditor( + colorFunction = func, + onFunctionChanged = onFuncChanged + ) + } + ) +} + +/** + * Full editor for a single [ProtoBrushPaint.TextureLayer], including all + * fields from SSA: texture ID, mapping, size unit, scale, rotation, + * origin, offset, wrap, blend mode, and animation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TextureLayerEditor( + layer: ProtoBrushPaint.TextureLayer, + availableTextures: List, + onLayerChanged: (ProtoBrushPaint.TextureLayer) -> Unit, + getTextureBitmap: (String) -> Bitmap? +) { + val bitmap = if (layer.clientTextureId.isNotEmpty()) { + getTextureBitmap(layer.clientTextureId) + } else null + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource( + id = R.string.brush_designer_texture_content_desc, + layer.clientTextureId + ), + modifier = Modifier + .size(80.dp) + .padding(bottom = 8.dp) + ) + } + + TextureIdSelector( + currentId = layer.clientTextureId, + availableTextures = availableTextures, + onTextureSelected = { id -> + onLayerChanged(layer.toBuilder().setClientTextureId(id).build()) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_mapping_mode), + currentValue = layer.mapping, + values = ProtoBrushPaint.TextureLayer.Mapping.entries.filter { + it != ProtoBrushPaint.TextureLayer.Mapping.MAPPING_UNSPECIFIED + }, + displayName = { it.name.replace("MAPPING_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setMapping(it).build()) } + ) + + if (layer.mapping == ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) { + EnumDropdown( + label = stringResource(R.string.brush_designer_size_unit), + currentValue = layer.sizeUnit, + values = ProtoBrushPaint.TextureLayer.SizeUnit.entries.filter { + it != ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_UNSPECIFIED + }, + displayName = { it.name.replace("SIZE_UNIT_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setSizeUnit(it).build()) } + ) + + NumericField( + title = stringResource(R.string.brush_designer_scale_x), + value = if (layer.hasSizeX()) layer.sizeX else 1f, + limits = NumericLimits(0.1f, 10f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setSizeX(it).build()) } + ) + NumericField( + title = stringResource(R.string.brush_designer_scale_y), + value = if (layer.hasSizeY()) layer.sizeY else 1f, + limits = NumericLimits(0.1f, 10f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setSizeY(it).build()) } + ) + } + + NumericField( + title = stringResource(R.string.brush_designer_rotation), + value = if (layer.hasRotationInRadians()) layer.rotationInRadians else 0f, + limits = NumericLimits.radiansShownAsDegrees(0f, 360f), + onValueChanged = { + onLayerChanged(layer.toBuilder().setRotationInRadians(it).build()) + } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_origin), + currentValue = layer.origin, + values = ProtoBrushPaint.TextureLayer.Origin.entries.toList(), + displayName = { it.name.replace("ORIGIN_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setOrigin(it).build()) } + ) + + NumericField( + title = stringResource(R.string.brush_designer_offset_x), + value = if (layer.hasOffsetX()) layer.offsetX else 0f, + limits = NumericLimits(-5f, 5f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setOffsetX(it).build()) } + ) + NumericField( + title = stringResource(R.string.brush_designer_offset_y), + value = if (layer.hasOffsetY()) layer.offsetY else 0f, + limits = NumericLimits(-5f, 5f, 0.1f), + onValueChanged = { onLayerChanged(layer.toBuilder().setOffsetY(it).build()) } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_wrap_x), + currentValue = layer.wrapX, + values = ProtoBrushPaint.TextureLayer.Wrap.entries.toList(), + displayName = { it.name.replace("WRAP_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setWrapX(it).build()) } + ) + EnumDropdown( + label = stringResource(R.string.brush_designer_wrap_y), + currentValue = layer.wrapY, + values = ProtoBrushPaint.TextureLayer.Wrap.entries.toList(), + displayName = { it.name.replace("WRAP_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setWrapY(it).build()) } + ) + + EnumDropdown( + label = stringResource(R.string.brush_designer_blend_mode), + currentValue = layer.blendMode, + values = ProtoBrushPaint.TextureLayer.BlendMode.entries.filter { + it != ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_UNSPECIFIED + }, + displayName = { it.name.replace("BLEND_MODE_", "") }, + onSelected = { onLayerChanged(layer.toBuilder().setBlendMode(it).build()) } + ) + Text( + text = blendModeDescription(layer.blendMode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) + ) + + if (layer.mapping == ProtoBrushPaint.TextureLayer.Mapping.MAPPING_STAMPING) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + stringResource(R.string.brush_designer_animation), + style = MaterialTheme.typography.labelLarge + ) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + NumericField( + title = stringResource(R.string.brush_designer_rows), + value = if (layer.hasAnimationRows()) layer.animationRows.toFloat() else 1f, + limits = NumericLimits(1f, 10f, 1f), + onValueChanged = { + onLayerChanged( + layer.toBuilder().setAnimationRows(it.toInt()).build() + ) + } + ) + NumericField( + title = stringResource(R.string.brush_designer_columns), + value = if (layer.hasAnimationColumns()) layer.animationColumns.toFloat() + else 1f, + limits = NumericLimits(1f, 10f, 1f), + onValueChanged = { + onLayerChanged( + layer.toBuilder().setAnimationColumns(it.toInt()).build() + ) + } + ) + NumericField( + title = stringResource(R.string.brush_designer_frames), + value = if (layer.hasAnimationFrames()) layer.animationFrames.toFloat() + else 1f, + limits = NumericLimits(1f, 64f, 1f), + onValueChanged = { + onLayerChanged( + layer.toBuilder().setAnimationFrames(it.toInt()).build() + ) + } + ) + NumericField( + title = stringResource(R.string.brush_designer_duration_seconds), + value = if (layer.hasAnimationDurationSeconds()) + layer.animationDurationSeconds else 0f, + limits = NumericLimits(0f, 5f, 0.1f), + onValueChanged = { + onLayerChanged( + layer.toBuilder().setAnimationDurationSeconds(it).build() + ) + } + ) + } + } + } +} + +/** + * Editor for a single [ProtoColorFunction], supporting OpacityMultiplier + * and ReplaceColor function types. + */ +@Composable +private fun ColorFunctionEditor( + colorFunction: ProtoColorFunction, + onFunctionChanged: (ProtoColorFunction) -> Unit +) { + val functionTypes = listOf("Opacity Multiplier", "Replace Color") + val currentType = when (colorFunction.functionCase) { + ProtoColorFunction.FunctionCase.REPLACE_COLOR -> 1 + else -> 0 + } + + EnumDropdown( + label = stringResource(R.string.brush_designer_function_type), + currentValue = functionTypes[currentType], + values = functionTypes, + displayName = { it }, + onSelected = { selected -> + when (functionTypes.indexOf(selected)) { + 0 -> onFunctionChanged( + ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build() + ) + + 1 -> onFunctionChanged( + ProtoColorFunction.newBuilder() + .setReplaceColor( + ProtoColor.getDefaultInstance() + ) + .build() + ) + } + } + ) + + when (colorFunction.functionCase) { + ProtoColorFunction.FunctionCase.OPACITY_MULTIPLIER -> { + NumericField( + title = stringResource(R.string.brush_designer_opacity_multiplier_label), + value = colorFunction.opacityMultiplier, + limits = NumericLimits(0f, 2f, 0.05f), + onValueChanged = { + onFunctionChanged( + ProtoColorFunction.newBuilder().setOpacityMultiplier(it).build() + ) + } + ) + } + + ProtoColorFunction.FunctionCase.REPLACE_COLOR -> { + Text( + text = stringResource(R.string.brush_designer_replace_color_message), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + + else -> { + Text( + text = stringResource(R.string.brush_designer_unknown_color_function), + style = MaterialTheme.typography.bodySmall + ) + } + } +} + + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TextureIdSelector( + currentId: String, + availableTextures: List, + onTextureSelected: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = currentId.ifEmpty { stringResource(R.string.brush_designer_no_texture) }, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.brush_designer_texture_id)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + availableTextures.forEach { id -> + DropdownMenuItem( + text = { Text(id) }, + onClick = { + onTextureSelected(id) + expanded = false + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelfOverlapSelector( + currentPaint: ProtoBrushPaint, + onOverlapSelected: (ProtoBrushPaint.SelfOverlap) -> Unit +) { + var overlapExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = overlapExpanded, + onExpandedChange = { overlapExpanded = it } + ) { + OutlinedTextField( + value = currentPaint.selfOverlap.name.replace("_", " "), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.brush_designer_self_overlap)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = overlapExpanded) + }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = overlapExpanded, + onDismissRequest = { overlapExpanded = false } + ) { + ProtoBrushPaint.SelfOverlap.entries + .filter { it != ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_UNSPECIFIED } + .forEach { overlap -> + DropdownMenuItem( + text = { Text(overlap.name) }, + onClick = { + onOverlapSelected(overlap) + overlapExpanded = false + } + ) + } + } + } +} + +@Composable +private fun TextureImportSection( + textureCount: Int, + texturePickerLauncher: androidx.activity.result.ActivityResultLauncher< + androidx.activity.result.PickVisualMediaRequest> +) { + Text( + text = stringResource(R.string.brush_designer_textures), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 16.dp) + ) + Button( + onClick = { + texturePickerLauncher.launch( + androidx.activity.result.PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.brush_designer_import_texture)) + } + + Text( + stringResource(R.string.brush_designer_loaded_textures, textureCount), + style = MaterialTheme.typography.bodySmall + ) +} + +/** Returns a short human-readable description for each blend mode. */ +private fun blendModeDescription(mode: ProtoBrushPaint.TextureLayer.BlendMode): String = + when (mode) { + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER -> + "Default — texture drawn over destination." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_IN -> + "Keeps destination where texture is opaque." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OUT -> + "Cuts out destination where texture is opaque." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_ATOP -> + "Draws texture only where destination exists." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_IN -> + "Draws texture only where destination is opaque." + + ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_MODULATE -> + "Multiplies source and destination colors." + + else -> "" + } 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 new file mode 100644 index 00000000..8b460ae9 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt @@ -0,0 +1,169 @@ +/* + * 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.brushdesigner.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +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 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 + +/** + * Tab 0: Tip geometry controls — scale (with lock toggle), corner rounding, + * slant, rotation, pinch, and particle (stamp) settings. + * + * Uses [NumericField] for professional ±button input with degree/percent + * unit conversions and click-to-edit exact-value entry. + * + * Stateless: receives data and callbacks, does not access ViewModel. + */ +@OptIn(ExperimentalInkCustomBrushApi::class) +@Composable +internal fun TipShapeTabContent( + currentTip: ProtoBrushTip, + activeBrush: Brush?, + textureStore: CahierTextureBitmapStore?, + onUpdateTip: (ProtoBrushTip.Builder.() -> Unit) -> Unit +) { + var isScaleLocked by remember { mutableStateOf(false) } + + TipPreview( + brush = activeBrush, + textureStore = textureStore, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Text( + stringResource(R.string.brush_designer_tip_geometry), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.brush_designer_lock_scale_ratio), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch(checked = isScaleLocked, onCheckedChange = { isScaleLocked = it }) + } + + NumericField( + title = stringResource(R.string.brush_designer_tip_scale_x), + value = if (currentTip.hasScaleX()) currentTip.scaleX else 1f, + limits = NumericLimits.floatShownAsPercent(10f, 200f), + onValueChanged = { newX -> + onUpdateTip { + val oldX = if (hasScaleX()) scaleX else 1f + val oldY = if (hasScaleY()) scaleY else 1f + setScaleX(newX) + if (isScaleLocked && oldX > 0f) { + setScaleY(oldY * (newX / oldX)) + } + } + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_tip_scale_y), + value = if (currentTip.hasScaleY()) currentTip.scaleY else 1f, + limits = NumericLimits.floatShownAsPercent(10f, 200f), + onValueChanged = { newY -> + onUpdateTip { + val oldX = if (hasScaleX()) scaleX else 1f + val oldY = if (hasScaleY()) scaleY else 1f + setScaleY(newY) + if (isScaleLocked && oldY > 0f) { + setScaleX(oldX * (newY / oldY)) + } + } + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_corner_rounding), + value = if (currentTip.hasCornerRounding()) currentTip.cornerRounding else 1f, + limits = NumericLimits.floatShownAsPercent(0f, 100f), + onValueChanged = { newValue -> onUpdateTip { setCornerRounding(newValue) } } + ) + + NumericField( + title = stringResource(R.string.brush_designer_slant), + value = if (currentTip.hasSlantRadians()) currentTip.slantRadians else 0f, + limits = NumericLimits.radiansShownAsDegrees(-90f, 90f), + onValueChanged = { newValue -> onUpdateTip { setSlantRadians(newValue) } } + ) + + NumericField( + title = stringResource(R.string.brush_designer_tip_rotation), + value = if (currentTip.hasRotationRadians()) currentTip.rotationRadians else 0f, + limits = NumericLimits.radiansShownAsDegrees(0f, 360f), + onValueChanged = { newValue -> onUpdateTip { setRotationRadians(newValue) } } + ) + + NumericField( + title = stringResource(R.string.brush_designer_pinch), + value = if (currentTip.hasPinch()) currentTip.pinch else 0f, + limits = NumericLimits.floatShownAsPercent(0f, 100f), + onValueChanged = { newValue -> onUpdateTip { setPinch(newValue) } } + ) + + HorizontalDivider() + Text( + stringResource(R.string.brush_designer_particle_settings), + style = MaterialTheme.typography.titleSmall + ) + + NumericField( + title = stringResource(R.string.brush_designer_gap_distance_scale), + value = if (currentTip.hasParticleGapDistanceScale()) currentTip + .particleGapDistanceScale else 0f, + limits = NumericLimits(0f, 5f, 0.1f), + onValueChanged = { newValue -> + onUpdateTip { setParticleGapDistanceScale(newValue) } + } + ) + + NumericField( + title = stringResource(R.string.brush_designer_gap_duration_ms), + value = if (currentTip.hasParticleGapDurationSeconds()) currentTip + .particleGapDurationSeconds * 1000f else 0f, + limits = NumericLimits(0f, 250f, 5f), + onValueChanged = { newValue -> + onUpdateTip { setParticleGapDurationSeconds(newValue / 1000f) } + } + ) +} 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..9d18885a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverter.kt @@ -0,0 +1,391 @@ +/* + * * 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.data + +import androidx.ink.brush.BrushFamily +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.toBrushFamily +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.ValidationSeverity +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.R +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +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 actualSources = GraphValidator.findActualSourceNode(context.graph, edge.fromNodeId) + for (actualSourceNode in actualSources) { + val behaviorLists = collectBehaviorNodes(actualSourceNode.id, context, path) + for (nodeList in behaviorLists) { + val comment = (actualSourceNode.data as? NodeData.Behavior)?.developerComment ?: "" + 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..0d8f1d21 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverter.kt @@ -0,0 +1,428 @@ +/* + * * 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.data + +import androidx.ink.brush.BrushFamily +import androidx.ink.storage.encode +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 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.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 + +/** 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 tempNodeData = NodeData.Behavior( + node = protoNode, + developerComment = behavior.developerComment, + 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 = Pair(protoNode, 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 = behavior.developerComment, + 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 = Pair(nodeData.node, 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..385da992 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/BrushGraphRepository.kt @@ -0,0 +1,659 @@ +/* + * * 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 com.example.cahier.R +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import com.example.cahier.developer.brushdesigner.data.AUTOSAVE_KEY +import androidx.ink.brush.ExperimentalInkCustomBrushApi +import androidx.ink.brush.BrushFamily +import androidx.ink.storage.AndroidBrushFamilySerialization +import androidx.ink.storage.BrushFamilyDecodeCallback +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.UUID +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction +import javax.inject.Inject +import javax.inject.Singleton +import com.example.cahier.core.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlin.OptIn +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@Singleton +@OptIn(ExperimentalInkCustomBrushApi::class, FlowPreview::class) +class BrushGraphRepository @Inject constructor( + private val customBrushDao: CustomBrushDao, + val textureStore: CahierTextureBitmapStore, + @ApplicationScope private val scope: CoroutineScope +) { + private val _graph = MutableStateFlow(createDefaultGraph()) + val graph: StateFlow = _graph.asStateFlow() + init { + scope.launch { + graph + .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("BrushGraphRepository", "Failed to auto-save brush", e) + } + } + } + } + + private var _lastValidBrushFamily: BrushFamily? = null + private val _graphIssues = MutableStateFlow>(emptyList()) + val graphIssues: StateFlow> = _graphIssues.asStateFlow() + + fun setGraph(newGraph: BrushGraph) { + _graph.update { newGraph } + } + + fun clearGraph() { + _graph.update { createDefaultGraph() } + validate() + postDebug(DisplayText.Resource(R.string.bg_msg_graph_cleared)) + } + + 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) } } + } + + 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 } + } + + fun clearIssues() { + _graphIssues.value = emptyList() + } + + 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, + BrushFamilyDecodeCallback { id: String, bitmap: Bitmap? -> + if (bitmap != null) { + textureStore.loadTexture(id, bitmap) + } + id + } + ) + loadBrushFamily(family) + true + } catch (e: Exception) { + android.util.Log.e("BrushGraphRepository", "Failed to decode auto saved brush family", e) + false + } + } + + 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 + } + } + + 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 + } + + 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() + } + + 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 + } + + 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()) + } + + 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 + } + + 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() + } + + 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() + } + + 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 -> {} + } + } + + 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 + } + + 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 + } + + 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 + } + } + + 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 + } + + 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 + } + + 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 new file mode 100644 index 00000000..0d3cbe98 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayText.kt @@ -0,0 +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. + */ +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() +} 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 new file mode 100644 index 00000000..1049ea49 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/DisplayUtils.kt @@ -0,0 +1,374 @@ +/* + * * 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.data + +import androidx.ink.brush.InputToolType +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.PredefinedEasingFunction as ProtoPredefinedEasingFunction +import com.example.cahier.developer.brushdesigner.ui.NumericLimits + +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_TIME_OF_INPUT_IN_MILLIS -> R.string.bg_source_time_of_input_ms + 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_PREDICTED_TIME_ELAPSED_IN_MILLIS -> R.string.bg_source_predicted_time_elapsed_ms + 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_INPUT_IN_MILLIS -> R.string.bg_source_time_since_input_ms + 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_INPUT_SPEED_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_speed_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_VELOCITY_X_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_velocity_x_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND -> R.string.bg_source_velocity_y_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_DISTANCE_TRAVELED_IN_CENTIMETERS -> R.string.bg_source_distance_traveled_absolute + ProtoBrushBehavior.Source.SOURCE_PREDICTED_INPUT_DISTANCE_TRAVELED_IN_CENTIMETERS -> + R.string.bg_source_predicted_distance_traveled_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_source_acceleration_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_x_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_y_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED -> + R.string.bg_source_acceleration_forward_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_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_TIME_OF_INPUT_IN_MILLIS, + ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_MILLIS, + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_MILLIS -> NumericLimits(0f, 10000f, 1f, "ms") + 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_INPUT_SPEED_IN_CENTIMETERS_PER_SECOND -> NumericLimits(0f, 100f, 0.1f, "cm/s") + ProtoBrushBehavior.Source.SOURCE_INPUT_VELOCITY_X_IN_CENTIMETERS_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_INPUT_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND -> NumericLimits(-100f, 100f, 0.1f, "cm/s") + ProtoBrushBehavior.Source.SOURCE_INPUT_DISTANCE_TRAVELED_IN_CENTIMETERS -> NumericLimits(0f, 100f, 0.01f, "cm") + ProtoBrushBehavior.Source.SOURCE_PREDICTED_INPUT_DISTANCE_TRAVELED_IN_CENTIMETERS -> NumericLimits(0f, 10f, 0.01f, "cm") + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED -> NumericLimits(0f, 5000f, 0.1f, "cm/s²") + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_INPUT_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 -> 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) + } +} + +enum class ProgressDomainContext { + 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) + } + } +} + +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 -> 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 + } + +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 + } + +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 + } + +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 + } + +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 + } + +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 + } + +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 + } + +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 + } + +fun ProtoBrushBehavior.ResponseNode.displayStringRId(): Int = + 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 + } + +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 + } + +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 + } + +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 + } + +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 + } + +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 + } + +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 + } + +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 + } + +fun ProtoBrushFamily.InputModel.displayStringRId(): Int = + when { + hasSlidingWindowModel() -> R.string.bg_model_sliding_window + hasSpringModel() -> R.string.bg_model_spring + hasExperimentalNaiveModel() -> R.string.bg_model_naive_experimental + 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 new file mode 100644 index 00000000..cd5bb6ce --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphDataModel.kt @@ -0,0 +1,595 @@ +/* + * * 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.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.storage.decode +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream +import com.example.cahier.R +import ink.proto.Color as ProtoColor + +/** + * Converts a [ProtoBrushFamily] into a functional [BrushFamily] object. + * + * 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) + } +} + +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, +) + +/** 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() + + /** 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 + } + } + + /** 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 + } + + override fun title() = R.string.bg_paint + + override fun subtitles() = listOf(DisplayText.Resource(R.string.bg_overlap_label, listOf(DisplayText.Resource(paint.selfOverlap.displayStringRId())))) + + 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))) + + 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) + } + ) + } + + /** 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) + } + + 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]))) + } + } + 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 + } + 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))) + } + 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 + } + } + + /** 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))) + } + 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 + + override fun subtitles() = listOf(DisplayText.Literal(clientBrushFamilyId)) + + 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)))) + } + 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, +} + +/** Represents a connection between two nodes. */ +data class GraphEdge( + val fromNodeId: String, + val toNodeId: String, + val toPortId: String, + val isDisabled: Boolean = false +) + +/** Represents the entire node graph state. */ +data class BrushGraph( + 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 +) { + abstract val side: PortSide + + class Output(nodeId: String, id: String = "output", label: DisplayText? = null) : + Port(nodeId, id, label, isAddPort = false) { + override val side = PortSide.OUTPUT + } + + 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) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } + + 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) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } + + 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) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } + + 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) : + Port(nodeId, id, label, isAddPort = true) { + override val side = PortSide.INPUT + } +} + +fun GraphNode.getVisiblePorts(graph: BrushGraph): List { + return data.getVisiblePorts(this.id, graph) +} + +/** Preserves input edges when changing node types by mapping them to new port IDs. */ +fun preserveEdgesOnTypeChange( + nodeId: String, + oldData: NodeData?, + newData: NodeData, + 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)) + } + } + } + } + + 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) +} + +/** Based on [Port] subtype, create default [NodeData] to populate it. */ +fun Port.inferNodeData(node: GraphNode): NodeData? = when (this) { + is Port.AddCoat -> NodeData.Coat() + 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() + .setReplaceColor( + 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() + ) + } + 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 + ) + } + 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 + ) + } 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 + } + } + 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 new file mode 100644 index 00000000..7d524f40 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/GraphValidator.kt @@ -0,0 +1,500 @@ +/* + * * 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 + +/** The severity of a validation issue. Errors and Warnings are generally associated + * with specific issues with nodes, and link to them, while Debug messages are general + * information. Errors represent blocking issues which cause the [BrushGraph] to fail + * validation, while Warnings are non-blocking, but should be fixed, and can help diagnose + * issues with the [BrushGraph]. Errors are downgraded to Warnings when the affected node + * is orphaned from the graph, so a node not included in the graph doesn't block validation. + */ +enum class ValidationSeverity { + 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, +) : IllegalStateException( + when (displayMessage) { + is DisplayText.Literal -> displayMessage.text + is DisplayText.Resource -> "Resource ${displayMessage.resId}" + } +) + +/** 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)) + } + } + + // 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, + ) + ) + } + } + + 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() + } + + /** 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())) + ) + } + } + } + } + + /** 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) + } + } + } + } + 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) + } + } +} 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 new file mode 100644 index 00000000..ad4ff65e --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/data/TutorialStep.kt @@ -0,0 +1,598 @@ +/* + * * 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 ink.proto.BrushBehavior + +/** + * Represents a step in the guided tutorial. + */ +data class TutorialStep( + val title: Int, + val message: Int, + val anchor: TutorialAnchor, + val actionRequired: TutorialAction, + val getTargetNode: (BrushGraph) -> GraphNode? = { null } +) + +enum class TutorialAnchor { + SCREEN_CENTER, + FAB, + NODE_CANVAS, + INSPECTOR, + TEST_CANVAS, + ACTION_BAR, + NOTIFICATION_ICON +} + +enum class TutorialAction { + CLICK_NEXT, + CONNECT_NODES, + SELECT_NODE, + EDIT_FIELD, + DRAW_ON_CANVAS, + DELETE_NODE, + SELECT_EDGE, + USE_ACTION_BAR, + CHECK_NOTIFICATIONS, + ADD_BEHAVIOR, + CLICK_NOTIFICATION, + CLICK_ERROR_LINK, + ADD_INPUT_FAB, + MOVE_NODE, + EXIT_INSPECTOR, + EDIT_DROPDOWN, + ADD_NODE_BETWEEN, + LONG_PRESS_NODE, + DUPLICATE_NODES, + SWAP_PORTS, + ADD_COLOR, + CLICK_DONE +} + +val TUTORIAL_STEPS = listOf( + TutorialStep( + title = R.string.bg_tutorial_welcome_title, + message = R.string.bg_tutorial_welcome_message, + anchor = TutorialAnchor.SCREEN_CENTER, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_test_canvas_title, + message = R.string.bg_tutorial_test_canvas_message, + anchor = TutorialAnchor.TEST_CANVAS, + actionRequired = TutorialAction.DRAW_ON_CANVAS + ), + TutorialStep( + title = R.string.bg_tutorial_test_canvas_options_title, + message = R.string.bg_tutorial_test_canvas_options_message, + anchor = TutorialAnchor.TEST_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_edit_tip_title, + message = R.string.bg_tutorial_edit_tip_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Tip } } + ), + TutorialStep( + title = R.string.bg_tutorial_modify_tip_shape_title, + message = R.string.bg_tutorial_modify_tip_shape_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_live_strokes_title, + message = R.string.bg_tutorial_live_strokes_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_inspector_features_title, + message = R.string.bg_tutorial_inspector_features_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_exit_inspector_title, + message = R.string.bg_tutorial_exit_inspector_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EXIT_INSPECTOR + ), + TutorialStep( + title = R.string.bg_tutorial_brush_behaviors_title, + message = R.string.bg_tutorial_brush_behaviors_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_add_behavior_title, + message = R.string.bg_tutorial_add_behavior_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.ADD_BEHAVIOR, + getTargetNode = { graph -> graph.nodes.find { it.data is NodeData.Tip } } + ), + TutorialStep( + title = R.string.bg_tutorial_error_title, + message = R.string.bg_tutorial_error_message, + anchor = TutorialAnchor.NOTIFICATION_ICON, + actionRequired = TutorialAction.CLICK_NOTIFICATION + ), + TutorialStep( + title = R.string.bg_tutorial_notification_pane_title, + message = R.string.bg_tutorial_notification_pane_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_navigate_to_nodes_with_issues_title, + message = R.string.bg_tutorial_navigate_to_nodes_with_issues_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_ERROR_LINK + ), + TutorialStep( + title = R.string.bg_tutorial_add_input_title, + message = R.string.bg_tutorial_add_input_message, + anchor = TutorialAnchor.FAB, + actionRequired = TutorialAction.ADD_INPUT_FAB + ), + TutorialStep( + title = R.string.bg_tutorial_change_node_type_title, + message = R.string.bg_tutorial_change_node_type_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_DROPDOWN, + getTargetNode = { graph -> graph.nodes.lastOrNull() } + ), + TutorialStep( + title = R.string.bg_tutorial_move_node_title, + message = R.string.bg_tutorial_move_node_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.MOVE_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_connect_nodes_title, + message = R.string.bg_tutorial_connect_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CONNECT_NODES + ), + TutorialStep( + title = R.string.bg_tutorial_edit_target_title, + message = R.string.bg_tutorial_edit_target_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.TARGET_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_set_target_title, + message = R.string.bg_tutorial_set_target_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_target_range_sliders_title, + message = R.string.bg_tutorial_target_range_sliders_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_set_target_range_title, + message = R.string.bg_tutorial_set_target_range_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_select_source_node_title, + message = R.string.bg_tutorial_select_source_node_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.SOURCE_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_set_source_type_title, + message = R.string.bg_tutorial_set_source_type_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_source_range_sliders_title, + message = R.string.bg_tutorial_source_range_sliders_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_set_source_range_title, + message = R.string.bg_tutorial_set_source_range_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_effects_of_range_title, + message = R.string.bg_tutorial_effects_of_range_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_explain_behavior_title, + message = R.string.bg_tutorial_explain_behavior_message, + anchor = TutorialAnchor.SCREEN_CENTER, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_out_of_range_behavior_title, + message = R.string.bg_tutorial_out_of_range_behavior_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_clamp_title, + message = R.string.bg_tutorial_clamp_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_mirror_and_repeat_title, + message = R.string.bg_tutorial_mirror_and_repeat_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_moving_on_to_complex_behavior_title, + message = R.string.bg_tutorial_moving_on_to_complex_behavior_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_set_narrow_range_title, + message = R.string.bg_tutorial_set_narrow_range_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_set_clamp_title, + message = R.string.bg_tutorial_set_clamp_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_move_node_space_title, + message = R.string.bg_tutorial_move_node_space_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.MOVE_NODE, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.SOURCE_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_click_edge_title, + message = R.string.bg_tutorial_click_edge_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_EDGE + ), + TutorialStep( + title = R.string.bg_tutorial_edge_inspector_title, + message = R.string.bg_tutorial_edge_inspector_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_node_navigation_title, + message = R.string.bg_tutorial_node_navigation_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.SELECT_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_back_to_edge_inspector_title, + message = R.string.bg_tutorial_back_to_edge_inspector_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_EDGE + ), + TutorialStep( + title = R.string.bg_tutorial_between_two_nodes_title, + message = R.string.bg_tutorial_between_two_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.ADD_NODE_BETWEEN + ), + TutorialStep( + title = R.string.bg_tutorial_edit_node_between_title, + message = R.string.bg_tutorial_edit_node_between_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_DROPDOWN, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.RESPONSE_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_binary_op_title, + message = R.string.bg_tutorial_binary_op_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_add_input_to_binary_op_title, + message = R.string.bg_tutorial_add_input_to_binary_op_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.ADD_BEHAVIOR, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.BINARY_OP_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_configure_new_node_title, + message = R.string.bg_tutorial_configure_new_node_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> graph.nodes.lastOrNull() } + ), + TutorialStep( + title = R.string.bg_tutorial_other_origins_of_values_title, + message = R.string.bg_tutorial_other_origins_of_values_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_change_to_constant_title, + message = R.string.bg_tutorial_change_to_constant_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_slide_constant_value_title, + message = R.string.bg_tutorial_slide_constant_value_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_constant_value_effects_title, + message = R.string.bg_tutorial_constant_value_effects_message, + anchor = TutorialAnchor.INSPECTOR, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_change_operation_title, + message = R.string.bg_tutorial_change_operation_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.BINARY_OP_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_select_product_title, + message = R.string.bg_tutorial_select_product_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_back_to_constant_title, + message = R.string.bg_tutorial_back_to_constant_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> graph.nodes.lastOrNull() } + ), + TutorialStep( + title = R.string.bg_tutorial_change_constant_value_title, + message = R.string.bg_tutorial_change_constant_value_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_constant_effect_title, + message = R.string.bg_tutorial_constant_effect_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_multiplying_vs_adding_title, + message = R.string.bg_tutorial_multiplying_vs_adding_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_further_exploration_title, + message = R.string.bg_tutorial_further_exploration_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_add_another_coat_title, + message = R.string.bg_tutorial_add_another_coat_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.LONG_PRESS_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_select_nodes_title, + message = R.string.bg_tutorial_select_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_duplicate_nodes_title, + message = R.string.bg_tutorial_duplicate_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.DUPLICATE_NODES + ), + TutorialStep( + title = R.string.bg_tutorial_move_duplicated_nodes_title, + message = R.string.bg_tutorial_move_duplicated_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_DONE + ), + TutorialStep( + title = R.string.bg_tutorial_warning_title, + message = R.string.bg_tutorial_warning_message, + anchor = TutorialAnchor.NOTIFICATION_ICON, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_connect_to_family_title, + message = R.string.bg_tutorial_connect_to_family_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CONNECT_NODES + ), + TutorialStep( + title = R.string.bg_tutorial_modify_second_coat_title, + message = R.string.bg_tutorial_modify_second_coat_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_create_border_effect_title, + message = R.string.bg_tutorial_create_border_effect_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_add_color_function_title, + message = R.string.bg_tutorial_add_color_function_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.ADD_COLOR + ), + TutorialStep( + title = R.string.bg_tutorial_coat_order_significance_title, + message = R.string.bg_tutorial_coat_order_significance_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_swap_coat_order_title, + message = R.string.bg_tutorial_swap_coat_order_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SWAP_PORTS, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Family + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_change_order_by_editing_edges_title, + message = R.string.bg_tutorial_change_order_by_editing_edges_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Family + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_back_color_function_title, + message = R.string.bg_tutorial_back_color_function_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.ColorFunction + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_change_function_type_title, + message = R.string.bg_tutorial_change_function_type_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_DROPDOWN + ), + TutorialStep( + title = R.string.bg_tutorial_configure_color_function_title, + message = R.string.bg_tutorial_configure_color_function_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_modify_border_color_title, + message = R.string.bg_tutorial_modify_border_color_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.SELECT_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_configure_constant_title, + message = R.string.bg_tutorial_configure_constant_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.EDIT_FIELD + ), + TutorialStep( + title = R.string.bg_tutorial_border_pattern_title, + message = R.string.bg_tutorial_border_pattern_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_cleanup_title, + message = R.string.bg_tutorial_cleanup_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.LONG_PRESS_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_select_duplicated_nodes_title, + message = R.string.bg_tutorial_select_duplicated_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CLICK_NEXT + ), + TutorialStep( + title = R.string.bg_tutorial_delete_duplicated_nodes_title, + message = R.string.bg_tutorial_delete_duplicated_nodes_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.DELETE_NODE + ), + TutorialStep( + title = R.string.bg_tutorial_reuse_behavior_title, + message = R.string.bg_tutorial_reuse_behavior_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CONNECT_NODES, + getTargetNode = { graph -> + graph.nodes.find { + it.data is NodeData.Behavior && + it.data.node.nodeCase == BrushBehavior.Node.NodeCase.TARGET_NODE + } + } + ), + TutorialStep( + title = R.string.bg_tutorial_multiple_outputs_title, + message = R.string.bg_tutorial_multiple_outputs_message, + anchor = TutorialAnchor.NODE_CANVAS, + actionRequired = TutorialAction.CONNECT_NODES + ), + TutorialStep( + title = R.string.bg_tutorial_complete_title, + message = R.string.bg_tutorial_complete_message, + anchor = TutorialAnchor.SCREEN_CENTER, + actionRequired = TutorialAction.CLICK_NEXT + ) +) 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..4afec0a9 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphDialogs.kt @@ -0,0 +1,302 @@ +/* + * * 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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +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.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 androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.IconButton +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +@Composable +fun NameTextureDialog( + show: Boolean, + onDismiss: () -> Unit, + textureNameInput: String, + onTextureNameInputChange: (String) -> Unit, + onConfirm: () -> Unit +) { + if (show) { + AlertDialog( + 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, + onDismiss: () -> Unit, + paletteBrushNameInput: String, + onPaletteBrushNameInputChange: (String) -> Unit, + onConfirm: () -> Unit +) { + if (show) { + AlertDialog( + 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 +) { + if (show) { + AlertDialog( + 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, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + if (show) { + AlertDialog( + 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, + onDismiss: () -> Unit, + onKeepChanges: () -> Unit, + onRestoreOriginal: () -> Unit +) { + if (show) { + AlertDialog( + 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, + onDismiss: () -> Unit, + textFieldsLocked: Boolean, + onToggleTextFieldsLocked: () -> Unit +) { + if (show) { + AlertDialog( + 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, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + if (show) { + AlertDialog( + 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 +) { + AlertDialog( + 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/DisplayTextExtensions.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/DisplayTextExtensions.kt new file mode 100644 index 00000000..71ae721c --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/DisplayTextExtensions.kt @@ -0,0 +1,43 @@ +/* + * * 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/GraphLayout.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt new file mode 100644 index 00000000..e39f8b07 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt @@ -0,0 +1,219 @@ +/* + * * 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.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.Port +import java.util.UUID + +const val NODE_WIDTH = 300f +const val NODE_PADDING_VERTICAL = 8f +const val NODE_PADDING_BOTTOM = 12f +const val TITLE_AREA_HEIGHT = 64f +const val SUBTITLE_LINE_HEIGHT = 32f +const val PREVIEW_AREA_HEIGHT = 64f +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 + +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 + 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/PreviewWidgets.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/PreviewWidgets.kt new file mode 100644 index 00000000..df1352c9 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/PreviewWidgets.kt @@ -0,0 +1,346 @@ +/* + * * 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.runtime.produceState +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +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.compose.foundation.background +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +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 com.example.cahier.developer.brushgraph.data.toBrushFamily +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import android.graphics.Matrix +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.ink.strokes.MutableStrokeInputBatch +import androidx.ink.strokes.InProgressStroke +import ink.proto.BrushFamily as ProtoBrushFamily +import ink.proto.BrushCoat as ProtoBrushCoat +import ink.proto.BrushTip as ProtoBrushTip +import ink.proto.BrushPaint as ProtoBrushPaint + +@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) { + 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( + family, + renderer = renderer, + brushSize = 10f, + showSingleInput = false, + ) +} + +@Composable +fun TipPreviewWidget(brushTip: ProtoBrushTip, renderer: CanvasStrokeRenderer) { + 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(family, renderer = renderer) +} + +@Composable +fun ColorFunctionPreviewWidget( + colorFunction: ink.proto.ColorFunction, + renderer: CanvasStrokeRenderer, +) { + 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(family, renderer = renderer, brushSize = 30f, zoom = 3f) +} + +@Composable +fun TextureLayerPreviewWidget( + textureLayer: ProtoBrushPaint.TextureLayer, + renderer: CanvasStrokeRenderer, +) { + 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(family, renderer = renderer, brushSize = 30f, zoom = 3f) +} + +@Composable +fun TextureWrapPreviewWidget( + wrapX: ProtoBrushPaint.TextureLayer.Wrap, + wrapY: ProtoBrushPaint.TextureLayer.Wrap, + renderer: CanvasStrokeRenderer, + 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(family, renderer = renderer, brushSize = 30f, zoom = 3f) +} + +@Composable +fun BlendModePreviewWidget( + blendMode: ProtoBrushPaint.TextureLayer.BlendMode, + renderer: CanvasStrokeRenderer, + 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(family, renderer = renderer, brushSize = 30f, zoom = 3f) +} + +@Composable +fun StrokePreviewWidget( + family: BrushFamily, + renderer: CanvasStrokeRenderer, + brushSize: Float = 30f, + showSingleInput: Boolean = true, + zoom: Float = 1f, +) { + val canvasBackground = MaterialTheme.colorScheme.surfaceContainer + 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/Tooltips.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Tooltips.kt new file mode 100644 index 00000000..dceeeb28 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Tooltips.kt @@ -0,0 +1,237 @@ +/* + * * 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 as ProtoBrushBehavior +import ink.proto.BrushPaint as ProtoBrushPaint +import com.example.cahier.R +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.FALLBACK_FILTER_NODE -> R.string.bg_tooltip_node_fallback_filter + 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_TIME_OF_INPUT_IN_MILLIS -> R.string.bg_tooltip_source_time_of_input_ms + 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_INPUT_SPEED_IN_CENTIMETERS_PER_SECOND -> R.string.bg_tooltip_source_speed_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_VELOCITY_X_IN_CENTIMETERS_PER_SECOND -> R.string.bg_tooltip_source_velocity_x_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_VELOCITY_Y_IN_CENTIMETERS_PER_SECOND -> R.string.bg_tooltip_source_velocity_y_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_DISTANCE_TRAVELED_IN_CENTIMETERS -> R.string.bg_tooltip_source_distance_traveled_absolute + ProtoBrushBehavior.Source.SOURCE_PREDICTED_INPUT_DISTANCE_TRAVELED_IN_CENTIMETERS -> R.string.bg_tooltip_source_predicted_distance_traveled_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_x_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_y_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED -> R.string.bg_tooltip_source_acceleration_forward_absolute + ProtoBrushBehavior.Source.SOURCE_INPUT_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_UNSPECIFIED -> R.string.bg_tooltip_source_unspecified + ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_MILLIS -> R.string.bg_tooltip_source_predicted_time_elapsed_ms + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_MILLIS -> R.string.bg_tooltip_source_time_since_input_ms +} + +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 -> 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.OptionalInputProperty.getTooltip(): Int = when (this) { + ProtoBrushBehavior.OptionalInputProperty.OPTIONAL_INPUT_PRESSURE -> R.string.bg_tooltip_optional_input_pressure + ProtoBrushBehavior.OptionalInputProperty.OPTIONAL_INPUT_TILT -> R.string.bg_tooltip_optional_input_tilt + ProtoBrushBehavior.OptionalInputProperty.OPTIONAL_INPUT_ORIENTATION -> R.string.bg_tooltip_optional_input_orientation + ProtoBrushBehavior.OptionalInputProperty.OPTIONAL_INPUT_TILT_X_AND_Y -> R.string.bg_tooltip_optional_input_tilt_x_y + else -> R.string.bg_tooltip_optional_input_default +} + +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_spring -> R.string.bg_tooltip_model_spring + R.string.bg_model_sliding_window -> R.string.bg_tooltip_model_sliding_window + R.string.bg_model_naive_experimental -> R.string.bg_tooltip_model_naive_experimental + 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/fields/BehaviorNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/BehaviorNodeFields.kt new file mode 100644 index 00000000..f8c3243d --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/BehaviorNodeFields.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. + */ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +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 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 ink.proto.BrushBehavior as ProtoBrushBehavior + +@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) { + 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, + textFieldsLocked = textFieldsLocked + ) + } + 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..034dacbf --- /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. + */ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +package com.example.cahier.developer.brushgraph.ui.fields + +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 +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.fields.ALL_BINARY_OPS +import com.example.cahier.developer.brushgraph.ui.getTooltip +import com.example.cahier.developer.brushgraph.data.displayStringRId +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun BinaryOpNodeFields( + binaryNode: ProtoBrushBehavior.BinaryOpNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + onDropdownEditComplete: () -> Unit, + modifier: Modifier = Modifier +) { + FieldWithTooltip( + tooltipTitle = stringResource(R.string.bg_title_operation_format, stringResource(binaryNode.operation.displayStringRId())), + tooltipText = stringResource(binaryNode.operation.getTooltip()), + modifier = modifier + ) { + 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..d6a14f4b --- /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( + text = stringResource(R.string.bg_coat_node_description), + style = MaterialTheme.typography.bodySmall, + modifier = modifier + ) +} 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..f77d4917 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ColorFunctionNodeFields.kt @@ -0,0 +1,148 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +package com.example.cahier.developer.brushgraph.ui.fields + +import androidx.compose.foundation.BorderStroke +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 ink.proto.Color as ProtoColor +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.brushdesigner.ui.NumericLimits +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +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.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 + } + + FieldWithTooltip( + tooltipTitle = stringResource(R.string.bg_title_function_type_format, stringResource(currentTypeResId)), + tooltipText = stringResource(getColorFunctionTooltip(currentTypeResId)), + modifier = modifier + ) { + 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..b0639396 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ConstantNodeFields.kt @@ -0,0 +1,48 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +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( + 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..13074a52 --- /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. + */ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + +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 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.ProgressDomainContext +import com.example.cahier.developer.brushgraph.data.getNumericLimits +import com.example.cahier.developer.brushgraph.ui.getTooltip +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.data.displayStringRId +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) + FieldWithTooltip( + tooltipTitle = stringResource(R.string.bg_title_damping_source_format, stringResource(dampingNode.dampingSource.displayStringRId())), + tooltipText = stringResource(dampingNode.dampingSource.getTooltip()), + modifier = modifier + ) { + 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..734959b1 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/FamilyNodeFields.kt @@ -0,0 +1,149 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +package com.example.cahier.developer.brushgraph.ui.fields + +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.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.data.displayStringRId +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 +) { + + OutlinedTextField( + value = data.clientBrushFamilyId, + onValueChange = { onUpdate(data.copy(clientBrushFamilyId = it)) }, + label = { Text(stringResource(R.string.bg_client_brush_family_id)) }, + 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_spring, R.string.bg_model_naive_experimental), + displayName = { stringResource(it) }, + onSelected = { modelResId -> + val newModel = + when (modelResId) { + R.string.bg_model_naive_experimental -> + ProtoBrushFamily.InputModel.newBuilder() + .setExperimentalNaiveModel( + ProtoBrushFamily.ExperimentalNaiveModel.getDefaultInstance() + ) + .build() + R.string.bg_model_sliding_window -> + ProtoBrushFamily.InputModel.newBuilder() + .setSlidingWindowModel( + ProtoBrushFamily.SlidingWindowModel.newBuilder() + .setWindowSizeSeconds(0.02f) + .setExperimentalUpsamplingPeriodSeconds(0.005f) + ) + .build() + R.string.bg_model_spring -> + ProtoBrushFamily.InputModel.newBuilder() + .setSpringModel(ProtoBrushFamily.SpringModel.getDefaultInstance()) + .build() + else -> + ProtoBrushFamily.InputModel.newBuilder() + .setSpringModel(ProtoBrushFamily.SpringModel.getDefaultInstance()) + .build() + } + onUpdate(data.copy(inputModel = newModel)) + onDropdownEditComplete() + } + ) + } + + val inputModel = data.inputModel + if (inputModel.hasSlidingWindowModel() || (!inputModel.hasSpringModel() && !inputModel.hasExperimentalNaiveModel())) { + 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..59f4c406 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/FieldsUtils.kt @@ -0,0 +1,163 @@ +/* + * * 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_INPUT_SPEED_IN_CENTIMETERS_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_VELOCITY_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_INPUT_VELOCITY_X_IN_CENTIMETERS_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_VELOCITY_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, + ProtoBrushBehavior.Source.SOURCE_INPUT_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_INPUT_DISTANCE_TRAVELED_IN_CENTIMETERS, + ProtoBrushBehavior.Source.SOURCE_PREDICTED_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE, + ProtoBrushBehavior.Source.SOURCE_PREDICTED_INPUT_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_TIME_OF_INPUT_IN_MILLIS, + ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_PREDICTED_TIME_ELAPSED_IN_MILLIS, + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_SECONDS, + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_INPUT_IN_MILLIS, + ProtoBrushBehavior.Source.SOURCE_TIME_SINCE_STROKE_END_IN_SECONDS +) + +internal val SOURCES_ACCELERATION = listOf( + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_INPUT_ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_ACCELERATION_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, + ProtoBrushBehavior.Source.SOURCE_INPUT_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, + 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..5e888598 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/IntegralNodeFields.kt @@ -0,0 +1,127 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +package com.example.cahier.developer.brushgraph.ui.fields + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +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.ProgressDomainContext +import com.example.cahier.developer.brushgraph.data.getNumericLimits + +import com.example.cahier.developer.brushgraph.ui.fields.ALL_OUT_OF_RANGE +import com.example.cahier.developer.brushgraph.ui.fields.ALL_PROGRESS_DOMAINS +import com.example.cahier.developer.brushgraph.data.displayStringRId +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) + 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..ccdc350f --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/InterpolationNodeFields.kt @@ -0,0 +1,61 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +package com.example.cahier.developer.brushgraph.ui.fields + +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 +import com.example.cahier.developer.brushdesigner.ui.EnumDropdown +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.fields.ALL_INTERPOLATIONS +import com.example.cahier.developer.brushgraph.ui.getTooltip +import com.example.cahier.developer.brushgraph.data.displayStringRId +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun InterpolationNodeFields( + interpNode: ProtoBrushBehavior.InterpolationNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + modifier: Modifier = Modifier +) { + FieldWithTooltip( + tooltipTitle = stringResource(R.string.bg_title_interpolation_format, stringResource(interpNode.interpolation.displayStringRId())), + tooltipText = stringResource(interpNode.interpolation.getTooltip()), + modifier = modifier + ) { + 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..c4ccae72 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/NodeFields.kt @@ -0,0 +1,264 @@ +/* + * * 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, + androidx.compose.material3.ExperimentalMaterial3Api::class +) + +package com.example.cahier.developer.brushgraph.ui.fields + +import com.example.cahier.developer.brushdesigner.ui.NumericLimits +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedIconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.HorizontalDivider +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 androidx.compose.ui.unit.sp +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 +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction +import androidx.ink.brush.InputToolType +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.fillMaxWidth + +import com.example.cahier.developer.brushgraph.data.* +import com.example.cahier.developer.brushgraph.ui.TipPreviewWidget + +/** Renders the editable fields for a node. */ +@Composable +fun NodeFields( + node: GraphNode, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + onUpdate: (NodeData) -> Unit, + textFieldsLocked: Boolean, + allTextureIds: Set, + onLoadTexture: () -> Unit, + strokeRenderer: CanvasStrokeRenderer, + onFieldEditComplete: () -> Unit = {}, + onDropdownEditComplete: () -> Unit = {}, +) { + 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..71c0e5d2 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/NoiseNodeFields.kt @@ -0,0 +1,111 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +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 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.ProgressDomainContext +import com.example.cahier.developer.brushgraph.data.getNumericLimits +import com.example.cahier.developer.brushgraph.ui.getTooltip +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.data.displayStringRId +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) + + 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..44fc5adf --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/PaintNodeFields.kt @@ -0,0 +1,62 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +package com.example.cahier.developer.brushgraph.ui.fields + +import androidx.compose.foundation.layout.padding +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.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.ui.getTooltip +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.data.displayStringRId +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( + tooltipTitle = stringResource(R.string.bg_title_self_overlap_format, stringResource(paint.selfOverlap.displayStringRId())), + tooltipText = stringResource(paint.selfOverlap.getTooltip()), + modifier = modifier.padding(vertical = 4.dp) + ) { + 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..37f16d5a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/PolarTargetNodeFields.kt @@ -0,0 +1,119 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +package com.example.cahier.developer.brushgraph.ui.fields + +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 +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.getNumericLimits +import com.example.cahier.developer.brushgraph.ui.getTooltip +import com.example.cahier.developer.brushgraph.ui.FieldWithTooltip +import com.example.cahier.developer.brushgraph.ui.fields.ALL_POLAR_TARGETS +import com.example.cahier.developer.brushgraph.data.displayStringRId +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 +) { + FieldWithTooltip( + tooltipTitle = stringResource(R.string.bg_title_polar_target_format, stringResource(polarNode.target.displayStringRId())), + tooltipText = stringResource(polarNode.target.getTooltip()), + modifier = modifier + ) { + 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..70db5c6d --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ResponseNodeFields.kt @@ -0,0 +1,472 @@ +/* + * * 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, androidx.compose.material3.ExperimentalMaterial3Api::class) + +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.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction +import ink.proto.StepPosition as ProtoStepPosition +import ink.proto.LinearEasingFunction +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +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.geometry.Offset +import androidx.compose.ui.graphics.drawscope.Stroke as DrawStroke +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.BrushBehavior as ProtoBrushBehavior +import ink.proto.CubicBezierEasingFunction as ProtoCubicBezier +import ink.proto.StepsEasingFunction as ProtoSteps +import kotlin.math.ceil +import kotlin.math.floor + +@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, +) { + 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 { + 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, + 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, 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, 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, + 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..1e015b3a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/SourceNodeFields.kt @@ -0,0 +1,213 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +package com.example.cahier.developer.brushgraph.ui.fields + +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.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.brushgraph.ui.FieldWithTooltip +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.getNumericLimits +import com.example.cahier.developer.brushgraph.ui.getTooltip +import com.example.cahier.developer.brushgraph.ui.TooltipDialog +import com.example.cahier.developer.brushgraph.data.displayStringRId +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun SourceNodeFields( + sourceNode: ProtoBrushBehavior.SourceNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + onDropdownEditComplete: () -> Unit, + onFieldEditComplete: () -> Unit, + textFieldsLocked: Boolean, + modifier: Modifier = Modifier +) { + val limits = sourceNode.source.getNumericLimits() + var expandedSource by remember { mutableStateOf(false) } + var showSourceTooltip by remember { mutableStateOf(false) } + + 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) { + 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..d4e73ed0 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TargetNodeFields.kt @@ -0,0 +1,182 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +package com.example.cahier.developer.brushgraph.ui.fields + +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.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.brushdesigner.ui.NumericLimits +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.getNumericLimits +import com.example.cahier.developer.brushgraph.ui.getTooltip +import com.example.cahier.developer.brushgraph.ui.TooltipDialog +import com.example.cahier.developer.brushgraph.data.displayStringRId +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@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) } + + 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) { + 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..ba5a1bbb --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TextureLayerNodeFields.kt @@ -0,0 +1,296 @@ +/* + * * 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, androidx.compose.material3.ExperimentalMaterial3Api::class) + +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.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.ui.res.stringResource +import com.example.cahier.R +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +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 com.example.cahier.developer.brushgraph.ui.TextureLayerPreviewWidget +import com.example.cahier.developer.brushgraph.ui.TextureWrapPreviewWidget +import com.example.cahier.developer.brushgraph.ui.BlendModePreviewWidget +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..449271e5 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TipNodeFields.kt @@ -0,0 +1,96 @@ +/* + * * 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 com.example.cahier.developer.brushgraph.ui.TipPreviewWidget +import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer +import ink.proto.BrushTip as ProtoBrushTip + +@Composable +fun TipNodeFields( + data: NodeData.Tip, + onUpdate: (NodeData) -> Unit, + onFieldEditComplete: () -> Unit, + strokeRenderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier +) { + val tip = data.tip + TipPreviewWidget(tip, 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..78a67931 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ToolTypeFilterNodeFields.kt @@ -0,0 +1,75 @@ +/* + * * 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.compose.material3.ExperimentalMaterial3Api::class) + +package com.example.cahier.developer.brushgraph.ui.fields + +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 com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.displayStringRId +import androidx.ink.brush.InputToolType +import com.example.cahier.R +import ink.proto.BrushBehavior as ProtoBrushBehavior + +@Composable +fun ToolTypeFilterNodeFields( + filterNode: ProtoBrushBehavior.ToolTypeFilterNode, + behaviorNode: ProtoBrushBehavior.Node, + onUpdate: (NodeData) -> Unit, + modifier: Modifier = Modifier +) { + Text(stringResource(R.string.bg_enabled_tool_types), style = MaterialTheme.typography.bodySmall) + ALL_TOOL_TYPES.forEach { toolType -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + 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..d37f309f --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeHeader.kt @@ -0,0 +1,121 @@ +/* + * * 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.node + +import androidx.compose.foundation.background +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.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.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.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, + 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()), + 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 = MaterialTheme.colorScheme.onSurfaceVariant, + 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..8bb89e77 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodePortDots.kt @@ -0,0 +1,164 @@ +/* + * * 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.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.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.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 kotlin.math.roundToInt + +@Composable +fun BoxScope.NodePortDots( + node: GraphNode, + position: Offset, + graph: BrushGraph, + visiblePorts: List, + zoom: Float, + onPortDrag: (PortSide, String, Boolean) -> Unit, + onPortDragUpdate: (Offset) -> Unit, + onPortDragEnd: () -> Unit, + getPortPosition: (String, Boolean) -> Offset, + onPortPositioned: (String, Offset) -> Unit, + canvasCoordinates: LayoutCoordinates?, + onReorderPorts: (String, Int, Int) -> Unit, +) { + 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( + port = port, + count = visiblePorts.size, + modifier = Modifier.align(Alignment.TopStart), + 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( + port = Port.Output(node.id, "output"), + count = 1, + modifier = Modifier.align(Alignment.TopEnd), + 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..50147a75 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodePortLabels.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. + */ +@file:OptIn(androidx.ink.brush.ExperimentalInkCustomBrushApi::class) + +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.width +import androidx.compose.foundation.layout.size +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.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.ui.asString +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.ui.INPUT_ROW_HEIGHT +import com.example.cahier.developer.brushgraph.data.Port + +@Composable +fun NodePortLabels( + node: GraphNode, + graph: BrushGraph, + visiblePorts: List, + isSelectionMode: Boolean, + onPortClick: (String, Port) -> Unit, + 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(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), 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 = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = port.label?.asString() ?: "", + style = MaterialTheme.typography.labelSmall, + color = if (isPortEmpty) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + 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 = MaterialTheme.colorScheme.onSurfaceVariant, + 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..ba227a6a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeRegistry.kt @@ -0,0 +1,128 @@ +/* + * * 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.node + +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import com.example.cahier.developer.brushgraph.data.PortSide +import com.example.cahier.developer.brushgraph.data.Port +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import com.example.cahier.developer.brushgraph.ui.NODE_PADDING_VERTICAL +import com.example.cahier.developer.brushgraph.ui.INPUT_ROW_HEIGHT +import com.example.cahier.developer.brushgraph.ui.width +import com.example.cahier.developer.brushgraph.ui.titleHeight + +/** 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..62d97d4c --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeWidget.kt @@ -0,0 +1,291 @@ +/* + * * 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.node + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectDragGestures +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.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.developer.brushgraph.ui.SineWavePreview +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.GraphNode +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.width +import com.example.cahier.developer.brushgraph.ui.height +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.core.ui.theme.extendedColorScheme +import kotlin.math.roundToInt + +@Composable +fun NodeWidget( + node: GraphNode, + position: Offset, + graph: BrushGraph, + isActiveSource: Boolean, + zoom: Float, + onMove: (Offset) -> Unit, + onClick: () -> Unit, + onUpdate: (NodeData) -> Unit, + 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, + canvasCoordinates: LayoutCoordinates? = null, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + allTextureIds: Set, + onLoadTexture: () -> Unit, + strokeRenderer: CanvasStrokeRenderer, + textFieldsLocked: Boolean, + brush: Brush, + isSelected: Boolean = false, + isSelectionMode: Boolean = false, + isInSelectedSet: Boolean = false, + 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) + + 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) { + val backgroundColor = + if (node.isDisabled) { + MaterialTheme.colorScheme.surfaceDim + } else { + when (node.data) { + is NodeData.Coat -> MaterialTheme.colorScheme.secondaryContainer + is NodeData.Tip, + is NodeData.Paint -> MaterialTheme.colorScheme.secondaryContainer + is NodeData.Family -> MaterialTheme.colorScheme.tertiaryContainer + is NodeData.TextureLayer, + is NodeData.ColorFunction -> MaterialTheme.colorScheme.surfaceVariant + else -> MaterialTheme.colorScheme.surfaceDim + } + } + Modifier.width(w.toDp()) + .height(h.toDp()) + .background( + if (node.isDisabled) { + MaterialTheme.colorScheme.surfaceDim + } else if (isActiveSource || isPressed || isSelected || isInSelectedSet) { + MaterialTheme.colorScheme.primaryContainer + } else if (node.hasError) { + MaterialTheme.colorScheme.errorContainer + } else if (node.hasWarning) { + MaterialTheme.extendedColorScheme.warningContainer + } else { + backgroundColor + }, + RoundedCornerShape(8.dp), + ) + .border( + if (isActiveSource || isPressed || isSelected || isInSelectedSet) { + 2.dp + } else if (node.isDisabled) { + 1.dp + } else if (node.hasError || node.hasWarning) { + 2.dp + } else { + 1.dp + }, + if (isActiveSource || isPressed || isSelected || isInSelectedSet) { + MaterialTheme.colorScheme.primary + } else if (node.isDisabled) { + MaterialTheme.colorScheme.outline.copy(alpha = 0.38f) + } else if (node.hasError) { + MaterialTheme.colorScheme.error + } else if (node.hasWarning) { + MaterialTheme.extendedColorScheme.warning + } else { + MaterialTheme.colorScheme.outline + }, + 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 + ) + + NodePortLabels( + node = node, + graph = graph, + visiblePorts = visiblePorts, + isSelectionMode = isSelectionMode, + onPortClick = onPortClick + ) + } + + 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 + val borderWidth = 2.dp + val borderColor = + when { + node.hasError -> MaterialTheme.colorScheme.error + node.hasWarning -> MaterialTheme.extendedColorScheme.warning + isActiveSource || isPressed || isSelected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.outline + } + + Box(modifier = Modifier.fillMaxHeight().width(borderWidth).background(borderColor)) + + 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..94d92174 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/PortDot.kt @@ -0,0 +1,277 @@ +/* + * * 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.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.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.positionChange +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.pointerInput +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, + count: Int, + modifier: Modifier, + zoom: Float, + onDrag: (PortSide, String, Boolean) -> Unit, + onDragUpdate: (Offset) -> Unit = {}, + onDragEnd: () -> Unit = {}, + onPortPositioned: (Offset) -> Unit, + canvasCoordinates: LayoutCoordinates? = null, + portPosition: Offset, + isReorderable: Boolean = false, + onReorderUpdate: (Float) -> Unit = {}, + onReorderEnd: () -> Unit = {}, + isDragging: Boolean = false, + dragOffset: Float = 0f, + isLargeHandle: Boolean = false, +) { + 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/BrushGraphViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt new file mode 100644 index 00000000..c58f5194 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/viewmodel/BrushGraphViewModel.kt @@ -0,0 +1,607 @@ +/* + * * 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, kotlinx.coroutines.FlowPreview::class) + +package com.example.cahier.developer.brushgraph.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.ink.brush.Brush +import androidx.ink.brush.BrushFamily +import androidx.ink.brush.StockBrushes +import androidx.ink.brush.TextureBitmapStore +import androidx.ink.storage.AndroidBrushFamilySerialization +import androidx.ink.storage.BrushFamilyDecodeCallback +import androidx.ink.strokes.Stroke +import com.example.cahier.developer.brushdesigner.data.CustomBrushEntity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.cahier.core.ui.CahierTextureBitmapStore +import com.example.cahier.developer.brushgraph.data.BrushGraphRepository +import com.example.cahier.developer.brushdesigner.data.CustomBrushDao +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.distinctUntilChanged +import com.example.cahier.developer.brushgraph.data.DisplayText +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.GraphPoint +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.data.GraphValidationException +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.ValidationSeverity +import androidx.compose.runtime.snapshotFlow +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 com.example.cahier.developer.brushgraph.data.TUTORIAL_STEPS +import com.example.cahier.developer.brushgraph.data.TutorialStep +import com.example.cahier.developer.brushgraph.data.TutorialAnchor +import com.example.cahier.developer.brushgraph.data.TutorialAction +import java.io.ByteArrayOutputStream +import java.io.ByteArrayInputStream + +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: Int? = 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() +) + +/** 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 ?: 0 + val size = testBrushSize + if (family != null) { + Brush.createWithColorIntArgb(family, color, size, 0.1f) + } else { + Brush.createWithColorIntArgb(StockBrushes.marker(), color, size, 0.1f) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = Brush.createWithColorIntArgb( + StockBrushes.marker(), + 0, + size = 20f, + epsilon = 0.1f, + ) + ) + + /** The list of strokes drawn in the preview area. */ + val strokeList = mutableStateListOf() + + fun updateTestBrushColor(colorArgb: Int) { + _uiState.update { it.copy(testBrushColor = colorArgb) } + } + + fun updateTestBrushSize(size: Float) { + _uiState.update { it.copy(testBrushSize = size) } + } + + fun updateAllTextureIds() { + _uiState.update { state -> state.copy(allTextureIds = textureStore.getAllIds()) } + } + + val tutorialManager = TutorialManager(repository) + + val tutorialStep get() = tutorialManager.tutorialStep + val currentStepIndex get() = tutorialManager.currentStepIndex + 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, isLandscape: 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(): Int = brush.value.colorIntArgb + + 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 loadFromPalette(entity: CustomBrushEntity) { + viewModelScope.launch(Dispatchers.IO) { + try { + val family = AndroidBrushFamilySerialization.decode( + ByteArrayInputStream(entity.brushBytes), + BrushFamilyDecodeCallback { id, bitmap -> + if (bitmap != null) { + textureStore.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..5bcbc456 --- /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.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf +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.TutorialAction +import com.example.cahier.developer.brushgraph.data.TutorialStep +import com.example.cahier.developer.brushgraph.data.TUTORIAL_STEPS + +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/home/SettingsScreen.kt b/app/src/main/java/com/example/cahier/features/home/SettingsScreen.kt index 653a761c..421430d7 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 @@ -42,6 +42,7 @@ import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -208,12 +209,12 @@ fun SettingsScreen( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.settings_node_graph_ui), + text = stringResource(R.string.settings_graph_ui), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = stringResource(R.string.settings_node_graph_coming_soon), + text = stringResource(R.string.settings_graph_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) 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 5c4834f5..b9206c72 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,7 +85,6 @@ Brush Designer - Editing controls coming in the next update. Use the preview canvas to draw test strokes. Close Stock Brushes My Palette @@ -261,7 +260,777 @@ Ink Brush Designer Create, test, and export custom .brush files Launch - Node Graph UI - Coming soon + Graph UI + Visual, node-based brush designer - \ No newline at end of file + + Name Texture + Texture ID + Save to Palette + Brush Name + Clear Graph + Are you sure you want to clear the entire brush graph? This action cannot be undone. + Start Tutorial + Starting the tutorial will clear your current brush graph to start from scratch. Your current brush will be saved and restored when you exit the tutorial. + Exit Tutorial + Do you want to keep the brush you built in the tutorial, or restore your original brush? + Keep Tutorial Brush + Restore Original Brush + Options + Feedback + Lock text fields + Reorganize Graph + Are you sure you want to reorganize the graph? This will reset all node positions and expansion states. This action cannot be undone. + Select + Export + Import + Organize + Templates + Pressure Pen + Delete Brush + No saved brushes yet + Coat + Paint + Tip + Behavior + Color Function + Texture Layer + My Palette + Delete Edge + Are you sure you want to delete this edge? + Add Node Between + Disable edge + Delete Node + Are you sure you want to delete this node and all its connections? + Disable node + Auto-update + %1$dpx + Node Type + Developer comment + Source + Out of Range Behavior + Vary Over + Enabled Tool Types: + Damping Source + Integrate Over + Operation + Interpolation + Target + Polar Target + Fallback Filter Node + No node data set + any + accumulate + discard + brush size + stroke coordinates + stroke space origin + first stroke input + last stroke input + tiling + stamping + repeat + mirror + clamp + source + source over + source atop + source in + source out + destination + destination over + destination atop + destination in + destination out + modulate + xor + Unknown Behavior Node Type + Connect Tip and Paint Preference(s) to this node. Only one Paint Preference is rendered. The system renderer will use the first compatible Paint Preference. + Self Overlap + Function Type + Color: + Client Brush Family ID + Brush developer comment + Input Model + Delete Nodes + Are you sure you want to delete the selected nodes and all their connections? + Select All + Duplicate + Unknown curve type + Points + Step Position + Predefined Function + Predefined + Cubic Bezier + Steps + Linear + None + Mapping Mode + Size Unit + Origin + Wrap X + Wrap Y + Blend Mode + Mapping + How is my texture imported? + Positioning + How is my texture drawn relative to the stroke? + Wrapping + What happens when the stroke draws further than the bounds of my texture? + Blending + How does my texture combine with the regular ink of the stroke? + Back + Next + Got it + Delete + Create Node + Exit + More options + Close Pane + Notifications (%1$d) + Errors + Warnings + Debug + Add Point + Remove Point + Add + Reorder + Upload Texture + Help + Show Notifications + Source + Constant + Noise + Tool Type Filter + Damping + Response + Integral + Binary Op + Interpolation + Target + 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. + Coat missing Tip input. + Coat missing at least one Paint input. + Interpolation missing input for \"%1$s\". + Polar Target needs at least one complete set of inputs (Angle and Magnitude). + Binary Op requires at least 2 inputs. + Binary Op cannot have more than 26 inputs. + %1$s missing input. + Family missing coat input. + Invalid connection: from node not found. + Missing source for pass-through connection + Invalid connection from %1$s to %2$s at port %3$s: %4$s + Brush Family must be connected to at least one coat. + %1$s output is not used. + Self overlap discard is incompatible with an opacity multiplier target on the coat tip. + Targeting opacity multiplier is incompatible with self overlap discard on the coat paint. + Source node \"%1$s\" cannot have equal range start and end values. + Cycle detected involving node %1$s. + + Coat node %1$s not found + Node %1$s not found + Graph must have exactly one Brush Family node + Coat node %1$s missing tip input + Tip node %1$s not found + Coat node %1$s missing paint input + Paint node %1$s not found + 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. + Brush loaded successfully + Failed to load brush: %1$s + Brush exported successfully + Failed to export brush: %1$s + Graph cleared + Graph reorganized successfully + 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 + Coat can only accept input from Paint at paint ports + Paint can only accept input from TextureLayer at Texture ports + Paint can only accept input from ColorFunction at Color ports + Tip can only accept input from Target or PolarTarget + Family can only connect to Coat + TextureLayer cannot accept inputs + ColorFunction cannot accept inputs + none + Invalid port for Coat + Invalid port for Family + Invalid port for Paint + + Behaviors + Texture + Color + Input + A + B + Value + Start + 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 + normalized pressure + tilt + tilt X + tilt Y + orientation + orientation about zero + speed + velocity X + velocity Y + direction + direction about zero + normalized direction X + normalized direction Y + distance traveled + time of input (s) + time of input (ms) + 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 + acceleration Y + acceleration forward + acceleration lateral + speed (absolute) + velocity X (absolute) + velocity Y (absolute) + distance traveled (absolute) + predicted distance traveled (absolute) + acceleration (absolute) + acceleration X (absolute) + acceleration Y (absolute) + acceleration forward (absolute) + acceleration lateral (absolute) + distance remaining (fraction) + width multiplier + height multiplier + size multiplier + slant offset + pinch offset + rotation offset + corner rounding offset + position offset X + position offset Y + position offset forward + position offset lateral + hue offset + saturation multiplier + luminosity offset + opacity multiplier + position offset absolute + position offset relative + product + sum + min + max + and then + or else + xor else + clamp + repeat + mirror + distance (absolute) + distance (size-relative) + time (seconds) + lerp + inverse lerp + Coat %1$d + Tip + Paint Preference + Behavior + Family + Start + Reorganize + OK + Cancel + Predefined Easing + Cubic Bezier + Linear + Steps + + + Start nodes: + Operator nodes: + Terminal nodes: + Size \u0026 Shape: + Color \u0026 Opacity: + + Range Start + Range End + Base Period + Damping Gap + Angle Start + Angle End + Mag Start + Mag End + Scale X + Scale Y + Corner Rounding + Slant Degrees + Rotation Degrees + Particle Gap Distance Scale + Particle Gap Duration (ms) + Window Size (ms) + Upsampling Frequency (Hz) + + Out of Range Behavior: %1$s + Vary Over: %1$s + Damping Source: %1$s + Operation: %1$s + Interpolation: %1$s + Target: %1$s + Polar Target: %1$s + Self Overlap: %1$s + Function Type: %1$s + Input Model: %1$s + + This source is only compatible with \'clamp\' behavior. + Sliding Window Model + Spring Model + Naive Experimental Model + Input: + Movement: + Distance: + Time: + Acceleration: + Source: %1$s + Seed + Position: + Pinch + Unknown Model + Exit Tutorial + Tutorial + Custom Brushes + Size X + Size Y + Animation Rows + Animation Columns + Animation Frames + Animation Duration (ms) + Offset X + Offset Y + Draw here to test + Collapse + Expand + Test canvas + Reset + Invert canvas + Out + Step Count + Opacity Multiplier + + + Defines the geometric shape and size of the brush tip. This shape acts as a cross-section that is repeated or extruded along the path to create the stroke mesh. + Controls the shading, color, and texturing applied to the stroke geometry. A Coat can have a list of Paint preferences for compatibility fallback; the first one compatible with the device and renderer is used. + Retrieves data from the device input (such as pressure, tilt, or speed) and maps it to a normalized 0 to 1 range. This allows you to use physical input to drive brush behavior dynamically. + Produces a single, fixed numeric value that does not change. This is useful for providing a steady baseline or default value to other nodes in the graph. + Generates a continuous random noise function with values between 0 and 1. It creates organic, non-repeating variations based on a domain like time or distance, allowing for natural-looking brush effects. + Filters out a branch of the behavior graph unless a specific input property (like pressure or tilt) is missing from the device. This node is deprecated in favor of more flexible fallback operations. + Passes or blocks values based on the type of tool being used (e.g., stylus, finger, or mouse). This allows you to create brush behaviors that only apply to specific input methods. + Smoothes out rapid changes in an input value, causing the output to gradually follow the input over time or distance. This is useful for reducing jitter and creating smoother brush strokes. + Maps an input value through a custom response curve or easing function. This allows you to reshape the input data, for example, to make a brush more or less sensitive to pressure. + Combines two input values using a standard binary operation such as addition, multiplication, minimum, or maximum. This allows you to mix different behavior branches together. + Performs interpolation between two values based on a third control value. For example, you can use this to blend between two different brush sizes based on pressure. + Accumulates or integrates an input value over time or distance since the start of the stroke. This is useful for effects that build up as you draw, like ink bleeding or texture accumulation. + Applies the final calculated value to a specific property of the brush tip, such as width, height, or color. This is a terminal node, meaning it does not pass values further but effects the actual rendering. + Applies a vector modifier (angle and magnitude) to a property of the brush tip, such as position offset. This allows for directional effects, like offsetting the tip based on the direction of travel. + A behavior node that modifies brush properties based on inputs and operations. + Configures a texture layer for the paint, specifying the image, size, mapping mode, and how it blends with the base color. + Configures a color function for the paint, allowing for dynamic color shifts based on behavior graph outputs. + Configures a layer of the brush, which can have its own tip and paint settings, allowing for complex multi-layered brushes. + The root node representing the complete brush family. It contains one or more coats that define the final appearance of the brush. + + + Measures the stylus or touch pressure, reported in a normalized range from 0 to 1. This is the most common source for controlling brush thickness or opacity based on how hard the user presses. + Measures the tilt of the stylus relative to the screen, reported in radians from 0 (perpendicular) to π/2 (parallel). This can be used to simulate calligraphy or airbrush effects by changing tip shape based on angle. + Absolute speed of the modeled stroke input in multiples of the brush size per second. This allows you to make the brush respond to how fast the user is drawing, for example, making it thinner at high speeds. + Signed X component of the velocity of the modeled stroke input in multiples of the brush size per second. Positive values indicate movement toward the right. + Signed Y component of the velocity of the modeled stroke input in multiples of the brush size per second. Positive values indicate movement downward. + Signed X component of the modeled stroke input\'s current direction of travel, normalized to the range [-1, 1]. It indicates the horizontal direction the stroke is moving. + 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. + Stylus tilt along the vertical axis, in radians. Positive values correspond to tilt downward. + Stylus orientation or angle relative to the screen, in radians from 0 to 2π. + Stylus orientation centered around zero, in radians from -π to π. + Estimated distance remaining to the end of the stroke, in multiples of brush size. This changes dynamically as you draw. + Time elapsed since this specific input was recorded, in seconds. This continues to increase and can drive post-stroke animations. + Angle of the direction of travel, in radians from 0 to 2π. + Angle of the direction of travel, centered around zero, in radians from -π to π. + Absolute acceleration of the stroke input, in multiples of brush size per second squared. + Horizontal component of acceleration, in multiples of brush size per second squared. + Vertical component of acceleration, in multiples of brush size per second squared. + Acceleration in the direction of travel, in multiples of brush size per second squared. + Acceleration perpendicular to the direction of travel, in multiples of brush size per second squared. + Absolute speed of the pointer on the screen, in centimeters per second. + Horizontal velocity of the pointer, in centimeters per second. + Vertical velocity of the pointer, in centimeters per second. + Distance traveled by the pointer on the screen, in centimeters. + Predicted distance to be traveled by the pointer, in centimeters. + Absolute acceleration of the pointer, in centimeters per second squared. + Horizontal acceleration of the pointer, in centimeters per second squared. + Vertical acceleration of the pointer, in centimeters per second squared. + Acceleration of the pointer in the direction of travel, in centimeters per second squared. + Acceleration of the pointer perpendicular to the direction of travel, in centimeters per second squared. + 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. + + 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. + A convenience target that scales both width and height of the brush tip simultaneously, maintaining the aspect ratio. + Adds an offset to the brush tip rotation. The final rotation angle is normalized modulo 2π. If multiple behaviors have this target, they stack additively. + Adds an offset to the brush tip corner rounding. The final value is clamped to the range [0, 1]. If multiple behaviors have this target, they stack additively. + Shifts the hue of the brush color. A positive offset shifts around the hue wheel from red towards orange. If multiple behaviors have this target, they stack additively. + Scales the saturation of the brush color. If multiple behaviors have this target, they stack multiplicatively. The final multiplier is clamped to [0, 2]. + Modifies the luminosity of the brush color. An offset of +/-100% corresponds to changing the luminosity by up to +/-100%. + Adds an offset to the brush tip slant. This tilts the shape of the tip. If multiple behaviors have this target, they stack additively. + Adds an offset to the brush tip pinch. This brings the upper corners of the tip closer together. If multiple behaviors have this target, they stack additively. + Scales the opacity of the brush color. If multiple behaviors have this target, they stack multiplicatively. The final multiplier is clamped to [0, 2]. + Adds an offset to the brush tip position along the horizontal axis. The offset is measured in multiples of the brush size. + Adds an offset to the brush tip position along the vertical axis. The offset is measured in multiples of the brush size. + Moves the brush tip forward or backward along the direction of travel. The distance is measured in multiples of the brush size. + Moves the brush tip sideways relative to the direction of travel. The distance is measured in multiples of the brush size. + Unspecified target. + + + Repeats the texture image horizontally and vertically, creating a tiled pattern. This is useful for seamless textures like paper or canvas. + Repeats the texture image while alternating mirror images, ensuring that adjacent edges always match. This helps to avoid visible seams in the pattern. + Clamps the texture to its edges. Points outside the texture take the color of the nearest edge pixel, stretching the border colors outward. + Unspecified texture wrap mode. + + + Specifies the texture size in the same absolute units as the stroke input position. This means the texture scale remains constant regardless of brush size. + Specifies the texture size as a multiple of the brush size. This means the texture scales up or down along with the brush width. + Units for specifying the size of the texture on the stroke. + + + The texture origin is fixed at the origin of the stroke space. This ensures that the texture remains aligned across different strokes. + The texture origin is anchored to the very first input point of the stroke. This means the pattern starts predictably at the beginning of each stroke. + The texture origin is anchored to the last input position of the stroke. This means the texture pattern will move along with the brush as you draw. + Specification of the origin point to use for the texture mapping. + + + The texture repeats as tiles across the stroke mesh. Each copy of the texture has the same size and shape, creating a continuous pattern. + The texture is \'stamped\' onto each individual particle of the stroke. This is intended for use with particle brushes to apply a shape to each dot. + How the texture should be applied and mapped to the stroke geometry. + + + Multiplies the source and destination colors. This results in a darker color, similar to multiplying layers in image editors. + Keeps the destination pixels that overlap with the source pixels, acting as a mask. The source color itself is not drawn. + Keeps the destination pixels that do NOT overlap with the source pixels, effectively erasing parts of the destination. + Draws the source color only where the destination exists, masking it to the existing shape. + Keeps the source pixels only where they overlap with the destination pixels, masking the source to the destination shape. + Draws the source color over the destination color. This is the standard blending mode most commonly used. + Draws the destination color over the source color. The source color is drawn behind the existing content. + Keeps the source color and discards the destination color. This effectively ignores the background or previous layers. + Keeps the destination color and discards the source color. This effectively ignores the current layer. + Keeps the source color only where it does NOT overlap with the destination. This can be used to cut out shapes. + Draws the destination color only where the source exists, masking it to the new shape. + Combines source and destination but clears areas where they intersect. This creates a visual effect where overlapping parts disappear. + How the texture color combines with the paint color. + + The default behavior, which may vary by implementation. It allows the renderer to choose the most efficient method. + Overlapping parts of a single stroke build up opacity and color density, mimicking physical ink buildup like a highlighter or watercolor where the path crosses itself. + The stroke maintains uniform opacity and color even where it crosses over itself, rendering as a flat, continuous shape without darkening at intersections. + How overlapping parts of the same stroke are rendered. + + + Adds a position offset in absolute stroke space. The angle determines the direction (0 is positive X-axis) and magnitude determines the distance in multiples of the brush size. + Adds a position offset relative to the current direction of travel. An angle of zero is forward, allowing you to create trailing or leading effects. + Unspecified polar target. + + + Values outside the specified range are clamped to the minimum or maximum boundary value. This prevents the output from exceeding the desired limits. + Values outside the range repeat by wrapping around to the other side, creating a repeating pattern or cycle. + 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. + Returns the smaller of the two values, A and B. If either value is missing, the result is missing. + Returns the larger of the two values, A and B. If either value is missing, the result is missing. + Returns value B only if value A is present (not null). If A is missing, it returns missing, allowing for conditional execution. + Returns value A if it is present. If A is missing, it falls back and returns value B instead. + Returns A if B is missing, or B if A is missing. If both are present or both are missing, it returns missing. + Binary operation to combine two values. + + + Damping or noise variation occurs over time, measured in seconds. This creates time-dependent effects that continue even if you stop moving. + Damping or noise variation occurs over physical distance traveled on the screen, measured in centimeters. This requires screen calibration data to be accurate. + Damping or noise variation occurs over distance traveled, measured in multiples of the brush size. This scales the effect naturally with the brush width. + Domain for measuring damping or noise progress. + + + Linear interpolation (LERP). Uses the first input value as a percentage (0 to 1) to blend between the second value and the third value. + Inverse linear interpolation. Calculates where the first value lies within the range defined by the second and third values, returning a 0 to 1 percentage. + Interpolation function to combine values. + + + Linear easing function. It returns the input value unchanged, creating a direct, proportional response. + Standard \'ease\' function. It starts slowly, accelerates in the middle, and slows down again at the end. + Standard \'ease-in\' function. It starts slowly and accelerates towards the end. + Standard \'ease-out\' function. It starts quickly and decelerates towards the end. + Standard \'ease-in-out\' function. It combines ease-in and ease-out, starting and ending slowly. + Step-start function. The value jumps immediately to the final value at the very beginning of the interval. + Step-end function. The value remains at the initial value until the very end of the interval, then jumps to the final value. + Predefined easing function to shape the response curve. + + + The step function jumps at the start of each interval, meaning the value changes at the beginning of the step. + The step function jumps at the end of each interval, meaning the value changes at the end of the step. + The step function does not jump at either boundary, maintaining a smoother transition between steps. + The step function jumps at both the start and the end of the interval. + Step position behavior for step easing functions. + + + A model that simulates a physical spring to smooth inputs. It creates a natural, fluid feeling by adding slight inertia to the stroke. + Averages nearby inputs together within a sliding time window. This creates smooth strokes by blending recent inputs. + A simple model that passes through raw inputs mostly unchanged. This is experimental and may result in less smooth strokes. + Selects the model used to smooth raw hardware inputs. + + + Multiplies the opacity of the stroke by a calculated value, allowing for dynamic transparency effects. + Replaces the brush color with a specified color, ignoring the baseline color for this function\'s output. + Selects the type of color function to apply. + + Welcome! + We will walk through the brush graph UI and explain how to use it by building a basic brush from the ground up. We already have the building blocks of a brush here at the beginning: Family (top-level representation of a brush style, similar to a font family), a Coat (layer of ink), a Tip (geometry extruded to create the mesh), and Paint (color and texture). + Test Canvas + Tap to open the test canvas at the bottom, and draw on it. + Test Canvas Options + As we modify the brush, the test canvas will auto-update any strokes drawn here, so it is useful to have something here as we edit the brush. There\'s also options here to configure the canvas\' color, clear it, and change the brush\'s color and size. + Edit Tip + Tap the Tip node to open the inspector view. + Modify Tip Shape + Modify the tip shape parameters. You can either use the slider, or tap the number to manually enter a value. + Live Strokes + Notice how changing the parameters affects the previews on the tip, coat, and family nodes, and also the test strokes in the canvas. + Inspector Features + Notice a few other key features in the inspector: the (?) button (show tooltips), the delete button (delete a node), and the disable button (temporarily deactivate a node). + Exit Inspector + Exit the inspector by clicking the (x) button or clicking on an empty part of the canvas. + Brush Behaviors + In addition to defining a shape, a Tip contains behaviors. In a behavior, a \'Source\' collects data from the input (e.g., pressure, speed), normalizes it, and passes it to a \'Target\' which uses that data to modify a value that affect the appearance of the stroke (e.g., color, size). + Add Behavior + The \'+...\' buttons on nodes will add a default node that produces an output compatible as input for the other node, and connect the two. Begin by adding a new behavior to the tip by clicking \'+ Behavior\'. + Error! + Uh-oh, this causes an error! Click the error icon in the upper right corner to take a closer look. + Notification Pane + There are three types of notifications: Error (brush is invalid), Warning (potential issue with brush), and Debug (a record of certain actions). Here we have one error, caused by target we created not having an input. + Navigate to Nodes with Issues + In most cases, the error or warning message will link to the node causing it. Click the error to navigate directly to the node with the issue. + Add Input + To fix the missing input issue, let\'s add an input to this target. We could click \'+ Input\', but let\'s try another way: click the floating action button (+) and select \'behavior\'. + Change Node Type + This will make a \'Target\' node by default, and automatically open the inspector. Change the Node Type to \'Source\' in the inspector. + Move Node + Move the node to the left of the existing \'Target\' by dragging it. + Connect Nodes + Create a connection between the two nodes by dragging from the output port (gray dot to the right of the Source node, labelled \'Out\') to the input port (gray dot to the left of the Target, labelled \'+ Input\'). + Edit Target + The validation error should now be fixed. You may already notice your stroke changing with this new behavior! Let\'s configure it futher: tap the Target node to open the inspector. + Set Target + Set the \'Target\' to \'hue offset\'. + Target Range Sliders + Notice the \'range start\' and \'range end\' sliders. These control how much this target value will be changed based on the inputs it receives. + Set Target Range + Let\'s give it a wide range so we get a lot of hue variation, say -360 to 360. + Select Source Node + When you\'re done, tap the Source node to open the inspector. + Set Source Type + Set the source to \'distance traveled\'. + Source Range Sliders + Source also has \'range\' sliders. These control how to normalize Source data to a 0 to 1 range, determining what range of Source data to map to the upper and lower range of the Target. + Set Source Range + Set source start to 0 and end to ~30. + Effects of Range + Notice how hue offset is only applied to the beginning of the stroke, where distance traveled is between 0 and ~30. The hue offset between those values follows the range set in the Target node of -360 to 360. + Explain Behavior + Together, these two nodes describe a behavior: map distance traveled in the range given by Source to hue offset in the range set by Target. + Out of Range Behavior + Notice \'out of range behavior\'. This controls what value the Source should pass when the data is outside of the set range. + Clamp + By default, the out of range behavior is \'clamp\': outside of the range, values are clamped to the bounds of the range, 0 or 1. This leads to no hue offset outside the Source range, because our Target range is -360 to 360 (and 360 or -360 hue offset is the same as none). + Mirror and Repeat + Try changing out of range behavior to \'mirror\' or \'repeat\' and notice what changes. + Moving On to a More Complex Behavior + Next we will build a more complicated behavior. Before we proceed, we will configure the Source to best illustrate the impacts of the different nodes. + Set a Narrow Range + Set the range of the source node narrow, with space on each side (say 20 to 80). + Set Clamp + When you\'re done with the range, set the Source node to use \'clamp\'. + Move Node + Let\'s make some space for another node. Move the source node to the left to make the edge longer. + Click Edge + Click the edge between the Source and the Target to open the edge inspector. + Edge Inspector + Like the node inspector, the edge inspector has disable and delete buttons which function similarly. It also lists the two nodes connected by the edge. + Node Navigation + Click either node listed in the edge inspector to navigate directly to it. + Back to Edge Inspector + Reopen the edge inspector for this edge. + Between Two Nodes + Let\'s insert a node inbetween these nodes. Click \'add node between\'. + Edit Node Between + By default, this adds a Response node, but let\'s change the Node Type to a Binary Op. + Binary Op + Binary Ops combine two values according to a function (e.g., sum, product). Notice there is an error since a Binary Op needs at least two values. In brush graph, a Binary Op can have more than two values; this is a UI convenience to make it easy to chain many Binary Ops together. + Add Input to Binary Op + Resolve the error by clicking \'+ Input\' on the Binary Op. + Configure New Node + Click the new Source node added to open it in the inspector. + Other Origins of Values + Values don\'t have to originate from data collected by a Source node; they can be randomly generated by a Noise node, or static from a Constant node. + Change to Constant + Change the Node Type to a Constant node. + Slide Constant Value + Try sliding the constant value around. + Constant Value Effects + Notice how the color changes; we still have the same section with a hue offset affected by the distance traveled, but the color of the stroke outside of that range changes with the constant! This is because we are summing these values together, but the amount of distance traveled only impacts hue offset within the set range, since we are using the clamp out of range behavior. + Change Operation + Let\'s change what the Binary Op is doing. Click to select the Binary Op node. + Select Product + Change the Operation to \'product\'. + Back to Constant + Tap the constant node to open it in the inspector. + Change the Constant + Now that we are multiplying values in the Binary Op, try sliding the constant around and see what happens. + The Constant\'s Effect + Notice that the area before the area affected by the Source is unaffected by any hue offset modifier, no matter how you change the constant. + Multiplying vs. Adding + This is because values are normalized on a range of 0 to 1, so with clamp, everything outside of the Source\'s range is either 0 or 1, depending on which side it is on. On the lower side, that is 0, and 0 * constant = 0 passed to Target, which maps to the lower end of the range for hue offset: -360, which is equivalent to 0, or no hue offset change. On the higher side, it is clamped to 1, and 1 * constant = constant passed to Target, which can then affect hue offset. + Further Exploration + What do you think would happen if the Source\'s out of range behavior was set to mirror or repeat? Feel free to explore this on your own. When you\'re ready to proceed, set the Source\'s out of range behavior back to clamp and click \'Next\'. + Add Another Coat + Let\'s add another coat for a \'border\' effect. Long-press any node other than the Family node to enter \'selection mode\'. + Select Nodes + Tap every node except the Family node to select them, or click \'Select All\' (this ignores the Family node). + Duplicate Nodes + Click \'Duplicate\' to make a copy of all of these nodes. + Move Duplicated Nodes + Drag any of the selected nodes to move them all out of the way so they don\'t overlap the other nodes. Click \'Done\' when you\'re ready to deselect them. + Warning! + Notice a warning appears since the output of all these nodes we copied is unused. + Connect to Family + Draw an edge from the \'Out\' port of the new coat to the \'+ Coat\' port on the Family node. + Modify Second Coat + Open the Tip of the second Coat. + Create Border Effect + Increase scale x and y to make it wider and taller. + Add Color Function + When you\'re done with the Tip, go to the Paint node of the second Coat, and click \'+ Color\' to add a Color Function. + Coat Order Significance + By default this should make a \'Replace Color\' function. You may see the whole stroke change color. If so, this is because of the order of the coats. Coats earlier in the order are drawn first, with later coats drawn on top. + Swap Coat Order + We want to draw the new, larger, recolored coat beneath the smaller one. Swap the order of the coats on the Family node by dragging the handle on \'coat 1\' up to swap with \'coat 0\'. + Change Order by Editing Edges + We could also change the order by \'editing\' the edges. Dragging from any input port with an edge connected allows you to move the edge. Release the edge while editing to delete, or reconnect it to another port to move it there. + Back Color Function + Navigate back to the Color Function inspector by clicking it. + Change Function Type + Instead of a black outline, let\'s try to make the border look a bit more like a shadow. Change the Function Type to \'Opacity Multiplier\'. + Configure Color Function + Let\'s make the border be a fainter version of the inner part of the stroke. Set Opacity Multiplier ~0.4. + Modify the Border Color + We can modify the constant to make the border follow the same pattern as the inner coat, but with different hues. Click the Constant Node on the border to open it in the inspector. + Configure the Constant + Try changing the value of the Constant. + Border Pattern + Notice that the border retains the same pattern in terms of hue offset, though the base colors change. + Cleanup + If we want the constant to be same, so the inner and outer coats have the exact same colors, we don\'t need the duplicated behavior nodes. Hold down on a node to enter select mode. + Select Duplicated Nodes + Tap to select the duplicated nodes making up the behavior for the border coat: Source, Constant, Binary Op, and Target. + Delete Duplicated Nodes + Click \'Delete\' to get rid of them. + Reuse Behavior + Drag a new edge from the \'Out\' port of the Target on the inner Coat to the \'+ Behavior\' port of the Tip on the outer Coat. + Multiple Outputs + Notice that now, the behavior is applied to both Tips on both Coats! The \'Out\' port on nodes can connect with multiple inputs, enabling complex graphs to use fewer nodes and be easier to design. + Tutorial Complete! + That should be enough to get you started. You can find Templates in the menu, which are a great next step to understand how brushes work. Remember to click the (?) buttons for tooltips if you get stuck. Happy designing! + + From + To + Input: %1$s + Edge + Inspector: %1$s + Help + Close Inspector + + + Ease + Ease In + Ease Out + Ease In Out + Step Start + Step End + Cubic(%1$f, %2$f, %3$f, %4$f) + Steps(%1$d, %2$s) + Linear(%1$s) + jump start + jump end + jump both + jump none + + Mapping Mode: %1$s + Size Unit: %1$s + Origin: %1$s + Wrap X: %1$s + Wrap Y: %1$s + Blend Mode: %1$s + + + Window Size (ms) + Upsampling Frequency (Hz) + 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..eb325aad --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushFamilyConverterTest.kt @@ -0,0 +1,905 @@ +/* + * * 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()) + } +} 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..4c2a5a90 --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/BrushGraphConverterTest.kt @@ -0,0 +1,227 @@ +/* + * * 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) + } + } + } + + 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/GraphDataModelTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphDataModelTest.kt new file mode 100644 index 00000000..aacd05d0 --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphDataModelTest.kt @@ -0,0 +1,381 @@ +/* + * * 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.Port +import com.example.cahier.developer.brushgraph.data.PortSide +import com.example.cahier.developer.brushgraph.data.GraphNode +import com.example.cahier.developer.brushgraph.data.NodeData +import com.example.cahier.developer.brushgraph.data.BrushGraph +import com.example.cahier.developer.brushgraph.data.DisplayText +import com.example.cahier.developer.brushgraph.data.GraphEdge +import com.example.cahier.developer.brushgraph.data.getVisiblePorts +import com.example.cahier.R +import ink.proto.BrushBehavior as ProtoBrushBehavior +import org.junit.Assert.assertEquals +import org.junit.Test + +class GraphDataModelTest { + + @Test + fun getVisiblePorts_singleInputNodeNoConnections_returnsAddInputPort() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setResponseNode(ProtoBrushBehavior.ResponseNode.getDefaultInstance()) + .build() + ) + ) + val graph = BrushGraph(nodes = listOf(node)) + val ports = node.getVisiblePorts(graph) + + assertEquals(1, ports.size) + assertEquals("add_input", ports[0].id) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[0].label) + } + + @Test + fun getVisiblePorts_singleInputNodeWithConnections_returnsInputAndAddInputPorts() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setResponseNode(ProtoBrushBehavior.ResponseNode.getDefaultInstance()) + .build(), + inputPortIds = listOf("Input") + ) + ) + val sourceNode = GraphNode(id = "2", data = NodeData.Behavior(node = ProtoBrushBehavior.Node.getDefaultInstance())) + val edge = GraphEdge(fromNodeId = "2", toNodeId = "1", toPortId = "Input") + val graph = BrushGraph(nodes = listOf(node, sourceNode), edges = listOf(edge)) + val ports = node.getVisiblePorts(graph) + + assertEquals(2, ports.size) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[0].label) + assertEquals(false, ports[0].isAddPort) + + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[1].label) + assertEquals(true, ports[1].isAddPort) + } + + @Test + fun getVisiblePorts_binaryOpNoConnections_returnsAddInputPort() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.newBuilder().setOperation(ProtoBrushBehavior.BinaryOp.BINARY_OP_SUM)) + .build() + ) + ) + val graph = BrushGraph(nodes = listOf(node)) + val ports = node.getVisiblePorts(graph) + + assertEquals(1, ports.size) + assertEquals("add_input", ports[0].id) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[0].label) + } + + @Test + fun getVisiblePorts_binaryOpWithConnections_returnsInputsAndAddInputPorts() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.newBuilder().setOperation(ProtoBrushBehavior.BinaryOp.BINARY_OP_SUM)) + .build(), + inputPortIds = listOf("input_0", "input_1") + ) + ) + val sourceNode = GraphNode(id = "2", data = NodeData.Behavior(node = ProtoBrushBehavior.Node.getDefaultInstance())) + val edge = GraphEdge(fromNodeId = "2", toNodeId = "1", toPortId = "input_0") + val graph = BrushGraph(nodes = listOf(node, sourceNode), edges = listOf(edge)) + val ports = node.getVisiblePorts(graph) + + assertEquals(3, ports.size) + assertEquals("input_0", ports[0].id) + assertEquals(DisplayText.Literal("A"), ports[0].label) + assertEquals("input_1", ports[1].id) + assertEquals(DisplayText.Literal("B"), ports[1].label) + assertEquals("add_input", ports[2].id) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[2].label) + } + + @Test + fun getVisiblePorts_polarTargetNoConnections_returnsAddInputPort() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setPolarTargetNode(ProtoBrushBehavior.PolarTargetNode.getDefaultInstance()) + .build() + ) + ) + val graph = BrushGraph(nodes = listOf(node)) + val ports = node.getVisiblePorts(graph) + + assertEquals(1, ports.size) + assertEquals("add_input", ports[0].id) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[0].label) + } + + @Test + fun getVisiblePorts_polarTargetWithConnections_returnsInputsAndAddInputPorts() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setPolarTargetNode(ProtoBrushBehavior.PolarTargetNode.getDefaultInstance()) + .build(), + inputPortIds = listOf("angle_0", "mag_0") + ) + ) + val sourceNode1 = GraphNode(id = "2", data = NodeData.Behavior(node = ProtoBrushBehavior.Node.getDefaultInstance())) + val sourceNode2 = GraphNode(id = "3", data = NodeData.Behavior(node = ProtoBrushBehavior.Node.getDefaultInstance())) + val edge1 = GraphEdge(fromNodeId = "2", toNodeId = "1", toPortId = "angle_0") + val edge2 = GraphEdge(fromNodeId = "3", toNodeId = "1", toPortId = "mag_0") + val graph = BrushGraph(nodes = listOf(node, sourceNode1, sourceNode2), edges = listOf(edge1, edge2)) + val ports = node.getVisiblePorts(graph) + + assertEquals(3, ports.size) + assertEquals("angle_0", ports[0].id) + assertEquals(DisplayText.Resource(R.string.bg_port_angle), ports[0].label) + assertEquals("mag_0", ports[1].id) + assertEquals(DisplayText.Resource(R.string.bg_port_mag), ports[1].label) + assertEquals("add_input", ports[2].id) + assertEquals(DisplayText.Resource(R.string.bg_port_input), ports[2].label) + } + + @Test + fun getVisiblePorts_paintNoConnections_returnsAddTextureAndAddColorPorts() { + val node = GraphNode(id = "1", data = NodeData.Paint(paint = ink.proto.BrushPaint.getDefaultInstance())) + val graph = BrushGraph(nodes = listOf(node)) + val ports = node.getVisiblePorts(graph) + + assertEquals(2, ports.size) + assertEquals("add_texture", ports[0].id) + assertEquals(DisplayText.Resource(R.string.bg_port_texture), ports[0].label) + assertEquals("add_color", ports[1].id) + assertEquals(DisplayText.Resource(R.string.bg_port_color), ports[1].label) + } + + @Test + fun getVisiblePorts_paintWithConnections_returnsPortsAndAddPorts() { + val node = GraphNode( + id = "1", + data = NodeData.Paint( + paint = ink.proto.BrushPaint.getDefaultInstance(), + texturePortIds = listOf("texture_0"), + colorPortIds = listOf("color_0") + ) + ) + val textureNode = GraphNode(id = "2", data = NodeData.TextureLayer(layer = ink.proto.BrushPaint.TextureLayer.getDefaultInstance())) + val colorNode = GraphNode(id = "3", data = NodeData.ColorFunction(function = ink.proto.ColorFunction.getDefaultInstance())) + + val edge1 = GraphEdge(fromNodeId = "2", toNodeId = "1", toPortId = "texture_0") + val edge2 = GraphEdge(fromNodeId = "3", toNodeId = "1", toPortId = "color_0") + + val graph = BrushGraph(nodes = listOf(node, textureNode, colorNode), edges = listOf(edge1, edge2)) + val ports = node.getVisiblePorts(graph) + + assertEquals(4, ports.size) + assertEquals(DisplayText.Resource(R.string.bg_port_texture), ports[0].label) + assertEquals(DisplayText.Resource(R.string.bg_port_texture), ports[1].label) + assertEquals(DisplayText.Resource(R.string.bg_port_color), ports[2].label) + assertEquals(DisplayText.Resource(R.string.bg_port_color), ports[3].label) + } + + @Test + fun preserveEdgesOnTypeChange_binaryOpToInterpolation_edgesArePreserved() { + val targetNodeId = "1" + val oldData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.newBuilder().build()) + .build(), + inputPortIds = listOf("input_0", "input_1", "input_2") + ) + + val sourceId1 = "2" + val sourceId2 = "3" + val sourceId3 = "4" + + val edge1 = GraphEdge(fromNodeId = sourceId1, toNodeId = targetNodeId, toPortId = "input_0") + val edge2 = GraphEdge(fromNodeId = sourceId2, toNodeId = targetNodeId, toPortId = "input_1") + val edge3 = GraphEdge(fromNodeId = sourceId3, toNodeId = targetNodeId, toPortId = "input_2") + + val edges = listOf(edge1, edge2, edge3) + + val newData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setInterpolationNode(ProtoBrushBehavior.InterpolationNode.getDefaultInstance()) + .build() + ) + + val (finalNewData, finalEdges) = preserveEdgesOnTypeChange(targetNodeId, oldData, newData, edges) + + assertEquals(3, finalEdges.size) + + val e1 = finalEdges.find { it.fromNodeId == sourceId1 }!! + val behaviorData = finalNewData as NodeData.Behavior + assertEquals(behaviorData.inputPortIds[0], e1.toPortId) + + val e2 = finalEdges.find { it.fromNodeId == sourceId2 }!! + assertEquals(behaviorData.inputPortIds[1], e2.toPortId) + + val e3 = finalEdges.find { it.fromNodeId == sourceId3 }!! + assertEquals(behaviorData.inputPortIds[2], e3.toPortId) + + assertEquals(3, behaviorData.inputPortIds.size) + } + + @Test + fun preserveEdgesOnTypeChange_interpolationToBinaryOp_edgesArePreserved() { + val targetNodeId = "1" + val oldData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setInterpolationNode(ProtoBrushBehavior.InterpolationNode.getDefaultInstance()) + .build() + ) + + val sourceId1 = "2" + val sourceId2 = "3" + val sourceId3 = "4" + + val edge1 = GraphEdge(fromNodeId = sourceId1, toNodeId = targetNodeId, toPortId = "Value") + val edge2 = GraphEdge(fromNodeId = sourceId2, toNodeId = targetNodeId, toPortId = "Start") + val edge3 = GraphEdge(fromNodeId = sourceId3, toNodeId = targetNodeId, toPortId = "End") + + val edges = listOf(edge1, edge2, edge3) + + val newData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.getDefaultInstance()) + .build() + ) + + val (finalNewData, finalEdges) = preserveEdgesOnTypeChange(targetNodeId, oldData, newData, edges) + + assertEquals(3, finalEdges.size) + + val behaviorData = finalNewData as NodeData.Behavior + val e1 = finalEdges.find { it.fromNodeId == sourceId1 }!! + assertEquals(behaviorData.inputPortIds[0], e1.toPortId) + + val e2 = finalEdges.find { it.fromNodeId == sourceId2 }!! + assertEquals(behaviorData.inputPortIds[1], e2.toPortId) + + val e3 = finalEdges.find { it.fromNodeId == sourceId3 }!! + assertEquals(behaviorData.inputPortIds[2], e3.toPortId) + + assertEquals(3, behaviorData.inputPortIds.size) + } + + @Test + fun preserveEdgesOnTypeChange_binaryOpToPolarTarget_edgesArePreserved() { + val targetNodeId = "1" + val oldData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.newBuilder().build()) + .build(), + inputPortIds = listOf("input_0", "input_1") + ) + + val sourceId1 = "2" + val sourceId2 = "3" + + val edge1 = GraphEdge(fromNodeId = sourceId1, toNodeId = targetNodeId, toPortId = "input_0") + val edge2 = GraphEdge(fromNodeId = sourceId2, toNodeId = targetNodeId, toPortId = "input_1") + + val edges = listOf(edge1, edge2) + + val newData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setPolarTargetNode(ProtoBrushBehavior.PolarTargetNode.getDefaultInstance()) + .build() + ) + + val (finalNewData, finalEdges) = preserveEdgesOnTypeChange(targetNodeId, oldData, newData, edges) + + assertEquals(2, finalEdges.size) + + val behaviorData = finalNewData as NodeData.Behavior + val e1 = finalEdges.find { it.fromNodeId == sourceId1 }!! + assertEquals(behaviorData.inputPortIds[0], e1.toPortId) + + val e2 = finalEdges.find { it.fromNodeId == sourceId2 }!! + assertEquals(behaviorData.inputPortIds[1], e2.toPortId) + + assertEquals(2, behaviorData.inputPortIds.size) + } + + @Test + fun preserveEdgesOnTypeChange_toTargetNode_edgesArePreserved() { + val targetNodeId = "1" + val oldData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setBinaryOpNode(ProtoBrushBehavior.BinaryOpNode.newBuilder().build()) + .build(), + inputPortIds = listOf("input_0", "input_1") + ) + + val sourceId1 = "2" + val sourceId2 = "3" + + val edge1 = GraphEdge(fromNodeId = sourceId1, toNodeId = targetNodeId, toPortId = "input_0") + val edge2 = GraphEdge(fromNodeId = sourceId2, toNodeId = targetNodeId, toPortId = "input_1") + + val edges = listOf(edge1, edge2) + + val newData = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setTargetNode(ProtoBrushBehavior.TargetNode.getDefaultInstance()) + .build() + ) + + val (finalNewData, finalEdges) = preserveEdgesOnTypeChange(targetNodeId, oldData, newData, edges) + + assertEquals(2, finalEdges.size) + + val behaviorData = finalNewData as NodeData.Behavior + val e1 = finalEdges.find { it.fromNodeId == sourceId1 }!! + assertEquals(behaviorData.inputPortIds[0], e1.toPortId) + + val e2 = finalEdges.find { it.fromNodeId == sourceId2 }!! + assertEquals(behaviorData.inputPortIds[1], e2.toPortId) + + assertEquals(2, behaviorData.inputPortIds.size) + } + + @Test + fun subtitles_constantNode_formatsWithDotSeparator() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setConstantNode(ProtoBrushBehavior.ConstantNode.newBuilder().setValue(1.5f).build()) + .build() + ) + ) + + val subtitles = node.data.subtitles() + + assertEquals(1, subtitles.size) + val subtitle = subtitles[0] + assert(subtitle is DisplayText.Literal) + assertEquals("1.50", (subtitle as DisplayText.Literal).text) + } +} diff --git a/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphValidatorTest.kt b/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphValidatorTest.kt new file mode 100644 index 00000000..cfdca02c --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/data/GraphValidatorTest.kt @@ -0,0 +1,91 @@ +/* + * * 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 ink.proto.BrushBehavior as ProtoBrushBehavior +import org.junit.Assert.assertEquals +import org.junit.Test + +class GraphValidatorTest { + + @Test + fun validateAll_duplicateIssuesWithDifferentArgs_areNotDeduplicated() { + val exc1 = GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_interp_missing_input, listOf(DisplayText.Literal("A"))), + nodeId = "1" + ) + val exc2 = GraphValidationException( + displayMessage = DisplayText.Resource(R.string.bg_err_interp_missing_input, listOf(DisplayText.Literal("B"))), + nodeId = "1" + ) + + val issues = listOf(exc1, exc2, exc1) + val distinctIssues = issues.distinct() + + assertEquals(2, distinctIssues.size) + assertEquals(exc1, distinctIssues[0]) + assertEquals(exc2, distinctIssues[1]) + } + + @Test + fun validateAll_interpolationNodeWithExcessInputs_doesNotThrowIndexOutOfBounds() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setInterpolationNode(ProtoBrushBehavior.InterpolationNode.getDefaultInstance()) + .build(), + inputPortIds = listOf("value", "start", "end", "excess_1", "excess_2") + ) + ) + val graph = BrushGraph(nodes = listOf(node)) + + val issues = GraphValidator.validateAll(graph) + + val missingInputIssues = issues.filter { it.displayMessage is DisplayText.Resource && it.displayMessage.resId == R.string.bg_err_interp_missing_input } + assertEquals(3, missingInputIssues.size) + } + + @Test + fun validateAll_sourceRangeEqual_producesMessageWithoutNestedLists() { + val node = GraphNode( + id = "1", + data = NodeData.Behavior( + node = ProtoBrushBehavior.Node.newBuilder() + .setSourceNode( + ProtoBrushBehavior.SourceNode.newBuilder() + .setSourceValueRangeStart(1.0f) + .setSourceValueRangeEnd(1.0f) + .build() + ) + .build() + ) + ) + val graph = BrushGraph(nodes = listOf(node)) + + val issues = GraphValidator.validateAll(graph) + + val rangeIssues = issues.filter { it.displayMessage is DisplayText.Resource && it.displayMessage.resId == R.string.bg_err_source_range_equal } + assertEquals(1, rangeIssues.size) + + val displayMessage = rangeIssues[0].displayMessage as DisplayText.Resource + assertEquals(1, displayMessage.args.size) + + val arg = displayMessage.args[0] + assert(arg is DisplayText) + } +} 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..cbf344c0 --- /dev/null +++ b/app/src/test/java/com/example/cahier/developer/brushgraph/ui/TooltipsTest.kt @@ -0,0 +1,248 @@ +/* + * * 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().setFallbackFilterNode(BrushBehavior.FallbackFilterNode.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 optionalInputPropertyEnumsTooltips_checked_areUnique() { + val tooltips = mutableSetOf() + for (value in BrushBehavior.OptionalInputProperty.values()) { + if (value.name == "UNRECOGNIZED") continue + val tooltip = value.getTooltip() + assertTrue("Tooltip should be unique for OptionalInputProperty.$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_spring, + R.string.bg_model_naive_experimental + ) + 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8f9759d7..6c38b5ec 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" }