diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c6c768..10b96ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,6 +87,8 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.navigation.runtime.ktx) diff --git a/app/src/main/java/com/example/cahier/core/ui/theme/Color.kt b/app/src/main/java/com/example/cahier/core/ui/theme/Color.kt index 16a6fe4..bad2080 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 116cdae..8356dbb 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/ui/BrushDesignerComponents.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerComponents.kt index d7c8ec7..c8dd8bc 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/NodeEditors.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt index 3a52d54..fd755bb 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.example.cahier.R import ink.proto.BrushBehavior +import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction /** * Dispatches to the correct editor based on the [BrushBehavior.Node.NodeCase]. @@ -174,7 +175,7 @@ internal fun ResponseNodeEditor( when (selected) { ResponseCurveType.Predefined -> newResponseBuilder.setPredefinedResponseCurve( - ink.proto.PredefinedEasingFunction.PREDEFINED_EASING_EASE + ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE ) ResponseCurveType.CubicBezier -> newResponseBuilder.setCubicBezierResponseCurve( @@ -258,7 +259,7 @@ internal fun ResponseNodeEditor( EnumDropdown( label = stringResource(R.string.brush_designer_node_predefined_curve), currentValue = response.predefinedResponseCurve, - values = ink.proto.PredefinedEasingFunction.entries.toList(), + values = ProtoPredefinedEasingFunction.entries.toList(), displayName = { it.name.replace("PREDEFINED_EASING_FUNCTION_", "") }, diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt index 20f99b5..60a4387 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NumericField.kt @@ -122,16 +122,18 @@ class NumericLimits( @OptIn(ExperimentalFoundationApi::class) @Composable internal fun NumericField( + modifier: Modifier = Modifier, title: String, value: Float, limits: NumericLimits, + onValueChangeFinished: (() -> Unit)? = null, onValueChanged: (Float) -> Unit ) { val displayValue = limits.fromRealValue(value) var showTextInput by remember { mutableStateOf(false) } var textInputValue by remember { mutableStateOf("") } - Column(modifier = Modifier.padding(vertical = 4.dp)) { + Column(modifier = modifier.padding(vertical = 4.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -191,7 +193,8 @@ internal fun NumericField( value = displayValue.coerceIn(limits.min, limits.max), onValueChange = { onValueChanged(limits.toRealValue(it)) }, valueRange = limits.min..limits.max, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + onValueChangeFinished = onValueChangeFinished ) IconButton(onClick = { diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt index 332dd15..81d3213 100644 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt +++ b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt @@ -49,6 +49,7 @@ 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, @@ -438,7 +439,7 @@ private fun ColorFunctionEditor( 1 -> onFunctionChanged( ProtoColorFunction.newBuilder() .setReplaceColor( - ink.proto.Color.getDefaultInstance() + ProtoColor.getDefaultInstance() ) .build() ) @@ -477,52 +478,7 @@ private fun ColorFunctionEditor( } } -/** - * A generic [ExposedDropdownMenuBox] for selecting from enum-like value lists. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun EnumDropdown( - label: String, - currentValue: T, - values: List, - displayName: (T) -> String, - onSelected: (T) -> Unit -) { - var expanded by remember { mutableStateOf(false) } - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it } - ) { - OutlinedTextField( - value = displayName(currentValue), - onValueChange = {}, - readOnly = true, - label = { Text(label) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - values.forEach { value -> - DropdownMenuItem( - text = { Text(displayName(value)) }, - onClick = { - onSelected(value) - expanded = false - } - ) - } - } - } -} @OptIn(ExperimentalMaterial3Api::class) @Composable 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 index b2f8aad..ad89a2c 100644 --- 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 @@ -92,6 +92,17 @@ object BrushFamilyConverter { .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 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 0000000..e55fef1 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushGraphDialogs.kt @@ -0,0 +1,318 @@ +/* + * * 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, + textureNameInput: String, + onTextureNameInputChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_name_texture)) }, + text = { + OutlinedTextField( + value = textureNameInput, + onValueChange = onTextureNameInputChange, + label = { Text(stringResource(R.string.bg_texture_id)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.bg_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_cancel)) + } + } + ) + } +} + +@Composable +fun SaveToPaletteDialog( + show: Boolean, + paletteBrushNameInput: String, + onPaletteBrushNameInputChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_save_to_palette)) }, + text = { + OutlinedTextField( + value = paletteBrushNameInput, + onValueChange = onPaletteBrushNameInputChange, + label = { Text(stringResource(R.string.bg_brush_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_cancel)) + } + } + ) + } +} + +@Composable +fun ClearGraphConfirmationDialog( + show: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_clear_graph)) }, + text = { + Text(stringResource(R.string.bg_clear_graph_confirmation)) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.clear)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_cancel)) + } + } + ) + } +} + +@Composable +fun TutorialWarningDialog( + show: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_start_tutorial)) }, + text = { + Text(stringResource(R.string.bg_start_tutorial_message)) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.bg_start)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_cancel)) + } + } + ) + } +} + +@Composable +fun TutorialFinishDialog( + show: Boolean, + onKeepChanges: () -> Unit, + onRestoreOriginal: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_exit_tutorial)) }, + text = { + Text(stringResource(R.string.bg_exit_tutorial_message)) + }, + confirmButton = { + Button(onClick = onKeepChanges) { + Text(stringResource(R.string.bg_keep_tutorial_brush)) + } + }, + dismissButton = { + Button(onClick = onRestoreOriginal) { + Text(stringResource(R.string.bg_restore_original_brush)) + } + } + ) + } +} + +@Composable +fun OptionsDialog( + show: Boolean, + textFieldsLocked: Boolean, + onToggleTextFieldsLocked: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_options)) }, + text = { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Text(stringResource(R.string.bg_lock_text_fields), modifier = Modifier.weight(1f)) + Switch( + checked = textFieldsLocked, + onCheckedChange = { onToggleTextFieldsLocked() } + ) + } + } + }, + confirmButton = { + Button(onClick = onDismiss) { + Text(stringResource(R.string.bg_ok)) + } + } + ) + } +} + +@Composable +fun ReorganizeConfirmationDialog( + show: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + if (show) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.bg_reorganize_graph)) }, + text = { + Text(stringResource(R.string.bg_reorganize_graph_confirmation)) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.bg_reorganize)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_cancel)) + } + } + ) + } +} + +@Composable +internal fun TooltipDialog( + title: String, + text: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(text) }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.bg_ok)) + } + } + ) +} + +@Composable +internal fun FieldWithTooltip( + tooltipTitle: String, + tooltipText: String, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + var showTooltip by remember { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth() + ) { + Box(modifier = Modifier.weight(1f)) { + content() + } + IconButton(onClick = { showTooltip = true }) { + Icon(Icons.AutoMirrored.Filled.Help, contentDescription = stringResource(R.string.bg_cd_help)) + } + } + if (showTooltip) { + TooltipDialog( + title = tooltipTitle, + text = tooltipText, + onDismiss = { showTooltip = false } + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Dimensions.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Dimensions.kt new file mode 100644 index 0000000..a5e0434 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/Dimensions.kt @@ -0,0 +1,26 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +package com.example.cahier.developer.brushgraph.ui + +const val NODE_WIDTH = 300f +const val NODE_PADDING_VERTICAL = 8f +const val NODE_PADDING_BOTTOM = 12f +const val INPUT_ROW_HEIGHT = 60f + +const val INSPECTOR_WIDTH_LANDSCAPE = 320f +const val INSPECTOR_HEIGHT_PORTRAIT = 400f +const val PREVIEW_HEIGHT_EXPANDED = 200f +const val PREVIEW_HEIGHT_COLLAPSED = 40f \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/DisplayTextExtensions.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/DisplayTextExtensions.kt new file mode 100644 index 0000000..d7d46fb --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/DisplayTextExtensions.kt @@ -0,0 +1,45 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +package com.example.cahier.developer.brushgraph.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.example.cahier.developer.brushgraph.data.DisplayText +import androidx.compose.ui.Modifier + +@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 0000000..dc9b0db --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/GraphLayout.kt @@ -0,0 +1,210 @@ +/* + * * 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 + +private const val TITLE_AREA_HEIGHT = 64f +private const val SUBTITLE_LINE_HEIGHT = 32f +private const val PREVIEW_AREA_HEIGHT = 64f + +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 0000000..59e435a --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/PreviewWidgets.kt @@ -0,0 +1,361 @@ +/* + * * 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 +import ink.proto.ColorFunction as ProtoColorFunction + +@Composable +fun SineWavePreview( + brush: Brush, + strokeRenderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + BoxWithConstraints(modifier = modifier) { + val canvasWidth = with(LocalDensity.current) { maxWidth.toPx() } + val canvasHeight = with(LocalDensity.current) { maxHeight.toPx() } + + val stroke = + remember(brush, canvasWidth, canvasHeight) { + if (canvasWidth <= 0f || canvasHeight <= 0f) return@remember null + val inputs = MutableStrokeInputBatch() + val numPoints = 100 + val horizontalBuffer = 40f + val effectiveWidth = canvasWidth - 2 * horizontalBuffer + if (effectiveWidth <= 0f) return@remember null + + val midY = canvasHeight / 2f + val amplitude = canvasHeight * 0.2f + val period = 1.5f + val frequency = 2f * Math.PI.toFloat() * period / effectiveWidth + + for (i in 0 until numPoints) { + val xOffset = i.toFloat() * effectiveWidth / (numPoints - 1) + val x = horizontalBuffer + xOffset + val y = midY + amplitude * kotlin.math.sin(frequency * xOffset) + inputs.add(type = InputToolType.STYLUS, x = x, y = y, elapsedTimeMillis = i.toLong() * 10) + } + val inProgressStroke = InProgressStroke() + inProgressStroke.start(brush) + inProgressStroke.enqueueInputs(inputs, MutableStrokeInputBatch()) + inProgressStroke.updateShape(numPoints.toLong() * 10) + inProgressStroke.toImmutable() + } + + val surface = MaterialTheme.colorScheme.surface + val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant + + Canvas(modifier = Modifier.fillMaxSize()) { + // Checkerboard background + val tileSize = 7.dp.toPx() + val numTilesX = (size.width / tileSize).toInt() + 2 + val numTilesY = (size.height / tileSize).toInt() + 2 + val startX = (size.width - numTilesX * tileSize) / 2f + val startY = (size.height - numTilesY * tileSize) / 2f + for (ix in 0 until numTilesX) { + for (iy in 0 until numTilesY) { + val color = if ((ix + iy) % 2 == 0) surface else surfaceVariant + drawRect( + color = color, + topLeft = Offset(startX + ix * tileSize, startY + iy * tileSize), + size = Size(tileSize, tileSize), + ) + } + } + + if (stroke != null) { + drawIntoCanvas { canvas -> + strokeRenderer.draw(canvas.nativeCanvas, stroke, Matrix()) + } + } + } + } +} + +@Composable +fun CoatPreviewWidget( + brushCoat: ProtoBrushCoat, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + val family by produceState(initialValue = StockBrushes.marker(), key1 = brushCoat) { + value = withContext(Dispatchers.IO) { + val familyProto = ProtoBrushFamily.newBuilder().addCoats(brushCoat).build() + runCatching { familyProto.toBrushFamily() }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget( + modifier = modifier, + family = family, + renderer = renderer, + brushSize = 10f, + showSingleInput = false, + ) +} + +@Composable +fun TipPreviewWidget( + brushTip: ProtoBrushTip, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + val family by produceState(initialValue = StockBrushes.marker(), key1 = brushTip) { + value = withContext(Dispatchers.IO) { + val familyProto = ProtoBrushFamily.newBuilder() + .addCoats(ProtoBrushCoat.newBuilder().setTip(brushTip).build()) + .build() + runCatching { familyProto.toBrushFamily() }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget(modifier = modifier, family = family, renderer = renderer) +} + +@Composable +fun ColorFunctionPreviewWidget( + colorFunction: ProtoColorFunction, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + val family by produceState(initialValue = StockBrushes.marker(), key1 = colorFunction) { + value = withContext(Dispatchers.IO) { + runCatching { + ProtoBrushFamily.newBuilder() + .addCoats( + ProtoBrushCoat.newBuilder() + .setTip(ProtoBrushTip.newBuilder().setCornerRounding(0f).build()) + .addPaintPreferences(ProtoBrushPaint.newBuilder().addColorFunctions(colorFunction).build()) + .build() + ) + .build() + .toBrushFamily() + }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget(modifier = modifier, family = family, renderer = renderer, brushSize = 30f, zoom = 3f) +} + +@Composable +fun TextureLayerPreviewWidget( + textureLayer: ProtoBrushPaint.TextureLayer, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, +) { + val family by produceState(initialValue = StockBrushes.marker(), key1 = textureLayer) { + value = withContext(Dispatchers.IO) { + runCatching { + ProtoBrushFamily.newBuilder() + .addCoats( + ProtoBrushCoat.newBuilder() + .setTip(ProtoBrushTip.newBuilder().setCornerRounding(0f).build()) + .addPaintPreferences(ProtoBrushPaint.newBuilder().addTextureLayers(textureLayer).build()) + .build() + ) + .build() + .toBrushFamily() + }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget(modifier = modifier, family = family, renderer = renderer, brushSize = 30f, zoom = 3f) +} + +@Composable +fun TextureWrapPreviewWidget( + wrapX: ProtoBrushPaint.TextureLayer.Wrap, + wrapY: ProtoBrushPaint.TextureLayer.Wrap, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, + clientTextureId: String = "", +) { + val family by produceState(initialValue = StockBrushes.marker(), key1 = Triple(wrapX, wrapY, clientTextureId)) { + value = withContext(Dispatchers.IO) { + val textureLayer = + ProtoBrushPaint.TextureLayer.newBuilder() + .setClientTextureId(clientTextureId) + .setSizeX(1f / 3f) + .setSizeY(1f / 3f) + .setWrapX(wrapX) + .setWrapY(wrapY) + .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) + .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) + .build() + + runCatching { + ProtoBrushFamily.newBuilder() + .addCoats( + ProtoBrushCoat.newBuilder() + .setTip(ProtoBrushTip.newBuilder().setCornerRounding(0f).build()) + .addPaintPreferences(ProtoBrushPaint.newBuilder().addTextureLayers(textureLayer).build()) + .build() + ) + .build() + .toBrushFamily() + }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget(modifier = modifier, family = family, renderer = renderer, brushSize = 30f, zoom = 3f) +} + +@Composable +fun BlendModePreviewWidget( + blendMode: ProtoBrushPaint.TextureLayer.BlendMode, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, + clientTextureId: String = "", +) { + val family by produceState(initialValue = StockBrushes.marker(), key1 = Pair(blendMode, clientTextureId)) { + value = withContext(Dispatchers.IO) { + val topLayer = + ProtoBrushPaint.TextureLayer.newBuilder() + .setClientTextureId(clientTextureId) + .setBlendMode(blendMode) + .setSizeX(1f) + .setSizeY(1f) + .build() + + val bottomLayer = + ProtoBrushPaint.TextureLayer.newBuilder() + .setClientTextureId(clientTextureId) + .setOffsetX(0.2f) + .setOffsetY(0.2f) + .setSizeX(1f) + .setSizeY(1f) + .build() + + runCatching { + ProtoBrushFamily.newBuilder() + .addCoats( + ProtoBrushCoat.newBuilder() + .setTip(ProtoBrushTip.newBuilder().setCornerRounding(0f).build()) + .addPaintPreferences( + ProtoBrushPaint.newBuilder() + .addTextureLayers(bottomLayer) + .addTextureLayers(topLayer) + .build() + ) + .build() + ) + .build() + .toBrushFamily() + }.getOrDefault(StockBrushes.marker()) + } + } + StrokePreviewWidget(modifier = modifier, family = family, renderer = renderer, brushSize = 30f, zoom = 3f) +} + +@Composable +fun StrokePreviewWidget( + family: BrushFamily, + renderer: CanvasStrokeRenderer, + modifier: Modifier = Modifier, + brushSize: Float = 30f, + showSingleInput: Boolean = true, + zoom: Float = 1f, +) { + val canvasBackground = MaterialTheme.colorScheme.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 0000000..dceeeb2 --- /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/BinaryOpNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/BinaryOpNodeFields.kt new file mode 100644 index 0000000..71a1232 --- /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( + modifier = modifier, + tooltipTitle = stringResource(R.string.bg_title_operation_format, stringResource(binaryNode.operation.displayStringRId())), + tooltipText = stringResource(binaryNode.operation.getTooltip()), + ) { + EnumDropdown( + label = stringResource(R.string.bg_operation), + currentValue = binaryNode.operation, + values = ALL_BINARY_OPS.toList(), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { op -> + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setBinaryOpNode(binaryNode.toBuilder().setOperation(op).build()) + .build() + ) + ) + onDropdownEditComplete() + } + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/CoatNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/CoatNodeFields.kt new file mode 100644 index 0000000..d45a426 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/CoatNodeFields.kt @@ -0,0 +1,34 @@ +/* + * * Copyright 2026 Google LLC. All rights reserved. + * * + * * Licensed under the Apache License, Version 2.0 (the "License"); + * * you may not use this file except in compliance with the License. + * * You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, software + * * distributed under the License is distributed on an "AS IS" BASIS, + * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * * See the License for the specific language governing permissions and + * * limitations under the License. + */ +package com.example.cahier.developer.brushgraph.ui.fields + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.example.cahier.R + +@Composable +fun CoatNodeFields( + modifier: Modifier = Modifier +) { + Text( + modifier = modifier, + text = stringResource(R.string.bg_coat_node_description), + style = MaterialTheme.typography.bodySmall, + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ConstantNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ConstantNodeFields.kt new file mode 100644 index 0000000..d6411af --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/ConstantNodeFields.kt @@ -0,0 +1,49 @@ +/* + * * 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( + modifier = modifier, + title = stringResource(R.string.bg_port_value), + value = constantNode.value, + limits = NumericLimits(-100f, 100f, 0.01f), + onValueChanged = { + onUpdate( + NodeData.Behavior(behaviorNode.toBuilder().setConstantNode(constantNode.toBuilder().setValue(it).build()).build()) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/DampingNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/DampingNodeFields.kt new file mode 100644 index 0000000..3882c14 --- /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( + modifier = modifier, + tooltipTitle = stringResource(R.string.bg_title_damping_source_format, stringResource(dampingNode.dampingSource.displayStringRId())), + tooltipText = stringResource(dampingNode.dampingSource.getTooltip()), + ) { + EnumDropdown( + label = stringResource(R.string.bg_damping_source), + currentValue = dampingNode.dampingSource, + values = ALL_PROGRESS_DOMAINS.toList(), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { domain -> + val newLimits = domain.getNumericLimits(ProgressDomainContext.DAMPING) + val clampedGap = dampingNode.dampingGap.coerceIn(newLimits.min, newLimits.max) + + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setDampingNode( + dampingNode.toBuilder() + .setDampingSource(domain) + .setDampingGap(clampedGap) + .build() + ) + .build() + ) + ) + onDropdownEditComplete() + } + ) + } + + NumericField( + title = stringResource(R.string.bg_label_damping_gap), + value = dampingNode.dampingGap, + limits = limits, + onValueChanged = { + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setDampingNode(dampingNode.toBuilder().setDampingGap(it).build()) + .build() + ) + ) + }, + onValueChangeFinished = onFieldEditComplete + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/FieldsUtils.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/FieldsUtils.kt new file mode 100644 index 0000000..59f4c40 --- /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 0000000..5e88859 --- /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 0000000..ff4d875 --- /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( + modifier = modifier, + tooltipTitle = stringResource(R.string.bg_title_interpolation_format, stringResource(interpNode.interpolation.displayStringRId())), + tooltipText = stringResource(interpNode.interpolation.getTooltip()), + ) { + EnumDropdown( + label = stringResource(R.string.bg_interpolation), + currentValue = interpNode.interpolation, + values = ALL_INTERPOLATIONS.toList(), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { interp -> + onUpdate( + NodeData.Behavior( + behaviorNode.toBuilder() + .setInterpolationNode(interpNode.toBuilder().setInterpolation(interp).build()) + .build() + ) + ) + } + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/PaintNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/PaintNodeFields.kt new file mode 100644 index 0000000..ae6b372 --- /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( + modifier = modifier.padding(vertical = 4.dp), + tooltipTitle = stringResource(R.string.bg_title_self_overlap_format, stringResource(paint.selfOverlap.displayStringRId())), + tooltipText = stringResource(paint.selfOverlap.getTooltip()), + ) { + EnumDropdown( + label = stringResource(R.string.bg_self_overlap), + currentValue = paint.selfOverlap, + values = listOf( + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ANY, + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ACCUMULATE, + ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_DISCARD, + ), + displayName = { stringResource(it.displayStringRId()) }, + onSelected = { so -> + onUpdate(NodeData.Paint(paint.toBuilder().setSelfOverlap(so).build(), texturePortIds = data.texturePortIds, colorPortIds = data.colorPortIds)) + onDropdownEditComplete() + } + ) + } +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TipNodeFields.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/fields/TipNodeFields.kt new file mode 100644 index 0000000..2246e0b --- /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(modifier = modifier, brushTip = tip, renderer = strokeRenderer) + + NumericField( + title = stringResource(R.string.bg_label_scale_x), + value = tip.scaleX, + limits = NumericLimits(0f, 2f, 0.01f), + onValueChanged = { onUpdate(NodeData.Tip(tip.toBuilder().setScaleX(it).build(), behaviorPortIds = data.behaviorPortIds)) }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_scale_y), + value = tip.scaleY, + limits = NumericLimits(0f, 2f, 0.01f), + onValueChanged = { onUpdate(NodeData.Tip(tip.toBuilder().setScaleY(it).build(), behaviorPortIds = data.behaviorPortIds)) }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_corner_rounding), + value = tip.cornerRounding, + limits = NumericLimits(0f, 1f, 0.01f), + onValueChanged = { onUpdate(NodeData.Tip(tip.toBuilder().setCornerRounding(it).build(), behaviorPortIds = data.behaviorPortIds)) }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_slant_degrees), + value = tip.slantRadians, + limits = NumericLimits.radiansShownAsDegrees(-90f, 90f), + onValueChanged = { onUpdate(NodeData.Tip(tip.toBuilder().setSlantRadians(it).build(), behaviorPortIds = data.behaviorPortIds)) }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_pinch), + value = tip.pinch, + limits = NumericLimits(0f, 1f, 0.01f), + onValueChanged = { onUpdate(NodeData.Tip(tip.toBuilder().setPinch(it).build(), behaviorPortIds = data.behaviorPortIds)) }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_rotation_degrees), + value = tip.rotationRadians, + limits = NumericLimits.radiansShownAsDegrees(0f, 360f), + onValueChanged = { onUpdate(NodeData.Tip(tip.toBuilder().setRotationRadians(it).build(), behaviorPortIds = data.behaviorPortIds)) }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_particle_gap_distance_scale), + value = tip.particleGapDistanceScale, + limits = NumericLimits(0f, 5f, 0.01f), + onValueChanged = { onUpdate(NodeData.Tip(tip.toBuilder().setParticleGapDistanceScale(it).build(), behaviorPortIds = data.behaviorPortIds)) }, + onValueChangeFinished = onFieldEditComplete + ) + NumericField( + title = stringResource(R.string.bg_label_particle_gap_duration_ms), + value = tip.particleGapDurationSeconds, + limits = NumericLimits(0f, 1000f, 1f, "ms", unitScale = 1000f), + onValueChanged = { onUpdate(NodeData.Tip(tip.toBuilder().setParticleGapDurationSeconds(it).build(), behaviorPortIds = data.behaviorPortIds)) }, + onValueChangeFinished = onFieldEditComplete + ) +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeHeader.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeHeader.kt new file mode 100644 index 0000000..d37f309 --- /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 0000000..5a68235 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodePortDots.kt @@ -0,0 +1,165 @@ +/* + * * 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, + canvasCoordinates: LayoutCoordinates?, + onPortDrag: (PortSide, String, Boolean) -> Unit, + onPortDragUpdate: (Offset) -> Unit, + onPortDragEnd: () -> Unit, + getPortPosition: (String, Boolean) -> Offset, + onPortPositioned: (String, Offset) -> Unit, + onReorderPorts: (String, Int, Int) -> Unit, + modifier: Modifier = Modifier, +) { + var activeReorderPortIndex by remember { mutableStateOf(null) } + var cumulativeDeltaY by remember { mutableFloatStateOf(0f) } + val hasAddPort = visiblePorts.any { it.isAddPort } + + val portCounts = remember(graph.edges, graph.nodes, node.id) { + if (node.data is NodeData.Paint) { + val nodesById = graph.nodes.associateBy { it.id } + val textureCount = graph.edges.count { edge -> + edge.toNodeId == node.id && nodesById[edge.fromNodeId]?.data is NodeData.TextureLayer + } + val colorCount = graph.edges.count { edge -> + edge.toNodeId == node.id && nodesById[edge.fromNodeId]?.data is NodeData.ColorFunction + } + Pair(textureCount, colorCount) + } else { + Pair(0, 0) + } + } + val T = portCounts.first + val C = portCounts.second + + for ((index, port) in visiblePorts.withIndex()) { + val edge = graph.edges.find { it.toNodeId == node.id && it.toPortId == port.id } + val portKey = edge?.let { "${it.fromNodeId}_${port.id}" } ?: "port_${port.id}" + key(portKey) { + PortDot( + modifier = modifier.align(Alignment.TopStart), + port = port, + count = visiblePorts.size, + zoom = zoom, + onDrag = onPortDrag, + onDragUpdate = onPortDragUpdate, + onDragEnd = onPortDragEnd, + onPortPositioned = { pos -> onPortPositioned(port.id, pos) }, + canvasCoordinates = canvasCoordinates, + portPosition = getPortPosition(port.id, true) - position, + isReorderable = node.data.isPortReorderable(port, index, hasAddPort), + isLargeHandle = node.data is NodeData.Behavior && node.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE && index % 2 == 0, + onReorderUpdate = { deltaY -> + if (activeReorderPortIndex != index) { + activeReorderPortIndex = index + cumulativeDeltaY = 0f + } + cumulativeDeltaY += deltaY + + val originalY = getPortPosition(port.id, true).y - position.y + var maxValidIndex = visiblePorts.size - 2 // Exclude add port + var minValidIndex = if (node.data is NodeData.Coat) 1 else 0 + + if (node.data is NodeData.Paint) { + if (index in 0 until T) { + minValidIndex = 0 + maxValidIndex = T - 1 + } else if (index in (T + 1) until (T + 1 + C)) { + minValidIndex = T + 1 + maxValidIndex = T + 1 + C - 1 + } + } + + val minDragY = NODE_PADDING_VERTICAL + node.data.titleHeight() + (0 + 0.5f) * INPUT_ROW_HEIGHT + val maxDragY = NODE_PADDING_VERTICAL + node.data.titleHeight() + (visiblePorts.size - 1 + 0.5f) * INPUT_ROW_HEIGHT + + val requestedY = originalY + cumulativeDeltaY + + val isPolarTarget = node.data is NodeData.Behavior && node.data.node.nodeCase == ink.proto.BrushBehavior.Node.NodeCase.POLAR_TARGET_NODE + + val targetIndex = if (isPolarTarget) { + val setSize = 2 + val currentSet = ((requestedY - NODE_PADDING_VERTICAL - node.data.titleHeight()) / (INPUT_ROW_HEIGHT * setSize) - 0.5f).roundToInt() + currentSet * setSize + } else { + ((requestedY - NODE_PADDING_VERTICAL - node.data.titleHeight()) / INPUT_ROW_HEIGHT - 0.5f).roundToInt() + } + + val currentY = requestedY.coerceIn(minDragY, maxDragY) + cumulativeDeltaY = currentY - originalY + + if (targetIndex in minValidIndex..maxValidIndex && targetIndex != index) { + onReorderPorts(node.id, index, targetIndex) + cumulativeDeltaY -= (targetIndex - index) * INPUT_ROW_HEIGHT + activeReorderPortIndex = targetIndex + } + }, + onReorderEnd = { + activeReorderPortIndex = null + cumulativeDeltaY = 0f + }, + isDragging = index == activeReorderPortIndex, + dragOffset = if (index == activeReorderPortIndex) cumulativeDeltaY else 0f + ) + } + } + if (node.data.hasOutput()) { + PortDot( + modifier = modifier.align(Alignment.TopEnd), + port = Port.Output(node.id, "output"), + count = 1, + 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 0000000..50147a7 --- /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 0000000..ba227a6 --- /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 0000000..407b220 --- /dev/null +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/NodeWidget.kt @@ -0,0 +1,273 @@ +/* + * * 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, + allTextureIds: Set, + strokeRenderer: CanvasStrokeRenderer, + textFieldsLocked: Boolean, + brush: Brush, + onChooseColor: (Color, (Color) -> Unit) -> Unit, + onLoadTexture: () -> Unit, + onMove: (Offset) -> Unit, + onClick: () -> Unit, + onUpdate: (NodeData) -> Unit, + modifier: Modifier = Modifier, + canvasCoordinates: LayoutCoordinates? = null, + isSelected: Boolean = false, + isSelectionMode: Boolean = false, + isInSelectedSet: Boolean = false, + onDragStart: () -> Unit = {}, + onDrag: (PointerInputChange) -> Unit = {}, + onDragEnd: () -> Unit = {}, + onPortDrag: (PortSide, String, Boolean) -> Unit = { _, _, _ -> }, + onPortDragUpdate: (Offset) -> Unit = {}, + onPortDragEnd: () -> Unit = {}, + onReorderPorts: (String, Int, Int) -> Unit = { _, _, _ -> }, + onPortClick: (String, Port) -> Unit = { _, _ -> }, + getPortPosition: (String, Boolean) -> Offset, + onPortPositioned: (String, Offset) -> Unit, + onClearNodeCache: () -> Unit, + onLongPress: () -> Unit = {}, +) { + var isPressed by remember { mutableStateOf(false) } + val density = LocalDensity.current + val visiblePorts = remember(node.data, graph) { node.getVisiblePorts(graph) } + + androidx.compose.runtime.DisposableEffect(node.id) { + onDispose { + onClearNodeCache() + } + } + + val currentOnMove by androidx.compose.runtime.rememberUpdatedState(onMove) + val currentOnDragStart by androidx.compose.runtime.rememberUpdatedState(onDragStart) + val currentOnDragEnd by androidx.compose.runtime.rememberUpdatedState(onDragEnd) + val currentOnDragUpdate by androidx.compose.runtime.rememberUpdatedState(onDrag) + + val w = node.data.width() + val h = node.data.height(visiblePorts.size) + + val backgroundColorByNodeData = + 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 + } + val (backgroundColor, outlineWeight, outlineColor) = when { + node.isDisabled -> + Triple(MaterialTheme.colorScheme.surfaceDim, + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.38f)) + isActiveSource || isPressed || isSelected || isInSelectedSet -> + Triple(MaterialTheme.colorScheme.primaryContainer, + 2.dp, + MaterialTheme.colorScheme.primary) + node.hasError -> + Triple(MaterialTheme.colorScheme.errorContainer, + 2.dp, + MaterialTheme.colorScheme.error) + node.hasWarning -> + Triple(MaterialTheme.extendedColorScheme.warningContainer, + 2.dp, + MaterialTheme.extendedColorScheme.warning) + else -> + Triple(backgroundColorByNodeData, + 1.dp, + MaterialTheme.colorScheme.outline) + } + Box( + modifier = + modifier.zIndex(if (isSelected) 1f else 0f) + .offset { IntOffset(position.x.roundToInt(), position.y.roundToInt()) } + .pointerInput(node.id, isSelected, isSelectionMode) { + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false + } + }, + onTap = { onClick() }, + onLongPress = { onLongPress() } + ) + } + .pointerInput(node.id, isSelected) { + detectDragGestures( + onDragStart = { currentOnDragStart() }, + onDragEnd = { currentOnDragEnd() }, + onDragCancel = { currentOnDragEnd() }, + onDrag = { change, dragAmount -> + change.consume() + currentOnMove(dragAmount) + currentOnDragUpdate(change) + }, + ) + } + .then( + with(density) { + Modifier.width(w.toDp()) + .height(h.toDp()) + .background( + backgroundColor, + RoundedCornerShape(8.dp), + ) + .border( + outlineWeight, + outlineColor, + RoundedCornerShape(8.dp), + ) + } + ) + ) { + Box(modifier = Modifier.fillMaxSize().alpha(if (node.isDisabled) 0.38f else 1f)) { + Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.Top) { + val nodeWidthDp = with(density) { NODE_WIDTH.toDp() } + val topPaddingDp = with(density) { NODE_PADDING_VERTICAL.toDp() } + val bottomPaddingDp = with(density) { NODE_PADDING_BOTTOM.toDp() } + Box( + modifier = Modifier.width(nodeWidthDp).fillMaxHeight().padding(bottom = bottomPaddingDp) + ) { + Column(modifier = Modifier.fillMaxSize().padding(top = topPaddingDp)) { + NodeHeader( + node = node, + graph = graph, + strokeRenderer = strokeRenderer + ) + + 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 + Box(modifier = Modifier.fillMaxHeight().width(2.dp).background(outlineColor)) + + SineWavePreview( + brush = brush, + strokeRenderer = strokeRenderer, + modifier = + Modifier.weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surface) + .clip(RoundedCornerShape(topEnd = 8.dp, bottomEnd = 8.dp)), + ) + } + } + + if (isSelectionMode && node.data !is NodeData.Family) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 6.dp, y = (-6).dp) + .size(16.dp) + .background( + if (isInSelectedSet) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface, + CircleShape + ) + .border(1.dp, MaterialTheme.colorScheme.primary, CircleShape) + ) + } + } +} +} diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/PortDot.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/node/PortDot.kt new file mode 100644 index 0000000..0d3efdb --- /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, + zoom: Float, + canvasCoordinates: LayoutCoordinates?, + portPosition: Offset, + onDrag: (PortSide, String, Boolean) -> Unit, + onDragUpdate: (Offset) -> Unit, + onDragEnd: () -> Unit, + onPortPositioned: (Offset) -> Unit, + modifier: Modifier = Modifier, + isDragging: Boolean = false, + dragOffset: Float = 0f, + isLargeHandle: Boolean = false, + isReorderable: Boolean = false, + onReorderUpdate: (Float) -> Unit = {}, + onReorderEnd: () -> Unit = {}, +) { + var portCoordinates by remember { mutableStateOf(null) } + val density = LocalDensity.current + + val currentOnDrag by androidx.compose.runtime.rememberUpdatedState(onDrag) + val currentOnDragUpdate by androidx.compose.runtime.rememberUpdatedState(onDragUpdate) + val currentOnDragEnd by androidx.compose.runtime.rememberUpdatedState(onDragEnd) + val currentOnReorderUpdate by androidx.compose.runtime.rememberUpdatedState(onReorderUpdate) + val currentOnReorderEnd by androidx.compose.runtime.rememberUpdatedState(onReorderEnd) + val currentPortId by androidx.compose.runtime.rememberUpdatedState(port.id) + val currentPortSide by androidx.compose.runtime.rememberUpdatedState(port.side) + + val outerWidth = if (port.side == PortSide.INPUT) 24.dp else 12.dp + val outerHeight = if (port.side == PortSide.INPUT) 32.dp else 12.dp + val outerX = if (port.side == PortSide.INPUT) (-24).dp else 14.dp + + val animatedY by animateDpAsState( + targetValue = with(density) { portPosition.y.toDp() } - (if (port.side == PortSide.INPUT) 16.dp else 6.dp), + label = "portY" + ) + val finalY = if (isDragging) with(density) { portPosition.y.toDp() } - (if (port.side == PortSide.INPUT) 16.dp else 6.dp) else animatedY + + Box( + modifier = + modifier + .offset { + IntOffset(outerX.roundToPx(), finalY.roundToPx()) + } + .size(width = outerWidth, height = outerHeight) + .graphicsLayer { + if (isDragging) { + translationY = dragOffset + } + } + .zIndex(if (isDragging) 1f else 0f) + ) { + if (port.side == PortSide.INPUT) { + // Input Port (Left Half) + Box( + modifier = Modifier + .align(Alignment.TopStart) + .size(width = 12.dp, height = 32.dp) + .pointerInput(port.nodeId, port.side, canvasCoordinates, zoom) { + detectPortDragGestures( + zoom = zoom, + onDragStart = { currentOnDrag(currentPortSide, currentPortId, true) }, + onDragEnd = { currentOnDragEnd() }, + onDragCancel = { currentOnDragEnd() }, + ) { change, _ -> + change.consume() + val canvasCo = canvasCoordinates + val portCo = portCoordinates + if (canvasCo != null && portCo != null && canvasCo.isAttached && portCo.isAttached) { + val graphSpacePos = canvasCo.localPositionOf(portCo, change.position) + currentOnDragUpdate(graphSpacePos) + } + } + } + ) { + Box( + modifier = Modifier + .align(Alignment.Center) + .size(12.dp) + .background( + if (isDragging) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) + .onGloballyPositioned { coordinates: androidx.compose.ui.layout.LayoutCoordinates -> + portCoordinates = coordinates + val canvasCo = canvasCoordinates + if (canvasCo != null && coordinates.isAttached) { + val center = Offset(coordinates.size.width / 2f, coordinates.size.height / 2f) + val graphSpacePos = canvasCo.localPositionOf(coordinates, center) + onPortPositioned(graphSpacePos) + } + } + ) + } + + // Reorder Handle (Right Half) + if (isReorderable) { + val handleHeight = if (isLargeHandle) with(density) { (INPUT_ROW_HEIGHT * 2).toDp() } else 32.dp + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .size(width = 12.dp, height = handleHeight) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), RoundedCornerShape(2.dp)) + .pointerInput(port.nodeId, port.side, zoom) { + detectPortDragGestures( + zoom = zoom, + onDrag = { change, dragAmount -> + change.consume() + currentOnReorderUpdate(dragAmount.y) + }, + onDragEnd = { currentOnReorderEnd() }, + onDragCancel = { currentOnReorderEnd() } + ) + } + ) { + Icon( + painter = painterResource(R.drawable.gs_drag_indicator_vd_theme_24), + contentDescription = stringResource(R.string.bg_cd_reorder), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center).size(12.dp) + ) + } + } + } else { + // Output Port + Box( + modifier = Modifier + .align(Alignment.Center) + .size(12.dp) + .background( + if (isDragging) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) + .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape) + .onGloballyPositioned { coordinates: androidx.compose.ui.layout.LayoutCoordinates -> + portCoordinates = coordinates + val canvasCo = canvasCoordinates + if (canvasCo != null && coordinates.isAttached) { + val center = Offset(coordinates.size.width / 2f, coordinates.size.height / 2f) + val graphSpacePos = canvasCo.localPositionOf(coordinates, center) + onPortPositioned(graphSpacePos) + } + } + .pointerInput(port.nodeId, port.side, canvasCoordinates, zoom) { + detectPortDragGestures( + zoom = zoom, + onDragStart = { currentOnDrag(currentPortSide, currentPortId, true) }, + onDragEnd = { currentOnDragEnd() }, + onDragCancel = { currentOnDragEnd() }, + ) { change, _ -> + change.consume() + val canvasCo = canvasCoordinates + val portCo = portCoordinates + if (canvasCo != null && portCo != null && canvasCo.isAttached && portCo.isAttached) { + val graphSpacePos = canvasCo.localPositionOf(portCo, change.position) + currentOnDragUpdate(graphSpacePos) + } + } + } + ) + } + } +} + +suspend fun androidx.compose.ui.input.pointer.PointerInputScope.detectPortDragGestures( + zoom: Float, + onDragStart: (Offset) -> Unit = {}, + onDragEnd: () -> Unit = {}, + onDragCancel: () -> Unit = {}, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, +) { + val touchSlop = viewConfiguration.touchSlop / zoom + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var drag: PointerInputChange? = null + var dragStartedCalled = false + + // Wait for the pointer to move beyond the (zoom-adjusted) touch slop. + val pointerId = down.id + var totalMainPositionChange = Offset.Zero + while (true) { + val event = awaitPointerEvent() + val dragEvent = event.changes.firstOrNull { it.id == pointerId } ?: break + if (dragEvent.isConsumed) break + if (dragEvent.changedToUpIgnoreConsumed()) break + + val positionChange = dragEvent.positionChange() + totalMainPositionChange += positionChange + val distance = totalMainPositionChange.getDistance() + if (distance >= touchSlop) { + onDragStart(dragEvent.position) + dragStartedCalled = true + onDrag(dragEvent, totalMainPositionChange) + if (dragEvent.isConsumed) { + drag = dragEvent + } + break + } + if (event.changes.all { it.changedToUpIgnoreConsumed() }) break + } + + if (drag != null || totalMainPositionChange.getDistance() >= touchSlop) { + val dragSuccessful = + drag(pointerId) { + onDrag(it, it.positionChange()) + it.consume() + } + if (!dragSuccessful) { + if (dragStartedCalled) onDragCancel() + } else { + if (dragStartedCalled) onDragEnd() + } + } + } +} diff --git a/app/src/main/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 0000000..b10d45c --- /dev/null +++ b/app/src/main/res/drawable/gs_drag_indicator_vd_theme_24.xml @@ -0,0 +1,10 @@ + + + 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 0000000..371fe08 --- /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 0000000..cbf344c --- /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 bb643ac..6c223bc 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" }