From 9ba7e0ed4527a9f1ca8663b10060e1bc1afd3688 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:49:29 +0000 Subject: [PATCH 1/3] Delete tabbed brushdesigner --- .../data/BrushDesignerRepository.kt | 74 -- .../brushdesigner/ui/BehaviorsTab.kt | 334 --------- .../brushdesigner/ui/BrushDesignerScreen.kt | 647 ------------------ .../brushdesigner/ui/BrushDesignerTab.kt | 29 - .../brushdesigner/ui/BrushDesignerTopBar.kt | 428 ------------ .../brushdesigner/ui/EditableListWidget.kt | 218 ------ .../brushdesigner/ui/MathCurvePreview.kt | 158 ----- .../developer/brushdesigner/ui/NodeEditors.kt | 578 ---------------- .../developer/brushdesigner/ui/PaintTab.kt | 581 ---------------- .../developer/brushdesigner/ui/PreviewPane.kt | 311 --------- .../developer/brushdesigner/ui/TipPreview.kt | 130 ---- .../developer/brushdesigner/ui/TipShapeTab.kt | 166 ----- .../viewmodel/BrushDesignerViewModel.kt | 526 -------------- .../viewmodel/PrefabBehaviors.kt | 309 --------- 14 files changed, 4489 deletions(-) delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/data/BrushDesignerRepository.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTab.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt delete mode 100644 app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/PrefabBehaviors.kt diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/data/BrushDesignerRepository.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/data/BrushDesignerRepository.kt deleted file mode 100644 index 5ce99c18..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/data/BrushDesignerRepository.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * - * * Copyright 2025 Google LLC. All rights reserved. - * * - * * Licensed under the Apache License, Version 2.0 (the "License"); - * * you may not use this file except in compliance with the License. - * * You may obtain a copy of the License at - * * - * * http://www.apache.org/licenses/LICENSE-2.0 - * * - * * Unless required by applicable law or agreed to in writing, software - * * distributed under the License is distributed on an "AS IS" BASIS, - * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * * See the License for the specific language governing permissions and - * * limitations under the License. - * - */ - -package com.example.cahier.developer.brushdesigner.data - -import androidx.ink.strokes.Stroke -import ink.proto.BrushCoat as ProtoBrushCoat -import ink.proto.BrushFamily as ProtoBrushFamily -import ink.proto.BrushPaint as ProtoBrushPaint -import ink.proto.BrushTip as ProtoBrushTip -import ink.proto.ColorFunction as ProtoColorFunction -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -@Singleton -class BrushDesignerRepository @Inject constructor() { - - private val initialProto: ProtoBrushFamily = ProtoBrushFamily.newBuilder() - .addCoats( - ProtoBrushCoat.newBuilder() - .setTip( - ProtoBrushTip.newBuilder().setScaleX(1f).setScaleY(1f).setCornerRounding(1f) - ) - .addPaintPreferences( - ProtoBrushPaint.newBuilder() - .setSelfOverlap(ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_ANY) - .addColorFunctions( - ProtoColorFunction.newBuilder() - .setOpacityMultiplier(1f) - ) - ) - ) - .setInputModel( - ProtoBrushFamily.InputModel.newBuilder() - .setSlidingWindowModel( - ProtoBrushFamily.SlidingWindowModel.newBuilder() - .setWindowSizeSeconds(0.02f) - .setExperimentalUpsamplingPeriodSeconds(0.005f) - ) - ) - .build() - - private val _activeBrushProto = MutableStateFlow(initialProto) - val activeBrushProto: StateFlow = _activeBrushProto.asStateFlow() - - private val _testStrokes = MutableStateFlow>(emptyList()) - val testStrokes: StateFlow> = _testStrokes.asStateFlow() - - fun updateActiveBrushProto(proto: ProtoBrushFamily) { - _activeBrushProto.value = proto - } - - fun updateTestStrokes(strokes: List) { - _testStrokes.value = strokes - } -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt deleted file mode 100644 index ef03c765..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BehaviorsTab.kt +++ /dev/null @@ -1,334 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Button -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.example.cahier.R -import com.example.cahier.developer.brushdesigner.viewmodel.PrefabBehaviors -import ink.proto.BrushBehavior -import ink.proto.BrushFamily as ProtoBrushFamily - -/** - * Tab 2: Dynamics & Behaviors controls — editable behavior stack with nested - * node graph editor, standard dynamics presets, and advanced dynamics presets. - * - * Uses [EditableListWidget] for managing behaviors (outer list) and nodes - * within each behavior (inner nested list), with [NodeEditor] for - * type-specific node editing. - * - * Stateless: receives data and callbacks, does not access ViewModel. - */ -@Composable -internal fun BehaviorsTabContent( - activeProto: ProtoBrushFamily, - selectedCoatIndex: Int, - onUpdateBehaviors: (List) -> Unit, - onAddBehavior: (List) -> Unit, -) { - val behaviors = activeProto - .coatsList.getOrNull(selectedCoatIndex)?.tip?.behaviorsList - ?: emptyList() - - Text( - stringResource(R.string.brush_designer_dynamics_behaviors), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp) - ) - - EditableListWidget( - title = stringResource(R.string.brush_designer_behavior_stack), - items = behaviors, - defaultItem = BrushBehavior.newBuilder() - .addNodes( - BrushBehavior.Node.newBuilder().setSourceNode( - BrushBehavior.SourceNode.newBuilder() - .setSource(BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE) - .setSourceValueRangeStart(0f) - .setSourceValueRangeEnd(1f) - .setSourceOutOfRangeBehavior( - BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP - ) - ) - ) - .addNodes( - BrushBehavior.Node.newBuilder().setTargetNode( - BrushBehavior.TargetNode.newBuilder() - .setTarget(BrushBehavior.Target.TARGET_SIZE_MULTIPLIER) - .setTargetModifierRangeStart(0.5f) - .setTargetModifierRangeEnd(1.5f) - ) - ) - .build(), - onItemsChanged = onUpdateBehaviors, - itemHeader = { behavior -> - val source = behavior.nodesList - .find { it.hasSourceNode() } - ?.sourceNode?.source?.name?.replace("SOURCE_", "") - ?: "INPUT" - val target = behavior.nodesList - .find { it.hasTargetNode() } - ?.targetNode?.target?.name?.replace("TARGET_", "") - ?: "OUTPUT" - "$source ➔ $target" - }, - editorContent = { behavior, onBehaviorChanged -> - BehaviorNodeGraphEditor( - behavior = behavior, - onBehaviorChanged = onBehaviorChanged - ) - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(8.dp)) - - StandardDynamicsSection(onAddBehavior = onAddBehavior) - - Spacer(modifier = Modifier.height(16.dp)) - - AdvancedDynamicsSection( - onAddBehavior = onAddBehavior - ) -} - -/** - * Nested node graph editor for a single [BrushBehavior]. - * Uses a nested [EditableListWidget] to manage the ordered list of nodes. - */ -@Composable -private fun BehaviorNodeGraphEditor( - behavior: BrushBehavior, - onBehaviorChanged: (BrushBehavior) -> Unit, -) { - Text( - stringResource(R.string.brush_designer_nodes_in_behavior), - style = MaterialTheme.typography.labelLarge - ) - - EditableListWidget( - title = "", - items = behavior.nodesList, - defaultItem = BrushBehavior.Node.newBuilder() - .setConstantNode( - BrushBehavior.ConstantNode.newBuilder().setValue(1f) - ) - .build(), - onItemsChanged = { newNodes -> - onBehaviorChanged( - behavior.toBuilder().clearNodes().addAllNodes(newNodes).build() - ) - }, - itemHeader = { node -> - node.nodeCase.name.replace("_NODE", "") - }, - editorContent = { node, onNodeChanged -> - NodeEditor(node = node, onNodeChanged = onNodeChanged) - } - ) -} - -/** - * Standard un-damped dynamics presets using [PrefabBehaviors] simple variants. - */ -@Composable -private fun StandardDynamicsSection( - onAddBehavior: (List) -> Unit, -) { - Text( - stringResource(R.string.brush_designer_standard_dynamics), - style = MaterialTheme.typography.labelLarge - ) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(modifier = Modifier.weight(1f), onClick = { - onAddBehavior(PrefabBehaviors.simplePressureToSize()) - }) { Text(stringResource(R.string.brush_designer_pressure_size)) } - - Button(modifier = Modifier.weight(1f), onClick = { - onAddBehavior(PrefabBehaviors.simpleSpeedToSize()) - }) { Text(stringResource(R.string.brush_designer_speed_size)) } - } - - Button(modifier = Modifier.fillMaxWidth(), onClick = { - onAddBehavior(PrefabBehaviors.simpleSpeedToOpacity()) - }) { Text(stringResource(R.string.brush_designer_speed_opacity)) } - } -} - -/** - * Advanced smoothed/jitter presets from [PrefabBehaviors]. - * All 17 prefabs are exposed here organized by category. - */ -@Composable -private fun AdvancedDynamicsSection( - onAddBehavior: (List) -> Unit, -) { - Text( - stringResource(R.string.brush_designer_advanced_dynamics), - style = MaterialTheme.typography.labelLarge - ) - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - // ── Pressure (smoothed) ── - PrefabButton( - label = stringResource(R.string.brush_designer_smooth_pressure_size), - iconRes = R.drawable.brush_24px, - onClick = { onAddBehavior(PrefabBehaviors.pressureToSize()) } - ) - PrefabButton( - label = "Pressure → Width", - iconRes = R.drawable.brush_24px, - onClick = { onAddBehavior(PrefabBehaviors.pressureToWidth()) } - ) - PrefabButton( - label = "Pressure → Opacity", - iconRes = R.drawable.brush_24px, - onClick = { onAddBehavior(PrefabBehaviors.pressureToOpacity()) } - ) - - Spacer(modifier = Modifier.height(4.dp)) - - // ── Tilt (smoothed) ── - PrefabButton( - label = "Tilt → Width", - iconRes = R.drawable.brush_24px, - onClick = { onAddBehavior(PrefabBehaviors.tiltToWidth()) } - ) - PrefabButton( - label = "Tilt → Slant", - iconRes = R.drawable.brush_24px, - onClick = { onAddBehavior(PrefabBehaviors.tiltToSlant()) } - ) - PrefabButton( - label = "Tilt → Opacity", - iconRes = R.drawable.brush_24px, - onClick = { onAddBehavior(PrefabBehaviors.tiltToOpacity()) } - ) - - Spacer(modifier = Modifier.height(4.dp)) - - // ── Speed (smoothed) ── - PrefabButton( - label = stringResource(R.string.brush_designer_smooth_speed_opacity), - iconRes = R.drawable.opacity_24px, - onClick = { onAddBehavior(PrefabBehaviors.speedToOpacity()) } - ) - PrefabButton( - label = "Speed → Size (Smoothed)", - iconRes = R.drawable.brush_24px, - onClick = { onAddBehavior(PrefabBehaviors.speedToSize()) } - ) - PrefabButton( - label = "Speed → Width", - iconRes = R.drawable.brush_24px, - onClick = { onAddBehavior(PrefabBehaviors.speedToWidth()) } - ) - - Spacer(modifier = Modifier.height(4.dp)) - - // ── Direction ── - PrefabButton( - label = "Direction → Rotation", - iconRes = R.drawable.brush_24px, - onClick = { onAddBehavior(PrefabBehaviors.directionToRotation()) } - ) - - Spacer(modifier = Modifier.height(4.dp)) - - // ── Distance / Time ── - PrefabButton( - label = "Distance → Opacity Fade", - iconRes = R.drawable.opacity_24px, - onClick = { onAddBehavior(PrefabBehaviors.distanceToOpacityFade()) } - ) - PrefabButton( - label = "Time → Opacity Fade", - iconRes = R.drawable.opacity_24px, - onClick = { onAddBehavior(PrefabBehaviors.timeToOpacityFade()) } - ) - - Spacer(modifier = Modifier.height(4.dp)) - - // ── Jitter ── - PrefabButton( - label = stringResource(R.string.brush_designer_pencil_jitter), - iconRes = R.drawable.texture_24px, - onClick = { onAddBehavior(PrefabBehaviors.slantJitter()) } - ) - PrefabButton( - label = "Width Jitter", - iconRes = R.drawable.texture_24px, - onClick = { onAddBehavior(PrefabBehaviors.widthJitter()) } - ) - PrefabButton( - label = "Opacity Jitter", - iconRes = R.drawable.texture_24px, - onClick = { onAddBehavior(PrefabBehaviors.opacityJitter()) } - ) - PrefabButton( - label = "Hue Jitter", - iconRes = R.drawable.texture_24px, - onClick = { onAddBehavior(PrefabBehaviors.hueJitter()) } - ) - PrefabButton( - label = "Position Jitter", - iconRes = R.drawable.texture_24px, - onClick = { onAddBehavior(PrefabBehaviors.positionJitter()) } - ) - } -} - -/** Reusable button for advanced dynamics presets. */ -@Composable -private fun PrefabButton( - label: String, - iconRes: Int, - onClick: () -> Unit, -) { - FilledTonalButton( - modifier = Modifier.fillMaxWidth(), - onClick = onClick - ) { - Icon( - painterResource(iconRes), - null, - Modifier.size(18.dp) - ) - Spacer(Modifier.width(8.dp)) - Text(label) - } -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt deleted file mode 100644 index 3d5d9cce..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerScreen.kt +++ /dev/null @@ -1,647 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import androidx.activity.compose.LocalActivity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.BottomSheetScaffold -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SheetValue -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.VerticalDragHandle -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold -import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState -import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.ink.brush.Brush -import androidx.ink.brush.StockBrushes -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.example.cahier.R -import com.example.cahier.developer.brushdesigner.viewmodel.BrushDesignerViewModel -import com.example.cahier.developer.brushgraph.ui.BrushSliderControl -import ink.proto.BrushTip as ProtoBrushTip - -/** - * Main entry point for the Brush Designer feature. - * This is the ONLY stateful composable — it owns the ViewModel reference - * and hoists all state/callbacks for child composables. - */ -@OptIn( - ExperimentalMaterial3Api::class, - ExperimentalMaterial3WindowSizeClassApi::class, - ExperimentalMaterial3AdaptiveApi::class, -) -@Composable -fun BrushDesignerScreen( - onNavigateUp: () -> Unit, - viewModel: BrushDesignerViewModel = hiltViewModel(), -) { - val activity = LocalActivity.current ?: return - val windowSizeClass = calculateWindowSizeClass(activity) - val isCompact = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact - - val exportLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("application/octet-stream") - ) { uri -> uri?.let { viewModel.saveBrushToFile(it) } } - - val importLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument() - ) { uri -> uri?.let { viewModel.loadBrushFromFile(it) } } - - val navigator = rememberListDetailPaneScaffoldNavigator() - val paneExpansionState = rememberPaneExpansionState() - var hasSetInitialProportion by remember { mutableStateOf(false) } - - val savedBrushes by viewModel.savedPaletteBrushes.collectAsStateWithLifecycle() - val activeProto by viewModel.activeBrushProto.collectAsStateWithLifecycle() - val selectedCoatIndex by viewModel.selectedCoatIndex.collectAsStateWithLifecycle() - val activeBrush by viewModel.activeBrush.collectAsStateWithLifecycle() - val testStrokes by viewModel.testStrokes.collectAsStateWithLifecycle() - val brushColor by viewModel.brushColor.collectAsStateWithLifecycle() - val brushSize by viewModel.brushSize.collectAsStateWithLifecycle() - - LaunchedEffect(isCompact, hasSetInitialProportion) { - if (!isCompact && !hasSetInitialProportion) { - paneExpansionState.setFirstPaneProportion(0.35f) - hasSetInitialProportion = true - } - } - - var showSavePaletteDialog by remember { mutableStateOf(false) } - if (showSavePaletteDialog) { - SaveToPaletteDialog( - onSave = { name -> viewModel.saveToPalette(name) }, - onDismiss = { showSavePaletteDialog = false } - ) - } - - Scaffold( - topBar = { - BrushDesignerTopBar( - isCompact = isCompact, - onNavigateUp = onNavigateUp, - savedBrushes = savedBrushes, - onLoadBrush = { viewModel.loadStockBrush(it) }, - onLoadFromPalette = { viewModel.loadFromPalette(it) }, - onDeleteFromPalette = { viewModel.deleteFromPalette(it) }, - onClearCanvas = { viewModel.clearCanvas() }, - onShowSaveDialog = { showSavePaletteDialog = true }, - onImport = { - importLauncher.launch(arrayOf("application/octet-stream", "*/*")) - }, - onExport = { - exportLauncher.launch( - "custom_${ - android.text.format.DateFormat.format( - "yyyyMMdd_HHmmss", - System.currentTimeMillis() - ) - }.brushfamily" - ) - } - ) - } - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - if (isCompact) { - val bottomSheetState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState( - initialValue = SheetValue.PartiallyExpanded - ) - ) - BottomSheetScaffold( - scaffoldState = bottomSheetState, - sheetPeekHeight = 200.dp, - sheetContent = { - ControlsPane( - modifier = Modifier.fillMaxWidth(), - activeProto = activeProto, - selectedCoatIndex = selectedCoatIndex, - viewModel = viewModel - ) - } - ) { - PreviewPane( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - activeBrush = activeBrush, - activeProto = activeProto, - strokes = testStrokes, - brushColor = brushColor, - brushSize = brushSize, - onReplaceStrokes = { viewModel.replaceStrokes(it) }, - onStrokesFinished = { viewModel.onStrokesFinished(it) }, - onGetNextBrush = { - viewModel.getActiveBrush() - ?: activeBrush - ?: Brush.createWithColorIntArgb( - StockBrushes.marker(), - android.graphics.Color.BLACK, - 15f, 0.1f - ) - }, - onSetBrushColor = { viewModel.setBrushColor(it) }, - onSetBrushSize = { viewModel.setBrushSize(it) } - ) - } - } else { - ListDetailPaneScaffold( - directive = navigator.scaffoldDirective, - value = navigator.scaffoldValue, - paneExpansionState = paneExpansionState, - paneExpansionDragHandle = { state -> - val interactionSource = remember { MutableInteractionSource() } - VerticalDragHandle( - modifier = Modifier.paneExpansionDraggable( - state, - LocalMinimumInteractiveComponentSize.current, - interactionSource, - ), - ) - }, - listPane = { - ControlsPane( - modifier = Modifier.fillMaxSize(), - activeProto = activeProto, - selectedCoatIndex = selectedCoatIndex, - viewModel = viewModel - ) - }, - detailPane = { - PreviewPane( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - activeBrush = activeBrush, - activeProto = activeProto, - strokes = testStrokes, - brushColor = brushColor, - brushSize = brushSize, - onReplaceStrokes = { viewModel.replaceStrokes(it) }, - onStrokesFinished = { viewModel.onStrokesFinished(it) }, - onGetNextBrush = { - viewModel.getActiveBrush() - ?: activeBrush - ?: Brush.createWithColorIntArgb( - StockBrushes.marker(), - android.graphics.Color.BLACK, - 15f, 0.1f - ) - }, - onSetBrushColor = { viewModel.setBrushColor(it) }, - onSetBrushSize = { viewModel.setBrushSize(it) } - ) - } - ) - } - } - } -} - -/** - * The controls panel containing coat management, metadata fields, - * input model selector, and tabbed content (Tip Shape / Paint / Behaviors). - * - * Note: Still accepts ViewModel for delegation to tab content and input model - * sections. All state (activeProto, selectedCoatIndex) is hoisted from the screen. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ControlsPane( - modifier: Modifier = Modifier, - activeProto: ink.proto.BrushFamily, - selectedCoatIndex: Int, - viewModel: BrushDesignerViewModel, -) { - var textFieldsLocked by remember { mutableStateOf(false) } - var selectedTab by remember { mutableStateOf(BrushDesignerTab.TipShape) } - - val currentTip = activeProto - .coatsList.getOrNull(selectedCoatIndex)?.tip ?: ProtoBrushTip.getDefaultInstance() - val inputModel = activeProto.inputModel - - var showTextureDialog by remember { mutableStateOf(false) } - var pendingTextureUri by remember { mutableStateOf(null) } - var textureIdInput by remember { mutableStateOf("") } - - val texturePickerLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.PickVisualMedia() - ) { uri -> - uri?.let { - pendingTextureUri = it - showTextureDialog = true - } - } - - if (showTextureDialog) { - TextureNameDialog( - textureIdInput = textureIdInput, - onTextureIdChange = { textureIdInput = it }, - onConfirm = { - val uri = pendingTextureUri - if (textureIdInput.isNotBlank() && uri != null) { - viewModel.addCustomTexture(uri, textureIdInput) - showTextureDialog = false - textureIdInput = "" - } - }, - onDismiss = { showTextureDialog = false } - ) - } - - Column( - modifier = modifier - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CoatLayersSection( - activeProto = activeProto, - selectedCoatIndex = selectedCoatIndex, - onSelectCoat = { viewModel.setSelectedCoat(it) }, - onAddCoat = { viewModel.addNewCoat() }, - onDeleteCoat = { viewModel.deleteSelectedCoat() } - ) - - HorizontalDivider() - Spacer(modifier = Modifier.height(8.dp)) - - MetadataSection( - developerComment = activeProto.developerComment, - textFieldsLocked = textFieldsLocked, - onToggleLock = { textFieldsLocked = it }, - onCommentChange = { viewModel.updateDeveloperComment(it) } - ) - - HorizontalDivider() - - InputModelSection( - inputModel = inputModel, - onUpdateInputModelToPassthrough = { viewModel.updateInputModelToPassthrough() }, - onUpdateSlidingWindowModel = { - ms, - hz, - -> - viewModel.updateSlidingWindowModel(ms, hz) - } - ) - - HorizontalDivider() - - TabRow(selectedTabIndex = selectedTab.ordinal) { - BrushDesignerTab.entries.forEach { tab -> - Tab( - selected = selectedTab == tab, - onClick = { selectedTab = tab }, - text = { Text(stringResource(tab.labelResId)) } - ) - } - } - - when (selectedTab) { - BrushDesignerTab.TipShape -> TipShapeTabContent( - currentTip = currentTip, - activeBrush = viewModel.getActiveBrush(), - onUpdateTip = { block -> viewModel.updateTip(block) } - ) - - BrushDesignerTab.Paint -> PaintTabContent( - activeProto = activeProto, - selectedCoatIndex = selectedCoatIndex, - onUpdatePaintPreferences = { viewModel.updatePaintPreferences(it) }, - onUpdateSelfOverlap = { viewModel.updateSelfOverlap(it) }, - texturePickerLauncher = texturePickerLauncher, - getTextureBitmap = { viewModel.getTextureBitmap(it) } - ) - - BrushDesignerTab.Behaviors -> BehaviorsTabContent( - activeProto = activeProto, - selectedCoatIndex = selectedCoatIndex, - onUpdateBehaviors = { viewModel.updateBehaviorsList(it) }, - onAddBehavior = { nodes -> viewModel.addBehavior(nodes) }, - ) - } - } -} - -@Composable -internal fun CoatLayersSection( - activeProto: ink.proto.BrushFamily, - selectedCoatIndex: Int, - onSelectCoat: (Int) -> Unit, - onAddCoat: () -> Unit, - onDeleteCoat: () -> Unit, -) { - Text( - stringResource(R.string.brush_designer_brush_layers), - style = MaterialTheme.typography.titleMedium - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - activeProto.coatsList.forEachIndexed { index, _ -> - FilterChip( - selected = selectedCoatIndex == index, - onClick = { onSelectCoat(index) }, - label = { Text(stringResource(R.string.brush_designer_coat_label, index + 1)) } - ) - } - - IconButton(onClick = onAddCoat) { - Icon( - painterResource(R.drawable.add_24px), - contentDescription = stringResource(R.string.brush_designer_add_layer) - ) - } - - if (activeProto.coatsList.size > 1) { - IconButton(onClick = onDeleteCoat) { - Icon( - painterResource(R.drawable.delete_24px), - contentDescription = stringResource(R.string.brush_designer_delete_layer), - tint = MaterialTheme.colorScheme.error - ) - } - } - } -} - -@Composable -internal fun MetadataSection( - developerComment: String, - textFieldsLocked: Boolean, - onToggleLock: (Boolean) -> Unit, - onCommentChange: (String) -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Checkbox( - checked = textFieldsLocked, - onCheckedChange = onToggleLock - ) - Text( - stringResource(R.string.brush_designer_lock_fields), - style = MaterialTheme.typography.bodyMedium - ) - } - - OutlinedTextField( - value = developerComment, - onValueChange = onCommentChange, - label = { Text(stringResource(R.string.brush_designer_developer_comment)) }, - enabled = !textFieldsLocked, - modifier = Modifier.fillMaxWidth(), - minLines = 3 - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun InputModelSection( - inputModel: ink.proto.BrushFamily.InputModel, - onUpdateInputModelToPassthrough: () -> Unit, - onUpdateSlidingWindowModel: (Long, Int) -> Unit, - modifier: Modifier = Modifier, -) { - Text( - stringResource(R.string.brush_designer_input_model), - style = MaterialTheme.typography.titleMedium - ) - - var expandedModelMenu by remember { mutableStateOf(false) } - val currentModelString = when { - inputModel.hasPassthroughModel() -> - stringResource(R.string.brush_designer_passthrough_model) - - inputModel.hasSlidingWindowModel() -> - stringResource(R.string.brush_designer_sliding_window) - - else -> stringResource(R.string.brush_designer_sliding_window_default) - } - - ExposedDropdownMenuBox( - expanded = expandedModelMenu, - onExpandedChange = { expandedModelMenu = it }, - ) { - OutlinedTextField( - value = currentModelString, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.brush_designer_model_type)) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedModelMenu) - }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - - DropdownMenu( - expanded = expandedModelMenu, - onDismissRequest = { expandedModelMenu = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.brush_designer_passthrough_model)) }, - onClick = { - onUpdateInputModelToPassthrough() - expandedModelMenu = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.brush_designer_sliding_window)) }, - onClick = { - onUpdateSlidingWindowModel(20L, 180) - expandedModelMenu = false - } - ) - } - } - - if (inputModel.hasSlidingWindowModel() || !inputModel.hasPassthroughModel()) { - SlidingWindowControls( - inputModel = inputModel, - onUpdateSlidingWindowModel = onUpdateSlidingWindowModel - ) - } -} - -@Composable -internal fun SlidingWindowControls( - inputModel: ink.proto.BrushFamily.InputModel, - onUpdateSlidingWindowModel: (Long, Int) -> Unit, -) { - val swModel = inputModel.slidingWindowModel - val windowMs = - if (swModel.hasWindowSizeSeconds()) (swModel.windowSizeSeconds * 1000) - .toLong() else 20L - val upsamplingHz = if (swModel.hasExperimentalUpsamplingPeriodSeconds()) { - val period = swModel.experimentalUpsamplingPeriodSeconds - if (period == Float.POSITIVE_INFINITY || period == 0f) 0 else (1f / period).toInt() - } else 180 - - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(12.dp)) { - BrushSliderControl( - label = stringResource(R.string.brush_designer_window_size_ms), - value = windowMs.toFloat(), - valueRange = 1f..100f, - onValueChange = { newValue -> - onUpdateSlidingWindowModel(newValue.toLong(), upsamplingHz) - } - ) - BrushSliderControl( - label = stringResource(R.string.brush_designer_upsampling_frequency_hz), - value = upsamplingHz.toFloat(), - valueRange = 0f..500f, - onValueChange = { newValue -> - onUpdateSlidingWindowModel(windowMs, newValue.toInt()) - } - ) - } - } -} - -@Composable -internal fun TextureNameDialog( - textureIdInput: String, - onTextureIdChange: (String) -> Unit, - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.brush_designer_name_texture_title)) }, - text = { - OutlinedTextField( - value = textureIdInput, - onValueChange = onTextureIdChange, - label = { Text(stringResource(R.string.brush_designer_texture_id_hint)) }, - singleLine = true - ) - }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(stringResource(R.string.brush_designer_load)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.brush_designer_cancel)) - } - } - ) -} - -@Preview(showBackground = true, widthDp = 360, heightDp = 200) -@Composable -private fun BrushSliderControlPreview() { - MaterialTheme { - Column(modifier = Modifier.padding(16.dp)) { - BrushSliderControl( - label = stringResource(R.string.brush_designer_scale_x), - value = 1.5f, - valueRange = 0.1f..5f, - onValueChange = {} - ) - } - } -} - -@Preview(showBackground = true, widthDp = 360, heightDp = 300) -@Composable -private fun TipShapeTabPreview() { - MaterialTheme { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - TipShapeTabContent( - currentTip = ProtoBrushTip.getDefaultInstance(), - activeBrush = null, - onUpdateTip = {} - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTab.kt deleted file mode 100644 index cc8f6061..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTab.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import androidx.annotation.StringRes -import com.example.cahier.R - -/** - * Represents the three tabs in the Brush Designer controls' pane. - */ -enum class BrushDesignerTab(@param:StringRes val labelResId: Int) { - TipShape(R.string.brush_designer_tab_tip_shape), - Paint(R.string.brush_designer_tab_paint), - Behaviors(R.string.brush_designer_tab_behaviors); -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt deleted file mode 100644 index d6b75359..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/BrushDesignerTopBar.kt +++ /dev/null @@ -1,428 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.PopupProperties -import androidx.ink.brush.BrushFamily -import androidx.ink.brush.StockBrushes -import com.example.cahier.R -import com.example.cahier.core.data.CustomBrushEntity -import com.example.cahier.core.ui.LocalTextureStore -import com.example.cahier.features.drawing.CustomBrushes - -/** - * Top app bar for the Brush Designer screen with stock brush, palette, - * save/import/export actions. - * - * Stateless: receives data and callbacks, does not access ViewModel. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun BrushDesignerTopBar( - isCompact: Boolean, - onNavigateUp: () -> Unit, - savedBrushes: List, - onLoadBrush: (BrushFamily) -> Unit, - onLoadFromPalette: (CustomBrushEntity) -> Unit, - onDeleteFromPalette: (String) -> Unit, - onClearCanvas: () -> Unit, - onShowSaveDialog: () -> Unit, - onImport: () -> Unit, - onExport: () -> Unit, -) { - TopAppBar( - title = { - Text( - stringResource(R.string.brush_designer_title), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - navigationIcon = { - IconButton(onClick = onNavigateUp) { - Icon( - painterResource(R.drawable.arrow_back_24px), - contentDescription = stringResource(R.string.brush_designer_close), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - actions = { - BrushLibraryMenu( - onLoadBrush = onLoadBrush - ) - PaletteMenu( - savedBrushes = savedBrushes, - onLoadFromPalette = onLoadFromPalette, - onDeleteFromPalette = onDeleteFromPalette - ) - if (isCompact) { - OverflowMenu( - onShowSaveDialog = onShowSaveDialog, - onClearCanvas = onClearCanvas, - onImport = onImport, - onExport = onExport - ) - } else { - TextButton(onClick = onShowSaveDialog) { - Text(stringResource(R.string.brush_designer_save)) - } - TextButton(onClick = onClearCanvas) { - Text(stringResource(R.string.clear)) - } - TextButton(onClick = onImport) { - Text(stringResource(R.string.brush_designer_import)) - } - TextButton(onClick = onExport) { - Text(stringResource(R.string.brush_designer_export)) - } - } - } - ) -} - -@Composable -private fun BrushLibraryMenu( - onLoadBrush: (BrushFamily) -> Unit, -) { - val textureStore = LocalTextureStore.current - var expanded by remember { mutableStateOf(false) } - val context = LocalContext.current - val cahierBrushes = remember { CustomBrushes.getBrushes(context, textureStore) } - - val stockBrushes = remember { - listOf( - R.string.highlighter to StockBrushes.highlighter(), - R.string.marker to StockBrushes.marker(), - R.string.pressure_pen to StockBrushes.pressurePen(), - R.string.dashed_line to StockBrushes.dashedLine(), - R.string.emoji_highlighter to StockBrushes.emojiHighlighter( - "emoji-heart", - showMiniEmojiTrail = true - ), - ) - } - - Box { - TextButton(onClick = { expanded = true }) { - Text(stringResource(R.string.brush_designer_brushes)) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DropdownMenuItem( - text = { - Text( - stringResource(R.string.brush_designer_section_stock), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) - }, - onClick = {}, - enabled = false - ) - stockBrushes.filter { it.first != R.string.emoji_highlighter } - .forEach { (nameResId, brushFamily) -> - DropdownMenuItem( - text = { Text(stringResource(nameResId)) }, - onClick = { - onLoadBrush(brushFamily) - expanded = false - } - ) - } - - var showEmojiSubMenu by remember { mutableStateOf(false) } - var itemWidth by remember { mutableStateOf(0) } - var itemHeight by remember { mutableStateOf(0) } - val density = LocalDensity.current - - Box(modifier = Modifier.onSizeChanged { - itemWidth = it.width - itemHeight = it.height - }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.emoji_highlighter)) }, - trailingIcon = { - Icon(Icons.Default.ChevronRight, contentDescription = null) - }, - onClick = { showEmojiSubMenu = true } - ) - DropdownMenu( - expanded = showEmojiSubMenu, - onDismissRequest = { showEmojiSubMenu = false }, - offset = DpOffset( - x = density.run { itemWidth.toDp() }, - y = density.run { -itemHeight.toDp() }), - properties = PopupProperties(focusable = true) - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.emoji_heart)) }, - onClick = { - onLoadBrush( - StockBrushes.emojiHighlighter( - "emoji-heart", - showMiniEmojiTrail = true - ) - ) - showEmojiSubMenu = false - expanded = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.emoji_star)) }, - onClick = { - onLoadBrush( - StockBrushes.emojiHighlighter( - "emoji-star", - showMiniEmojiTrail = true - ) - ) - showEmojiSubMenu = false - expanded = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.emoji_poop)) }, - onClick = { - onLoadBrush( - StockBrushes.emojiHighlighter( - "emoji-poop", - showMiniEmojiTrail = true - ) - ) - showEmojiSubMenu = false - expanded = false - } - ) - } - } - - if (cahierBrushes.isNotEmpty()) { - DropdownMenuItem( - text = { - Text( - stringResource(R.string.brush_designer_section_cahier), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) - }, - onClick = {}, - enabled = false - ) - cahierBrushes.forEach { customBrush -> - DropdownMenuItem( - text = { Text(customBrush.name) }, - onClick = { - onLoadBrush(customBrush.brushFamily) - expanded = false - } - ) - } - } - } - } -} - -@Composable -private fun PaletteMenu( - savedBrushes: List, - onLoadFromPalette: (CustomBrushEntity) -> Unit, - onDeleteFromPalette: (String) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - Box { - TextButton(onClick = { expanded = true }) { - Text(stringResource(R.string.brush_designer_my_brushes)) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - if (savedBrushes.isEmpty()) { - DropdownMenuItem( - text = { Text(stringResource(R.string.brush_designer_no_saved_brushes)) }, - onClick = { expanded = false } - ) - } else { - savedBrushes.forEach { entity -> - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(entity.name, modifier = Modifier.weight(1f)) - IconButton(onClick = { - onDeleteFromPalette(entity.name) - }) { - Icon( - painterResource(R.drawable.delete_24px), - contentDescription = stringResource(R.string.delete), - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(18.dp) - ) - } - } - }, - onClick = { - onLoadFromPalette(entity) - expanded = false - } - ) - } - } - } - } -} - -@Composable -private fun OverflowMenu( - onShowSaveDialog: () -> Unit, - onClearCanvas: () -> Unit, - onImport: () -> Unit, - onExport: () -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - Box { - IconButton(onClick = { expanded = true }) { - Icon( - painterResource(R.drawable.menu_24px), - contentDescription = stringResource(R.string.brush_designer_more_options), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.brush_designer_save)) }, - onClick = { onShowSaveDialog(); expanded = false } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.clear)) }, - onClick = { onClearCanvas(); expanded = false } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.brush_designer_import)) }, - onClick = { onImport(); expanded = false } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.brush_designer_export)) }, - onClick = { onExport(); expanded = false } - ) - } - } -} - -/** - * Dialog for saving the current brush to the local palette with a name. - * - * Stateless: receives callbacks only. - */ -@Composable -internal fun SaveToPaletteDialog( - onSave: (String) -> Unit, - onDismiss: () -> Unit, -) { - var brushNameInput by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.brush_designer_save_dialog_title)) }, - text = { - androidx.compose.foundation.layout.Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f), - shape = RoundedCornerShape(8.dp) - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painterResource(R.drawable.info_24px), - contentDescription = null, - tint = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.width(12.dp)) - Text( - text = stringResource(R.string.brush_designer_save_dialog_info), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onTertiaryContainer - ) - } - } - OutlinedTextField( - value = brushNameInput, - onValueChange = { brushNameInput = it }, - label = { Text(stringResource(R.string.brush_designer_brush_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - }, - confirmButton = { - TextButton(onClick = { - if (brushNameInput.isNotBlank()) { - onSave(brushNameInput) - onDismiss() - } - }) { Text(stringResource(R.string.save)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.brush_designer_cancel)) - } - } - ) -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt deleted file mode 100644 index f5664e43..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/EditableListWidget.kt +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.example.cahier.R - -/** - * Wrapper for A/B testing: tracks whether each item is enabled or disabled - * without removing it from the list. - */ -data class CheckableItem(val item: T, val enabled: Boolean = true) - -/** - * A generic list editor that supports add, delete, duplicate, and A/B - * testing (enable/disable toggle) for ordered lists of proto items. - * - * Internally stateful: manages A/B toggle state locally via [CheckableItem]. - * The initial item list is passed in via [items]; after initialization, the - * widget owns the list. All mutations are emitted via [onItemsChanged] with - * only the enabled items. - * - * @param title section header text - * @param items initial list of items (from proto), used only for first composition - * @param defaultItem factory default for new items - * @param onItemsChanged callback with the updated list of enabled items - * @param itemHeader display label for each item in collapsed view - * @param editorContent expanded editor for the selected item - */ -@Composable -internal fun EditableListWidget( - title: String, - items: List, - defaultItem: T, - onItemsChanged: (List) -> Unit, - itemHeader: @Composable (T) -> String, - editorContent: @Composable (item: T, onItemChanged: (T) -> Unit) -> Unit -) { - var itemStates by remember { mutableStateOf(items - .map { CheckableItem(it, true) }) } - - var selectedIndex by remember { mutableStateOf(null) } - - fun emitEnabledItems() { - onItemsChanged(itemStates.filter { it.enabled }.map { it.item }) - } - - Column(modifier = Modifier.fillMaxWidth()) { - if (title.isNotEmpty()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(title, style = MaterialTheme.typography.titleMedium) - IconButton(onClick = { - itemStates = itemStates + CheckableItem(defaultItem, true) - selectedIndex = itemStates.size - emitEnabledItems() - }) { - Icon( - painterResource(R.drawable.add_24px), - contentDescription = stringResource( - R.string.brush_designer_add_item, title - ), - tint = MaterialTheme.colorScheme.primary - ) - } - } - } - - itemStates.forEachIndexed { index, state -> - val isSelected = selectedIndex == index - Card( - colors = CardDefaults.cardColors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - } - ), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - onClick = { selectedIndex = if (isSelected) null else index } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = state.enabled, - onCheckedChange = { checked -> - val newList = itemStates.toMutableList() - newList[index] = state.copy(enabled = checked) - itemStates = newList - emitEnabledItems() - } - ) - - Text( - text = itemHeader(state.item), - modifier = Modifier.weight(1f), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyMedium - ) - - IconButton(onClick = { - val newList = itemStates.toMutableList() - newList.add(index + 1, state.copy()) - itemStates = newList - emitEnabledItems() - }) { - Icon( - painterResource(R.drawable.content_copy_24px), - contentDescription = stringResource( - R.string.brush_designer_duplicate_item - ) - ) - } - - IconButton(onClick = { - val newList = itemStates.toMutableList() - newList.removeAt(index) - itemStates = newList - if (selectedIndex == index) selectedIndex = null - else if (selectedIndex != null && selectedIndex!! > index) { - selectedIndex = selectedIndex!! - 1 - } - emitEnabledItems() - }) { - Icon( - painterResource(R.drawable.delete_24px), - contentDescription = stringResource( - R.string.brush_designer_delete_item - ), - tint = MaterialTheme.colorScheme.error - ) - } - } - } - } - - selectedIndex?.let { index -> - if (index < itemStates.size) { - val currentItem = itemStates[index].item - Card( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - border = BorderStroke( - 1.dp, MaterialTheme.colorScheme.outlineVariant - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource( - R.string.brush_designer_editing_item, index + 1 - ), - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.labelLarge - ) - Spacer(Modifier.height(8.dp)) - - editorContent(currentItem) { updatedItem -> - val newList = itemStates.toMutableList() - newList[index] = itemStates[index].copy(item = updatedItem) - itemStates = newList - emitEnabledItems() - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt deleted file mode 100644 index be135437..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/MathCurvePreview.kt +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.unit.dp -import ink.proto.BrushBehavior - -/** - * A Canvas-based composable that visually plots the easing/response function - * of a [BrushBehavior.ResponseNode]. - * - * Supports: - * - Cubic Bézier curves (cubicBezierResponseCurve) - * - Linear piecewise curves (linearResponseCurve) - * - Predefined easing functions (rendered as a straight line placeholder) - * - Steps response curves (rendered as a staircase) - */ -@Composable -internal fun MathCurvePreview( - responseNode: BrushBehavior.ResponseNode, - modifier: Modifier = Modifier -) { - val lineColor = MaterialTheme.colorScheme.primary - val gridColor = MaterialTheme.colorScheme.outlineVariant - - Card( - modifier = modifier - .fillMaxWidth() - .height(150.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Canvas( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - val w = size.width - val h = size.height - - drawLine( - gridColor, - start = Offset(0f, h), - end = Offset(w, h), - strokeWidth = 2f - ) - drawLine( - gridColor, - start = Offset(0f, 0f), - end = Offset(0f, h), - strokeWidth = 2f - ) - - for (i in 1..3) { - val frac = i / 4f - drawLine( - gridColor.copy(alpha = 0.3f), - start = Offset(0f, h * (1f - frac)), - end = Offset(w, h * (1f - frac)), - strokeWidth = 1f - ) - drawLine( - gridColor.copy(alpha = 0.3f), - start = Offset(w * frac, 0f), - end = Offset(w * frac, h), - strokeWidth = 1f - ) - } - - val path = Path() - val curveCase = responseNode.responseCurveCase - - when (curveCase) { - BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> { - val cb = responseNode.cubicBezierResponseCurve - path.moveTo(0f, h) - path.cubicTo( - cb.x1 * w, h - (cb.y1 * h), - cb.x2 * w, h - (cb.y2 * h), - w, 0f - ) - } - - BrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> { - val linear = responseNode.linearResponseCurve - val xs = linear.xList - val ys = linear.yList - if (xs.isNotEmpty() && ys.isNotEmpty()) { - path.moveTo(xs[0] * w, h - (ys[0] * h)) - for (i in 1 until minOf(xs.size, ys.size)) { - path.lineTo(xs[i] * w, h - (ys[i] * h)) - } - } else { - path.moveTo(0f, h) - path.lineTo(w, 0f) - } - } - - BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> { - val steps = responseNode.stepsResponseCurve - val stepCount = steps.stepCount - if (stepCount > 0) { - val stepWidth = w / stepCount - val stepHeight = h / stepCount - path.moveTo(0f, h) - for (i in 0 until stepCount) { - val y = h - ((i + 1) * stepHeight) - path.lineTo(i * stepWidth, y) - path.lineTo((i + 1) * stepWidth, y) - } - } else { - path.moveTo(0f, h) - path.lineTo(w, 0f) - } - } - - else -> { - path.moveTo(0f, h) - path.lineTo(w, 0f) - } - } - - drawPath( - path, - color = lineColor, - style = Stroke(width = 4f) - ) - } - } -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt deleted file mode 100644 index 21feb319..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/NodeEditors.kt +++ /dev/null @@ -1,578 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.example.cahier.R -import com.example.cahier.developer.brushgraph.ui.EnumDropdown -import com.example.cahier.developer.brushgraph.ui.NumericField -import com.example.cahier.developer.brushgraph.ui.NumericLimits -import ink.proto.BrushBehavior -import ink.proto.CubicBezierEasingFunction -import ink.proto.LinearEasingFunction -import ink.proto.StepsEasingFunction -import ink.proto.PredefinedEasingFunction as ProtoPredefinedEasingFunction - -/** - * Dispatches to the correct editor based on the [BrushBehavior.Node.NodeCase]. - * - * Each editor is stateless: it receives the current node and emits - * the updated node via [onNodeChanged]. - */ -@Composable -internal fun NodeEditor( - node: BrushBehavior.Node, - onNodeChanged: (BrushBehavior.Node) -> Unit, - modifier: Modifier = Modifier, -) { - when (node.nodeCase) { - BrushBehavior.Node.NodeCase.SOURCE_NODE -> - SourceNodeEditor(node.sourceNode, onNodeChanged) - - BrushBehavior.Node.NodeCase.RESPONSE_NODE -> - ResponseNodeEditor(node.responseNode, onNodeChanged) - - BrushBehavior.Node.NodeCase.DAMPING_NODE -> - DampingNodeEditor(node.dampingNode, onNodeChanged) - - BrushBehavior.Node.NodeCase.NOISE_NODE -> - NoiseNodeEditor(node.noiseNode, onNodeChanged) - - BrushBehavior.Node.NodeCase.TARGET_NODE -> - TargetNodeEditor(node.targetNode, onNodeChanged) - - BrushBehavior.Node.NodeCase.CONSTANT_NODE -> - ConstantNodeEditor(node.constantNode, onNodeChanged) - - BrushBehavior.Node.NodeCase.BINARY_OP_NODE -> - BinaryOpNodeEditor(node.binaryOpNode, onNodeChanged) - - BrushBehavior.Node.NodeCase.INTERPOLATION_NODE -> - InterpolationNodeEditor(node.interpolationNode, onNodeChanged) - - else -> Text( - "Unsupported node type: ${node.nodeCase}", - style = MaterialTheme.typography.bodySmall - ) - } -} - -@Composable -internal fun SourceNodeEditor( - source: BrushBehavior.SourceNode, - onNodeChanged: (BrushBehavior.Node) -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - Text( - stringResource(R.string.brush_designer_node_source), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.secondary - ) - - val sources = BrushBehavior.Source.entries.filter { - it != BrushBehavior.Source.SOURCE_UNSPECIFIED - } - EnumDropdown( - label = stringResource(R.string.brush_designer_node_source_input), - currentValue = source.source, - values = sources, - displayName = { it.name.replace("SOURCE_", "") }, - onSelected = { selected -> - onNodeChanged( - BrushBehavior.Node.newBuilder().setSourceNode( - source.toBuilder().setSource(selected) - ).build() - ) - } - ) - - NumericField( - title = stringResource(R.string.brush_designer_node_range_start), - value = source.sourceValueRangeStart, - limits = NumericLimits(-100f, 100f, 0.1f), - onValueChanged = { - onNodeChanged( - BrushBehavior.Node.newBuilder().setSourceNode( - source.toBuilder().setSourceValueRangeStart(it) - ).build() - ) - } - ) - - NumericField( - title = stringResource(R.string.brush_designer_node_range_end), - value = source.sourceValueRangeEnd, - limits = NumericLimits(-100f, 100f, 0.1f), - onValueChanged = { - onNodeChanged( - BrushBehavior.Node.newBuilder().setSourceNode( - source.toBuilder().setSourceValueRangeEnd(it) - ).build() - ) - } - ) - - EnumDropdown( - label = stringResource(R.string.brush_designer_node_out_of_range), - currentValue = source.sourceOutOfRangeBehavior, - values = BrushBehavior.OutOfRange.entries.filter { - it != BrushBehavior.OutOfRange.OUT_OF_RANGE_UNSPECIFIED - }, - displayName = { it.name.replace("OUT_OF_RANGE_", "") }, - onSelected = { selected -> - onNodeChanged( - BrushBehavior.Node.newBuilder().setSourceNode( - source.toBuilder().setSourceOutOfRangeBehavior(selected) - ).build() - ) - } - ) - } -} - -@Composable -internal fun ResponseNodeEditor( - response: BrushBehavior.ResponseNode, - onNodeChanged: (BrushBehavior.Node) -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - Text( - stringResource(R.string.brush_designer_node_response_curve), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.secondary - ) - - MathCurvePreview( - responseNode = response, - modifier = Modifier.padding(vertical = 8.dp) - ) - - val currentType = when (response.responseCurveCase) { - BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> - ResponseCurveType.CubicBezier - - BrushBehavior.ResponseNode.ResponseCurveCase.LINEAR_RESPONSE_CURVE -> - ResponseCurveType.Linear - - BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> - ResponseCurveType.Steps - - else -> ResponseCurveType.Predefined - } - - EnumDropdown( - label = stringResource(R.string.brush_designer_node_curve_type), - currentValue = currentType, - values = ResponseCurveType.entries.toList(), - displayName = { it.displayName }, - onSelected = { selected -> - val newResponseBuilder = response.toBuilder() - when (selected) { - ResponseCurveType.Predefined -> - newResponseBuilder.setPredefinedResponseCurve( - ProtoPredefinedEasingFunction.PREDEFINED_EASING_EASE - ) - - ResponseCurveType.CubicBezier -> - newResponseBuilder.setCubicBezierResponseCurve( - CubicBezierEasingFunction.newBuilder() - .setX1(0.25f).setY1(0.1f).setX2(0.25f).setY2(1f) - ) - - ResponseCurveType.Linear -> - newResponseBuilder.setLinearResponseCurve( - LinearEasingFunction.newBuilder() - ) - - ResponseCurveType.Steps -> - newResponseBuilder.setStepsResponseCurve( - StepsEasingFunction.newBuilder().setStepCount(4) - ) - } - onNodeChanged( - BrushBehavior.Node.newBuilder() - .setResponseNode(newResponseBuilder).build() - ) - } - ) - - when (response.responseCurveCase) { - BrushBehavior.ResponseNode.ResponseCurveCase.CUBIC_BEZIER_RESPONSE_CURVE -> { - val cb = response.cubicBezierResponseCurve - NumericField( - title = stringResource(R.string.brush_designer_node_control_x1), - value = cb.x1, - limits = NumericLimits(0f, 1f, 0.05f) - ) { - onNodeChanged( - BrushBehavior.Node.newBuilder().setResponseNode( - response.toBuilder().setCubicBezierResponseCurve( - cb.toBuilder().setX1(it) - ) - ).build() - ) - } - NumericField( - title = stringResource(R.string.brush_designer_node_control_y1), - value = cb.y1, - limits = NumericLimits(-1f, 2f, 0.05f) - ) { - onNodeChanged( - BrushBehavior.Node.newBuilder().setResponseNode( - response.toBuilder().setCubicBezierResponseCurve( - cb.toBuilder().setY1(it) - ) - ).build() - ) - } - NumericField( - title = stringResource(R.string.brush_designer_node_control_x2), - value = cb.x2, - limits = NumericLimits(0f, 1f, 0.05f) - ) { - onNodeChanged( - BrushBehavior.Node.newBuilder().setResponseNode( - response.toBuilder().setCubicBezierResponseCurve( - cb.toBuilder().setX2(it) - ) - ).build() - ) - } - NumericField( - title = stringResource(R.string.brush_designer_node_control_y2), - value = cb.y2, - limits = NumericLimits(-1f, 2f, 0.05f) - ) { - onNodeChanged( - BrushBehavior.Node.newBuilder().setResponseNode( - response.toBuilder().setCubicBezierResponseCurve( - cb.toBuilder().setY2(it) - ) - ).build() - ) - } - } - - BrushBehavior.ResponseNode.ResponseCurveCase.PREDEFINED_RESPONSE_CURVE -> { - EnumDropdown( - label = stringResource(R.string.brush_designer_node_predefined_curve), - currentValue = response.predefinedResponseCurve, - values = ProtoPredefinedEasingFunction.entries.toList(), - displayName = { - it.name.replace("PREDEFINED_EASING_FUNCTION_", "") - }, - onSelected = { selected -> - onNodeChanged( - BrushBehavior.Node.newBuilder().setResponseNode( - response.toBuilder().setPredefinedResponseCurve(selected) - ).build() - ) - } - ) - } - - BrushBehavior.ResponseNode.ResponseCurveCase.STEPS_RESPONSE_CURVE -> { - val steps = response.stepsResponseCurve - NumericField( - title = stringResource(R.string.brush_designer_node_step_count), - value = steps.stepCount.toFloat(), - limits = NumericLimits(1f, 20f, 1f) - ) { - onNodeChanged( - BrushBehavior.Node.newBuilder().setResponseNode( - response.toBuilder().setStepsResponseCurve( - steps.toBuilder().setStepCount(it.toInt()) - ) - ).build() - ) - } - } - - else -> { - Text( - "Linear curves: edit points via export/import.", - style = MaterialTheme.typography.bodySmall - ) - } - } - } -} - -@Composable -internal fun DampingNodeEditor( - damping: BrushBehavior.DampingNode, - onNodeChanged: (BrushBehavior.Node) -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - Text( - stringResource(R.string.brush_designer_node_damping), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.secondary - ) - - EnumDropdown( - label = stringResource(R.string.brush_designer_node_damping_source), - currentValue = damping.dampingSource, - values = BrushBehavior.ProgressDomain.entries.filter { - it != BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_UNSPECIFIED - }, - displayName = { it.name.replace("PROGRESS_DOMAIN_", "") }, - onSelected = { selected -> - onNodeChanged( - BrushBehavior.Node.newBuilder().setDampingNode( - damping.toBuilder().setDampingSource(selected) - ).build() - ) - } - ) - - NumericField( - title = stringResource(R.string.brush_designer_node_gap_seconds), - value = damping.dampingGap, - limits = NumericLimits(0.01f, 2f, 0.01f), - onValueChanged = { - onNodeChanged( - BrushBehavior.Node.newBuilder().setDampingNode( - damping.toBuilder().setDampingGap(it) - ).build() - ) - } - ) - } -} - -@Composable -internal fun NoiseNodeEditor( - noise: BrushBehavior.NoiseNode, - onNodeChanged: (BrushBehavior.Node) -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - Text( - text = stringResource(R.string.brush_designer_node_noise), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.secondary - ) - - NumericField( - title = stringResource(R.string.brush_designer_node_seed), - value = noise.seed.toFloat(), - limits = NumericLimits(-10000f, 10000f, 1f), - onValueChanged = { - onNodeChanged( - BrushBehavior.Node.newBuilder().setNoiseNode( - noise.toBuilder().setSeed(it.toInt()) - ).build() - ) - } - ) - - EnumDropdown( - label = stringResource(R.string.brush_designer_node_vary_over), - currentValue = noise.varyOver, - values = BrushBehavior.ProgressDomain.entries.filter { - it != BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_UNSPECIFIED - }, - displayName = { it.name.replace("PROGRESS_DOMAIN_", "") }, - onSelected = { selected -> - onNodeChanged( - BrushBehavior.Node.newBuilder().setNoiseNode( - noise.toBuilder().setVaryOver(selected) - ).build() - ) - } - ) - - NumericField( - title = stringResource(R.string.brush_designer_node_base_period), - value = noise.basePeriod, - limits = NumericLimits(0.01f, 5f, 0.01f), - onValueChanged = { - onNodeChanged( - BrushBehavior.Node.newBuilder().setNoiseNode( - noise.toBuilder().setBasePeriod(it) - ).build() - ) - } - ) - } -} - -@Composable -internal fun TargetNodeEditor( - target: BrushBehavior.TargetNode, - onNodeChanged: (BrushBehavior.Node) -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - Text( - stringResource(R.string.brush_designer_node_target), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.secondary - ) - - EnumDropdown( - label = stringResource(R.string.brush_designer_node_target_output), - currentValue = target.target, - values = BrushBehavior.Target.entries.filter { - it != BrushBehavior.Target.TARGET_UNSPECIFIED - }, - displayName = { it.name.replace("TARGET_", "") }, - onSelected = { selected -> - onNodeChanged( - BrushBehavior.Node.newBuilder().setTargetNode( - target.toBuilder().setTarget(selected) - ).build() - ) - } - ) - - NumericField( - title = stringResource(R.string.brush_designer_node_modifier_range_start), - value = target.targetModifierRangeStart, - limits = NumericLimits(-10f, 10f, 0.05f), - onValueChanged = { - onNodeChanged( - BrushBehavior.Node.newBuilder().setTargetNode( - target.toBuilder().setTargetModifierRangeStart(it) - ).build() - ) - } - ) - - NumericField( - title = stringResource(R.string.brush_designer_node_modifier_range_end), - value = target.targetModifierRangeEnd, - limits = NumericLimits(-10f, 10f, 0.05f), - onValueChanged = { - onNodeChanged( - BrushBehavior.Node.newBuilder().setTargetNode( - target.toBuilder().setTargetModifierRangeEnd(it) - ).build() - ) - } - ) - } -} - -@Composable -internal fun ConstantNodeEditor( - constant: BrushBehavior.ConstantNode, - onNodeChanged: (BrushBehavior.Node) -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - Text( - stringResource(R.string.brush_designer_node_constant_value), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.secondary - ) - - NumericField( - title = stringResource(R.string.brush_designer_node_value), - value = constant.value, - limits = NumericLimits(-100f, 100f, 0.1f), - onValueChanged = { - onNodeChanged( - BrushBehavior.Node.newBuilder().setConstantNode( - constant.toBuilder().setValue(it) - ).build() - ) - } - ) - } -} - -@Composable -internal fun BinaryOpNodeEditor( - binaryOp: BrushBehavior.BinaryOpNode, - onNodeChanged: (BrushBehavior.Node) -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - Text( - stringResource(R.string.brush_designer_node_binary_operation), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.secondary - ) - - EnumDropdown( - label = stringResource(R.string.brush_designer_node_operation), - currentValue = binaryOp.operation, - values = BrushBehavior.BinaryOp.entries.filter { - it != BrushBehavior.BinaryOp.BINARY_OP_UNSPECIFIED - }, - displayName = { it.name.replace("BINARY_OP_", "") }, - onSelected = { selected -> - onNodeChanged( - BrushBehavior.Node.newBuilder().setBinaryOpNode( - binaryOp.toBuilder().setOperation(selected) - ).build() - ) - } - ) - } -} - -@Composable -internal fun InterpolationNodeEditor( - interpolation: BrushBehavior.InterpolationNode, - onNodeChanged: (BrushBehavior.Node) -> Unit, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - Text( - stringResource(R.string.brush_designer_node_interpolation), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.secondary - ) - - EnumDropdown( - label = stringResource(R.string.brush_designer_node_interpolation), - currentValue = interpolation.interpolation, - values = BrushBehavior.Interpolation.entries.filter { - it != BrushBehavior.Interpolation.INTERPOLATION_UNSPECIFIED - }, - displayName = { it.name.replace("INTERPOLATION_", "") }, - onSelected = { selected -> - onNodeChanged( - BrushBehavior.Node.newBuilder().setInterpolationNode( - interpolation.toBuilder().setInterpolation(selected) - ).build() - ) - } - ) - } -} - -/** Response curve types available in the curve type selector dropdown. */ -private enum class ResponseCurveType(val displayName: String) { - Predefined("Predefined"), - CubicBezier("Cubic Bézier"), - Linear("Linear"), - Steps("Steps"); -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt deleted file mode 100644 index fb7cc3f2..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PaintTab.kt +++ /dev/null @@ -1,581 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import android.graphics.Bitmap -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.example.cahier.R -import com.example.cahier.core.ui.LocalTextureStore -import com.example.cahier.developer.brushgraph.ui.EnumDropdown -import com.example.cahier.developer.brushgraph.ui.NumericField -import com.example.cahier.developer.brushgraph.ui.NumericLimits -import ink.proto.BrushFamily as ProtoBrushFamily -import ink.proto.BrushPaint as ProtoBrushPaint -import ink.proto.Color as ProtoColor -import ink.proto.ColorFunction as ProtoColorFunction - -/** - * Tab 1: Paint & texture controls — multi-layer textures, multi-function colors, - * self overlap, and texture import. - * - * Uses [EditableListWidget] for managing multiple texture layers and color - * functions with add/remove/duplicate/toggle support. - * - * Stateless: receives data and callbacks, does not access ViewModel. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun PaintTabContent( - activeProto: ProtoBrushFamily, - selectedCoatIndex: Int, - onUpdatePaintPreferences: (List) -> Unit, - onUpdateSelfOverlap: (ProtoBrushPaint.SelfOverlap) -> Unit, - texturePickerLauncher: androidx.activity.result.ActivityResultLauncher< - androidx.activity.result.PickVisualMediaRequest>, - getTextureBitmap: (String) -> Bitmap?, -) { - val paintPrefs = - activeProto.coatsList.getOrNull(selectedCoatIndex)?.paintPreferencesList - ?: emptyList() - - val textureStore = LocalTextureStore.current - val availableTextures = remember { textureStore.getAllIds().toList() } - - Text( - text = stringResource(R.string.brush_designer_paint_texture), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp) - ) - - EditableListWidget( - title = stringResource(R.string.brush_designer_paint_preferences), - items = paintPrefs, - defaultItem = ProtoBrushPaint.newBuilder() - .addTextureLayers( - ProtoBrushPaint.TextureLayer.newBuilder() - .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) - .setSizeX(1f).setSizeY(1f) - .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) - .setBlendMode(ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER) - ) - .build(), - onItemsChanged = onUpdatePaintPreferences, - itemHeader = { paint -> - val texCount = paint.textureLayersCount - val colorCount = paint.colorFunctionsCount - "$texCount texture(s), $colorCount color fn(s)" - }, - editorContent = { paint, onPaintChanged -> - PaintPreferenceEditor( - paint = paint, - availableTextures = availableTextures, - onPaintChanged = onPaintChanged, - getTextureBitmap = getTextureBitmap - ) - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - SelfOverlapSelector( - currentPaint = paintPrefs.firstOrNull() ?: ProtoBrushPaint.getDefaultInstance(), - onOverlapSelected = onUpdateSelfOverlap - ) - - Spacer(modifier = Modifier.height(16.dp)) - HorizontalDivider() - - TextureImportSection( - textureCount = activeProto.textureIdToBitmapMap.size, - texturePickerLauncher = texturePickerLauncher - ) -} - -/** - * Editor for a single paint preference, containing its own texture layers - * and color functions via nested [EditableListWidget]. - */ -@Composable -private fun PaintPreferenceEditor( - paint: ProtoBrushPaint, - availableTextures: List, - onPaintChanged: (ProtoBrushPaint) -> Unit, - getTextureBitmap: (String) -> Bitmap?, -) { - EditableListWidget( - title = stringResource(R.string.brush_designer_texture_layers), - items = paint.textureLayersList, - defaultItem = ProtoBrushPaint.TextureLayer.newBuilder() - .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) - .setSizeX(1f).setSizeY(1f) - .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) - .setBlendMode(ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER) - .build(), - onItemsChanged = { newLayers -> - onPaintChanged( - paint.toBuilder().clearTextureLayers() - .also { b -> newLayers.forEach { b.addTextureLayers(it) } } - .build() - ) - }, - itemHeader = { layer -> - val texId = - layer.clientTextureId.ifEmpty { stringResource(R.string.brush_designer_empty) } - val blend = layer.blendMode.name.replace("BLEND_MODE_", "") - "$texId ($blend)" - }, - editorContent = { layer, onLayerChanged -> - TextureLayerEditor( - layer = layer, - availableTextures = availableTextures, - onLayerChanged = onLayerChanged, - getTextureBitmap = getTextureBitmap - ) - } - ) - - Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(8.dp)) - - if (paint.colorFunctionsList.isEmpty()) { - Text( - text = stringResource(R.string.brush_designer_no_color_functions), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 4.dp) - ) - } - EditableListWidget( - title = stringResource(R.string.brush_designer_color_functions), - items = paint.colorFunctionsList, - defaultItem = ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build(), - onItemsChanged = { newFuncs -> - onPaintChanged( - paint.toBuilder().clearColorFunctions() - .also { b -> newFuncs.forEach { b.addColorFunctions(it) } } - .build() - ) - }, - itemHeader = { func -> - when (func.functionCase) { - ProtoColorFunction.FunctionCase.OPACITY_MULTIPLIER -> - stringResource( - R.string.brush_designer_opacity_multiplier, - func.opacityMultiplier - ) - - ProtoColorFunction.FunctionCase.REPLACE_COLOR -> - stringResource(R.string.brush_designer_replace_color) - - else -> stringResource(R.string.brush_designer_unknown) - } - }, - editorContent = { func, onFuncChanged -> - ColorFunctionEditor( - colorFunction = func, - onFunctionChanged = onFuncChanged - ) - } - ) -} - -/** - * Full editor for a single [ProtoBrushPaint.TextureLayer], including all - * fields: texture ID, mapping, size unit, scale, rotation, - * origin, offset, wrap, blend mode, and animation. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun TextureLayerEditor( - layer: ProtoBrushPaint.TextureLayer, - availableTextures: List, - onLayerChanged: (ProtoBrushPaint.TextureLayer) -> Unit, - getTextureBitmap: (String) -> Bitmap?, -) { - val bitmap = if (layer.clientTextureId.isNotEmpty()) { - getTextureBitmap(layer.clientTextureId) - } else null - if (bitmap != null) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = stringResource( - id = R.string.brush_designer_texture_content_desc, - layer.clientTextureId - ), - modifier = Modifier - .size(80.dp) - .padding(bottom = 8.dp) - ) - } - - TextureIdSelector( - currentId = layer.clientTextureId, - availableTextures = availableTextures, - onTextureSelected = { id -> - onLayerChanged(layer.toBuilder().setClientTextureId(id).build()) - } - ) - - EnumDropdown( - label = stringResource(R.string.brush_designer_mapping_mode), - currentValue = layer.mapping, - values = ProtoBrushPaint.TextureLayer.Mapping.entries.filter { - it != ProtoBrushPaint.TextureLayer.Mapping.MAPPING_UNSPECIFIED - }, - displayName = { it.name.replace("MAPPING_", "") }, - onSelected = { onLayerChanged(layer.toBuilder().setMapping(it).build()) } - ) - - if (layer.mapping == ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) { - EnumDropdown( - label = stringResource(R.string.brush_designer_size_unit), - currentValue = layer.sizeUnit, - values = ProtoBrushPaint.TextureLayer.SizeUnit.entries.filter { - it != ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_UNSPECIFIED - }, - displayName = { it.name.replace("SIZE_UNIT_", "") }, - onSelected = { onLayerChanged(layer.toBuilder().setSizeUnit(it).build()) } - ) - - NumericField( - title = stringResource(R.string.brush_designer_scale_x), - value = if (layer.hasSizeX()) layer.sizeX else 1f, - limits = NumericLimits(0.1f, 10f, 0.1f), - onValueChanged = { onLayerChanged(layer.toBuilder().setSizeX(it).build()) } - ) - NumericField( - title = stringResource(R.string.brush_designer_scale_y), - value = if (layer.hasSizeY()) layer.sizeY else 1f, - limits = NumericLimits(0.1f, 10f, 0.1f), - onValueChanged = { onLayerChanged(layer.toBuilder().setSizeY(it).build()) } - ) - } - - NumericField( - title = stringResource(R.string.brush_designer_rotation), - value = if (layer.hasRotationInRadians()) layer.rotationInRadians else 0f, - limits = NumericLimits.radiansShownAsDegrees(0f, 360f), - onValueChanged = { - onLayerChanged(layer.toBuilder().setRotationInRadians(it).build()) - } - ) - - EnumDropdown( - label = stringResource(R.string.brush_designer_origin), - currentValue = layer.origin, - values = ProtoBrushPaint.TextureLayer.Origin.entries.toList(), - displayName = { it.name.replace("ORIGIN_", "") }, - onSelected = { onLayerChanged(layer.toBuilder().setOrigin(it).build()) } - ) - - NumericField( - title = stringResource(R.string.brush_designer_offset_x), - value = if (layer.hasOffsetX()) layer.offsetX else 0f, - limits = NumericLimits(-5f, 5f, 0.1f), - onValueChanged = { onLayerChanged(layer.toBuilder().setOffsetX(it).build()) } - ) - NumericField( - title = stringResource(R.string.brush_designer_offset_y), - value = if (layer.hasOffsetY()) layer.offsetY else 0f, - limits = NumericLimits(-5f, 5f, 0.1f), - onValueChanged = { onLayerChanged(layer.toBuilder().setOffsetY(it).build()) } - ) - - EnumDropdown( - label = stringResource(R.string.brush_designer_wrap_x), - currentValue = layer.wrapX, - values = ProtoBrushPaint.TextureLayer.Wrap.entries.toList(), - displayName = { it.name.replace("WRAP_", "") }, - onSelected = { onLayerChanged(layer.toBuilder().setWrapX(it).build()) } - ) - EnumDropdown( - label = stringResource(R.string.brush_designer_wrap_y), - currentValue = layer.wrapY, - values = ProtoBrushPaint.TextureLayer.Wrap.entries.toList(), - displayName = { it.name.replace("WRAP_", "") }, - onSelected = { onLayerChanged(layer.toBuilder().setWrapY(it).build()) } - ) - - EnumDropdown( - label = stringResource(R.string.brush_designer_blend_mode), - currentValue = layer.blendMode, - values = ProtoBrushPaint.TextureLayer.BlendMode.entries.filter { - it != ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_UNSPECIFIED - }, - displayName = { it.name.replace("BLEND_MODE_", "") }, - onSelected = { onLayerChanged(layer.toBuilder().setBlendMode(it).build()) } - ) - Text( - text = blendModeDescription(layer.blendMode), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - -/** - * Editor for a single [ProtoColorFunction], supporting OpacityMultiplier - * and ReplaceColor function types. - */ -@Composable -private fun ColorFunctionEditor( - colorFunction: ProtoColorFunction, - onFunctionChanged: (ProtoColorFunction) -> Unit, -) { - val functionTypes = listOf( - stringResource(R.string.brush_designer_opacity_multiplier_label), - stringResource(R.string.brush_designer_replace_color) - ) - val currentType = when (colorFunction.functionCase) { - ProtoColorFunction.FunctionCase.REPLACE_COLOR -> 1 - else -> 0 - } - - EnumDropdown( - label = stringResource(R.string.brush_designer_function_type), - currentValue = functionTypes[currentType], - values = functionTypes, - displayName = { it }, - onSelected = { selected -> - when (functionTypes.indexOf(selected)) { - 0 -> onFunctionChanged( - ProtoColorFunction.newBuilder().setOpacityMultiplier(1f).build() - ) - - 1 -> onFunctionChanged( - ProtoColorFunction.newBuilder() - .setReplaceColor( - ProtoColor.getDefaultInstance() - ) - .build() - ) - } - } - ) - - when (colorFunction.functionCase) { - ProtoColorFunction.FunctionCase.OPACITY_MULTIPLIER -> { - NumericField( - title = stringResource(R.string.brush_designer_opacity_multiplier_label), - value = colorFunction.opacityMultiplier, - limits = NumericLimits(0f, 2f, 0.05f), - onValueChanged = { - onFunctionChanged( - ProtoColorFunction.newBuilder().setOpacityMultiplier(it).build() - ) - } - ) - } - - ProtoColorFunction.FunctionCase.REPLACE_COLOR -> { - Text( - text = stringResource(R.string.brush_designer_replace_color_message), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(vertical = 4.dp) - ) - } - - else -> { - Text( - text = stringResource(R.string.brush_designer_unknown_color_function), - style = MaterialTheme.typography.bodySmall - ) - } - } -} - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun TextureIdSelector( - currentId: String, - availableTextures: List, - onTextureSelected: (String) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = it } - ) { - OutlinedTextField( - value = currentId.ifEmpty { stringResource(R.string.brush_designer_no_texture) }, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.brush_designer_texture_id)) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - availableTextures.forEach { id -> - DropdownMenuItem( - text = { Text(id) }, - onClick = { - onTextureSelected(id) - expanded = false - } - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SelfOverlapSelector( - currentPaint: ProtoBrushPaint, - onOverlapSelected: (ProtoBrushPaint.SelfOverlap) -> Unit, -) { - var overlapExpanded by remember { mutableStateOf(false) } - - ExposedDropdownMenuBox( - expanded = overlapExpanded, - onExpandedChange = { overlapExpanded = it } - ) { - OutlinedTextField( - value = currentPaint.selfOverlap.name.replace("_", " "), - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.brush_designer_self_overlap)) }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = overlapExpanded) - }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() - ) - ExposedDropdownMenu( - expanded = overlapExpanded, - onDismissRequest = { overlapExpanded = false } - ) { - ProtoBrushPaint.SelfOverlap.entries - .filter { it != ProtoBrushPaint.SelfOverlap.SELF_OVERLAP_UNSPECIFIED } - .forEach { overlap -> - DropdownMenuItem( - text = { Text(overlap.name) }, - onClick = { - onOverlapSelected(overlap) - overlapExpanded = false - } - ) - } - } - } -} - -@Composable -private fun TextureImportSection( - textureCount: Int, - texturePickerLauncher: androidx.activity.result.ActivityResultLauncher< - androidx.activity.result.PickVisualMediaRequest>, -) { - Text( - text = stringResource(R.string.brush_designer_textures), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 16.dp) - ) - Button( - onClick = { - texturePickerLauncher.launch( - androidx.activity.result.PickVisualMediaRequest( - ActivityResultContracts.PickVisualMedia.ImageOnly - ) - ) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.brush_designer_import_texture)) - } - - Text( - stringResource(R.string.brush_designer_loaded_textures, textureCount), - style = MaterialTheme.typography.bodySmall - ) -} - -/** Returns a short human-readable description for each blend mode. */ -private fun blendModeDescription(mode: ProtoBrushPaint.TextureLayer.BlendMode): String = - when (mode) { - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER -> - "Default — texture drawn over destination." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_IN -> - "Keeps destination where texture is opaque." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OUT -> - "Cuts out destination where texture is opaque." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_ATOP -> - "Draws texture only where destination exists." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_IN -> - "Draws texture only where destination is opaque." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_MODULATE -> - "Multiplies source and destination colors." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_ATOP -> - "Draws destination only where texture exists." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_XOR -> - "Shows non-overlapping regions of source and destination." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OUT -> - "Shows texture only where destination is transparent." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST -> - "Ignores texture; destination unchanged." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC -> - "Replaces destination with texture." - - ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_DST_OVER -> - "Draws destination over texture." - - else -> "" - } diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt deleted file mode 100644 index 1bd55349..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/PreviewPane.kt +++ /dev/null @@ -1,311 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.ink.brush.Brush -import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer -import androidx.ink.strokes.Stroke -import com.example.cahier.R -import com.example.cahier.core.ui.DrawingSurface -import com.example.cahier.core.ui.LocalTextureStore -import com.example.cahier.core.ui.theme.BrushBlack -import com.example.cahier.core.ui.theme.BrushBlue -import com.example.cahier.core.ui.theme.BrushGreen -import com.example.cahier.core.ui.theme.BrushRed -import com.example.cahier.core.ui.theme.BrushYellow -import com.example.cahier.developer.brushgraph.ui.CustomColorPickerDialog -import ink.proto.BrushFamily as ProtoBrushFamily - -/** - * The drawing preview pane where users can test/preview their custom brush. - * Includes the canvas, size selector, and color picker controls. - * - * Stateless: receives all state and callbacks, does not access ViewModel. - */ -@Composable -internal fun PreviewPane( - modifier: Modifier = Modifier, - activeBrush: Brush?, - activeProto: ProtoBrushFamily, - strokes: List, - brushColor: Color, - brushSize: Float, - onReplaceStrokes: (List) -> Unit, - onStrokesFinished: (List) -> Unit, - onGetNextBrush: () -> Brush, - onSetBrushColor: (Color) -> Unit, - onSetBrushSize: (Float) -> Unit, -) { - val textureStore = LocalTextureStore.current - val cacheGen by textureStore.generation.collectAsState() - - val canvasStrokeRenderer = remember(cacheGen) { - CanvasStrokeRenderer.create(textureStore = textureStore) - } - val localStrokes = remember { mutableStateListOf() } - var showCustomColorPicker by remember { mutableStateOf(false) } - - LaunchedEffect(strokes) { - if (localStrokes != strokes) { - localStrokes.clear() - localStrokes.addAll(strokes) - } - } - - LaunchedEffect(activeBrush) { - if (activeBrush != null && localStrokes.isNotEmpty()) { - val updatedStrokes = localStrokes.map { it.copy(brush = activeBrush) } - localStrokes.clear() - localStrokes.addAll(updatedStrokes) - onReplaceStrokes(updatedStrokes) - } - } - - Box( - modifier = modifier - .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(12.dp) - ) - .border( - width = 2.dp, MaterialTheme.colorScheme.outlineVariant, - shape = RoundedCornerShape(12.dp) - ) - .clip(RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center - ) { - if (showCustomColorPicker) { - CustomColorPickerDialog( - initialColor = brushColor, - onColorSelected = onSetBrushColor, - onDismissRequest = { showCustomColorPicker = false } - ) - } - - if (activeBrush != null) { - DrawingSurface( - strokes = localStrokes, - canvasStrokeRenderer = canvasStrokeRenderer, - onStrokesFinished = { newStrokes -> - localStrokes.addAll(newStrokes) - onStrokesFinished(newStrokes) - }, - onErase = { _, _ -> }, - onEraseStart = { }, - onEraseEnd = { }, - currentBrush = activeBrush, - onGetNextBrush = onGetNextBrush, - isEraserMode = false, - backgroundImageUri = null, - onStartDrag = {}, - modifier = Modifier.fillMaxSize() - ) - - PreviewToolbar( - brushSize = brushSize, - brushColor = brushColor, - onSizeSelected = onSetBrushSize, - onColorSelected = onSetBrushColor, - onShowCustomColorPicker = { showCustomColorPicker = true }, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(16.dp) - ) - } else { - if (activeProto.coatsCount == 0) { - Text( - text = stringResource(R.string.brush_designer_invalid_brush), - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge - ) - } else { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.outline - ) - } - } - } -} - -/** - * Toolbar overlay with brush size and color selectors. - */ -@Composable -private fun PreviewToolbar( - brushSize: Float, - brushColor: Color, - onSizeSelected: (Float) -> Unit, - onColorSelected: (Color) -> Unit, - onShowCustomColorPicker: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .background( - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f), - shape = RoundedCornerShape(50) - ) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - SizeSelector( - brushSize = brushSize, - onSizeSelected = onSizeSelected - ) - - VerticalDivider(modifier = Modifier.height(24.dp)) - - ColorSelector( - brushColor = brushColor, - onColorSelected = onColorSelected, - onShowCustomColorPicker = onShowCustomColorPicker - ) - } -} - -@Composable -private fun SizeSelector( - brushSize: Float, - onSizeSelected: (Float) -> Unit, -) { - var sizeMenuExpanded by remember { mutableStateOf(false) } - Box { - TextButton(onClick = { sizeMenuExpanded = true }) { - Text( - stringResource( - id = R.string.brush_designer_size_px, brushSize.toInt() - ), - fontWeight = FontWeight.Bold - ) - } - DropdownMenu( - expanded = sizeMenuExpanded, - onDismissRequest = { sizeMenuExpanded = false } - ) { - listOf(2f, 5f, 10f, 15f, 25f, 50f, 100f).forEach { size -> - DropdownMenuItem( - text = { Text(stringResource(R.string.brush_designer_size_px, size.toInt())) }, - onClick = { - onSizeSelected(size) - sizeMenuExpanded = false - } - ) - } - } - } -} - -@Composable -private fun ColorSelector( - brushColor: Color, - onColorSelected: (Color) -> Unit, - onShowCustomColorPicker: () -> Unit, -) { - var colorMenuExpanded by remember { mutableStateOf(false) } - Box { - IconButton(onClick = { colorMenuExpanded = true }) { - Icon( - painterResource(R.drawable.circle_24px), - contentDescription = stringResource(R.string.color), - tint = brushColor - ) - } - DropdownMenu( - expanded = colorMenuExpanded, - onDismissRequest = { colorMenuExpanded = false } - ) { - val colors = mapOf( - stringResource(R.string.brush_designer_color_black) to BrushBlack, - stringResource(R.string.brush_designer_color_red) to BrushRed, - stringResource(R.string.brush_designer_color_blue) to BrushBlue, - stringResource(R.string.brush_designer_color_green) to BrushGreen, - stringResource(R.string.brush_designer_color_yellow) to BrushYellow - ) - colors.forEach { (name, color) -> - DropdownMenuItem( - text = { Text(name) }, - leadingIcon = { - Icon( - painterResource(R.drawable.circle_24px), - contentDescription = name, - tint = color - ) - }, - onClick = { - onColorSelected(color) - colorMenuExpanded = false - } - ) - } - HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp)) - - DropdownMenuItem( - text = { Text(stringResource(R.string.brush_designer_custom_color)) }, - leadingIcon = { - Icon( - painterResource(R.drawable.circle_24px), - contentDescription = stringResource(R.string.brush_designer_custom_color), - tint = brushColor - ) - }, - onClick = { - colorMenuExpanded = false - onShowCustomColorPicker() - } - ) - } - } -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt deleted file mode 100644 index 9884040c..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipPreview.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.unit.dp -import androidx.core.graphics.withSave -import androidx.ink.brush.Brush -import androidx.ink.brush.InputToolType -import androidx.ink.rendering.android.canvas.CanvasStrokeRenderer -import androidx.ink.strokes.MutableStrokeInputBatch -import androidx.ink.strokes.Stroke -import com.example.cahier.core.ui.LocalTextureStore - -/** - * Renders a live stroke preview of the current brush — a Z-shaped squiggle - * that shows how the brush actually paints (tip shape, texture, behaviors). - * - * Re-renders whenever the [brush] or [textureStore] changes. - */ -@Composable -internal fun TipPreview( - brush: Brush?, - modifier: Modifier = Modifier, -) { - val textureStore = LocalTextureStore.current - val cacheGen by textureStore.generation.collectAsState() - val strokeRenderer = remember(cacheGen) { - CanvasStrokeRenderer.create(textureStore) - } - - val gridColor = MaterialTheme.colorScheme.outlineVariant - - Card( - modifier = modifier - .fillMaxWidth() - .height(150.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Canvas( - modifier = Modifier - .fillMaxSize() - .padding(8.dp) - ) { - if (brush == null) return@Canvas - - val w = size.width - val h = size.height - - drawLine( - color = gridColor, - start = Offset(w / 2, 0f), - end = Offset(w / 2, h), - strokeWidth = 0.5f - ) - drawLine( - color = gridColor, - start = Offset(0f, h / 2), - end = Offset(w, h / 2), - strokeWidth = 0.5f - ) - - val inputBatch = MutableStrokeInputBatch() - val points = listOf( - Triple(w * 0.1f, h * 0.3f, 0L), - Triple(w * 0.25f, h * 0.25f, 10L), - Triple(w * 0.45f, h * 0.2f, 20L), - Triple(w * 0.55f, h * 0.35f, 30L), - Triple(w * 0.5f, h * 0.5f, 40L), - Triple(w * 0.45f, h * 0.65f, 50L), - Triple(w * 0.55f, h * 0.8f, 60L), - Triple(w * 0.75f, h * 0.75f, 70L), - Triple(w * 0.9f, h * 0.7f, 80L), - ) - - points.forEachIndexed { index, (x, y, timeMs) -> - inputBatch.add( - type = InputToolType.STYLUS, - x = x, - y = y, - elapsedTimeMillis = timeMs, - pressure = 0.5f + (index.toFloat() / points.size) * 0.3f, - tiltRadians = 0.2f + (index.toFloat() / points.size) * 0.4f, - orientationRadians = (index.toFloat() / points.size) * 1.5f - ) - } - - val stroke = Stroke(brush = brush, inputs = inputBatch) - - val nativeCanvas = drawContext.canvas.nativeCanvas - nativeCanvas.withSave { - strokeRenderer.draw( - stroke = stroke, - canvas = this, - strokeToScreenTransform = android.graphics.Matrix() - ) - } - } - } -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt deleted file mode 100644 index e08fcf9c..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/ui/TipShapeTab.kt +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.ui - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.ink.brush.Brush -import com.example.cahier.R -import com.example.cahier.developer.brushgraph.ui.NumericField -import com.example.cahier.developer.brushgraph.ui.NumericLimits -import ink.proto.BrushTip as ProtoBrushTip - -/** - * Tab 0: Tip geometry controls — scale (with lock toggle), corner rounding, - * slant, rotation, pinch, and particle (stamp) settings. - * - * Uses [com.example.cahier.developer.brushgraph.ui.NumericField] for professional ±button input with degree/percent - * unit conversions and click-to-edit exact-value entry. - * - * Stateless: receives data and callbacks, does not access ViewModel. - */ -@Composable -internal fun TipShapeTabContent( - currentTip: ProtoBrushTip, - activeBrush: Brush?, - onUpdateTip: (ProtoBrushTip.Builder.() -> Unit) -> Unit, -) { - var isScaleLocked by remember { mutableStateOf(false) } - - TipPreview( - brush = activeBrush, - modifier = Modifier.padding(vertical = 8.dp) - ) - - Text( - stringResource(R.string.brush_designer_tip_geometry), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp) - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(R.string.brush_designer_lock_scale_ratio), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f) - ) - Switch(checked = isScaleLocked, onCheckedChange = { isScaleLocked = it }) - } - - NumericField( - title = stringResource(R.string.brush_designer_tip_scale_x), - value = if (currentTip.hasScaleX()) currentTip.scaleX else 1f, - limits = NumericLimits.floatShownAsPercent(10f, 200f), - onValueChanged = { newX -> - onUpdateTip { - val oldX = if (hasScaleX()) scaleX else 1f - val oldY = if (hasScaleY()) scaleY else 1f - setScaleX(newX) - if (isScaleLocked && oldX > 0f) { - setScaleY(oldY * (newX / oldX)) - } - } - } - ) - - NumericField( - title = stringResource(R.string.brush_designer_tip_scale_y), - value = if (currentTip.hasScaleY()) currentTip.scaleY else 1f, - limits = NumericLimits.floatShownAsPercent(10f, 200f), - onValueChanged = { newY -> - onUpdateTip { - val oldX = if (hasScaleX()) scaleX else 1f - val oldY = if (hasScaleY()) scaleY else 1f - setScaleY(newY) - if (isScaleLocked && oldY > 0f) { - setScaleX(oldX * (newY / oldY)) - } - } - } - ) - - NumericField( - title = stringResource(R.string.brush_designer_corner_rounding), - value = if (currentTip.hasCornerRounding()) currentTip.cornerRounding else 1f, - limits = NumericLimits.floatShownAsPercent(0f, 100f), - onValueChanged = { newValue -> onUpdateTip { setCornerRounding(newValue) } } - ) - - NumericField( - title = stringResource(R.string.brush_designer_slant), - value = if (currentTip.hasSlantRadians()) currentTip.slantRadians else 0f, - limits = NumericLimits.radiansShownAsDegrees(-90f, 90f), - onValueChanged = { newValue -> onUpdateTip { setSlantRadians(newValue) } } - ) - - NumericField( - title = stringResource(R.string.brush_designer_tip_rotation), - value = if (currentTip.hasRotationRadians()) currentTip.rotationRadians else 0f, - limits = NumericLimits.radiansShownAsDegrees(0f, 360f), - onValueChanged = { newValue -> onUpdateTip { setRotationRadians(newValue) } } - ) - - NumericField( - title = stringResource(R.string.brush_designer_pinch), - value = if (currentTip.hasPinch()) currentTip.pinch else 0f, - limits = NumericLimits.floatShownAsPercent(0f, 100f), - onValueChanged = { newValue -> onUpdateTip { setPinch(newValue) } } - ) - - HorizontalDivider() - Text( - stringResource(R.string.brush_designer_particle_settings), - style = MaterialTheme.typography.titleSmall - ) - - NumericField( - title = stringResource(R.string.brush_designer_gap_distance_scale), - value = if (currentTip.hasParticleGapDistanceScale()) currentTip - .particleGapDistanceScale else 0f, - limits = NumericLimits(0f, 5f, 0.1f), - onValueChanged = { newValue -> - onUpdateTip { setParticleGapDistanceScale(newValue) } - } - ) - - NumericField( - title = stringResource(R.string.brush_designer_gap_duration_ms), - value = if (currentTip.hasParticleGapDurationSeconds()) currentTip - .particleGapDurationSeconds * 1000f else 0f, - limits = NumericLimits(0f, 250f, 5f), - onValueChanged = { newValue -> - onUpdateTip { setParticleGapDurationSeconds(newValue / 1000f) } - } - ) -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt deleted file mode 100644 index 2be7c187..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModel.kt +++ /dev/null @@ -1,526 +0,0 @@ -/* - * Copyright 2025 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.viewmodel - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import androidx.compose.ui.graphics.Color -import androidx.ink.brush.Brush -import androidx.ink.brush.BrushFamily -import androidx.ink.brush.Version -import androidx.ink.brush.compose.createWithComposeColor -import androidx.ink.storage.decode -import androidx.ink.storage.encode -import androidx.ink.strokes.Stroke -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.cahier.core.data.CustomBrushDao -import com.example.cahier.core.data.CustomBrushEntity -import com.example.cahier.core.ui.CahierTextureBitmapStore -import com.example.cahier.core.ui.theme.BrushBlack -import com.example.cahier.developer.brushdesigner.data.BrushDesignerRepository -import com.google.protobuf.ByteString -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import ink.proto.BrushBehavior -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.util.zip.GZIPInputStream -import java.util.zip.GZIPOutputStream -import javax.inject.Inject -import ink.proto.BrushCoat as ProtoBrushCoat -import ink.proto.BrushFamily as ProtoBrushFamily -import ink.proto.BrushPaint as ProtoBrushPaint -import ink.proto.BrushTip as ProtoBrushTip - -@OptIn(FlowPreview::class) -@HiltViewModel -class BrushDesignerViewModel @Inject constructor( - @param:ApplicationContext private val context: Context, - private val repository: BrushDesignerRepository, - private val textureStore: CahierTextureBitmapStore, - private val customBrushDao: CustomBrushDao, -) : ViewModel() { - - val activeBrushProto: StateFlow = repository.activeBrushProto - - val testStrokes: StateFlow> = repository.testStrokes - - private val _brushColor = MutableStateFlow(BrushBlack) - val brushColor: StateFlow = _brushColor.asStateFlow() - - val previewBrushFamily: StateFlow = repository.activeBrushProto - .debounce(150) - .map { proto -> - withContext(Dispatchers.IO) { - try { - val rawBytes = proto.toByteArray() - val baos = ByteArrayOutputStream() - GZIPOutputStream(baos).use { it.write(rawBytes) } - - ByteArrayInputStream(baos.toByteArray()).use { inputStream -> - BrushFamily.decode( - inputStream, - maxVersion = Version.DEVELOPMENT - ) { textureId: String, bitmap: Bitmap? -> - bitmap?.let { textureStore.loadTexture(textureId, it) } - textureId - } - } - } catch (e: Exception) { - null - } - } - } - .flowOn(Dispatchers.Default) - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = null - ) - - private val _brushSize = MutableStateFlow(15f) - val brushSize: StateFlow = _brushSize.asStateFlow() - - val activeBrush: StateFlow = combine( - previewBrushFamily, - _brushColor, - _brushSize - ) { family, color, size -> - if (family == null) null - else Brush.createWithComposeColor( - family = family, - color = color, - size = size, - epsilon = 0.1f - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = null - ) - - private val autoSaveFile = File(context.cacheDir, "autosave.brush") - - init { - if (autoSaveFile.exists()) { - loadBrushFromFile(Uri.fromFile(autoSaveFile)) - } - - viewModelScope.launch(Dispatchers.IO) { - repository.activeBrushProto - .debounce(1000L) - .collect { proto -> - try { - autoSaveFile.outputStream().use { outputStream -> - GZIPOutputStream(outputStream).use { gzip -> - gzip.write(proto.toByteArray()) - } - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - } - - val savedPaletteBrushes: StateFlow> = - customBrushDao.getAllCustomBrushes() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = emptyList() - ) - - private val _selectedCoatIndex = MutableStateFlow(0) - val selectedCoatIndex: StateFlow = _selectedCoatIndex.asStateFlow() - - - fun getActiveBrush(): Brush? = activeBrush.value - - fun onStrokesFinished(newStrokes: List) { - repository.updateTestStrokes(repository.testStrokes.value + newStrokes) - } - - fun replaceStrokes(updatedStrokes: List) { - repository.updateTestStrokes(updatedStrokes) - } - - fun clearCanvas() { - repository.updateTestStrokes(emptyList()) - } - - - fun saveBrushToFile(uri: Uri) { - viewModelScope.launch(Dispatchers.IO) { - try { - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - GZIPOutputStream(outputStream).use { gzipStream -> - gzipStream.write(repository.activeBrushProto.value.toByteArray()) - } - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - - fun loadBrushFromFile(uri: Uri) { - viewModelScope.launch { - try { - val bytes = withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.use { inputStream -> - GZIPInputStream(inputStream).use { gzipStream -> - gzipStream.readBytes() - } - } - } - if (bytes != null) { - val loadedProto = ProtoBrushFamily.parseFrom(bytes) - repository.updateActiveBrushProto(loadedProto) - clearCanvas() - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - - fun loadStockBrush(stockBrush: BrushFamily) { - viewModelScope.launch(Dispatchers.IO) { - try { - val baos = ByteArrayOutputStream() - stockBrush.encode(baos, textureStore) - - val gzippedBytes = baos.toByteArray() - ByteArrayInputStream(gzippedBytes).use { inputStream -> - GZIPInputStream(inputStream).use { gzipStream -> - val rawBytes = gzipStream.readBytes() - val loadedProto = ProtoBrushFamily.parseFrom(rawBytes) - repository.updateActiveBrushProto(loadedProto) - clearCanvas() - } - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - - fun updateDeveloperComment(comment: String) { - val builder = repository.activeBrushProto.value.toBuilder() - builder.developerComment = comment - repository.updateActiveBrushProto(builder.build()) - } - - fun updateTip(updateBlock: (ProtoBrushTip.Builder) -> Unit) { - val familyBuilder = repository.activeBrushProto.value.toBuilder() - val index = _selectedCoatIndex.value - - val coatBuilder = familyBuilder.getCoats(index).toBuilder() - val tipBuilder = coatBuilder.tip.toBuilder() - updateBlock(tipBuilder) - - coatBuilder.setTip(tipBuilder) - familyBuilder.setCoats(index, coatBuilder) - repository.updateActiveBrushProto(familyBuilder.build()) - } - - fun updateSelfOverlap(overlap: ProtoBrushPaint.SelfOverlap) { - val familyBuilder = repository.activeBrushProto.value.toBuilder() - val index = _selectedCoatIndex.value - - if (familyBuilder.coatsCount <= index) { - familyBuilder.addCoats( - ProtoBrushCoat.newBuilder() - .setTip(ProtoBrushTip.newBuilder()) - ) - } - val coatBuilder = familyBuilder.getCoats(index).toBuilder() - - if (coatBuilder.paintPreferencesCount == 0) { - coatBuilder.addPaintPreferences(ProtoBrushPaint.newBuilder()) - } - val paintBuilder = coatBuilder.getPaintPreferences(0).toBuilder() - - paintBuilder.selfOverlap = overlap - - coatBuilder.setPaintPreferences(0, paintBuilder) - familyBuilder.setCoats(index, coatBuilder) - repository.updateActiveBrushProto(familyBuilder.build()) - } - - fun updateSlidingWindowModel(windowMillis: Long, upsamplingHz: Int) { - val familyBuilder = repository.activeBrushProto.value.toBuilder() - - val inputModelBuilder = familyBuilder.inputModel.toBuilder() - - val swBuilder = inputModelBuilder.slidingWindowModel.toBuilder() - - swBuilder.setWindowSizeSeconds(windowMillis / 1000f) - - if (upsamplingHz <= 0) { - swBuilder.setExperimentalUpsamplingPeriodSeconds(Float.POSITIVE_INFINITY) - } else { - swBuilder.setExperimentalUpsamplingPeriodSeconds(1f / upsamplingHz.toFloat()) - } - - inputModelBuilder.setSlidingWindowModel(swBuilder) - familyBuilder.setInputModel(inputModelBuilder) - - repository.updateActiveBrushProto(familyBuilder.build()) - } - - fun updateInputModelToPassthrough() { - val familyBuilder = repository.activeBrushProto.value.toBuilder() - val inputModelBuilder = familyBuilder.inputModel.toBuilder() - - inputModelBuilder.setPassthroughModel( - ink.proto.BrushFamily.PassthroughModel.getDefaultInstance() - ) - - familyBuilder.setInputModel(inputModelBuilder) - repository.updateActiveBrushProto(familyBuilder.build()) - } - - fun setBrushColor(color: Color) { - _brushColor.value = color - } - - fun setBrushSize(size: Float) { - _brushSize.value = size - } - - fun saveToPalette(brushName: String): Job { - return viewModelScope.launch(Dispatchers.IO) { - try { - val rawBytes = repository.activeBrushProto.value.toByteArray() - val baos = ByteArrayOutputStream() - - GZIPOutputStream(baos).use { gzip -> - gzip.write(rawBytes) - } - - val finalCompressedBytes = baos.toByteArray() - - customBrushDao.saveCustomBrush( - CustomBrushEntity( - name = brushName, - brushBytes = finalCompressedBytes - ) - ) - } catch (e: Exception) { - e.printStackTrace() - } - } - } - - fun loadFromPalette(entity: CustomBrushEntity) { - viewModelScope.launch(Dispatchers.IO) { - try { - ByteArrayInputStream(entity.brushBytes).use { bais -> - GZIPInputStream(bais).use { gzip -> - val rawBytes = gzip.readBytes() - val loadedProto = ProtoBrushFamily.parseFrom(rawBytes) - - withContext(Dispatchers.Main) { - repository.updateActiveBrushProto(loadedProto) - clearCanvas() - } - } - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - - fun addCustomTexture(uri: Uri, textureId: String) { - viewModelScope.launch(Dispatchers.IO) { - try { - val bitmap = context.contentResolver.openInputStream(uri)?.use { - BitmapFactory.decodeStream(it) - } ?: return@launch - - textureStore.loadTexture(textureId, bitmap) - - val builder = repository.activeBrushProto.value.toBuilder() - - val baos = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos) - builder.putTextureIdToBitmap( - textureId, - ByteString.copyFrom(baos.toByteArray()) - ) - - val index = _selectedCoatIndex.value - if (builder.coatsCount <= index) { - builder.addCoats( - ProtoBrushCoat - .newBuilder().setTip(ProtoBrushTip.newBuilder()) - ) - } - val coatBuilder = builder.getCoats(index).toBuilder() - - if (coatBuilder.paintPreferencesCount == 0) { - coatBuilder.addPaintPreferences(ProtoBrushPaint.newBuilder()) - } - val paintBuilder = coatBuilder.getPaintPreferences(0).toBuilder() - - val textureLayer = ProtoBrushPaint.TextureLayer.newBuilder() - .setClientTextureId(textureId) - .setMapping(ProtoBrushPaint.TextureLayer.Mapping.MAPPING_TILING) - .setBlendMode(ProtoBrushPaint.TextureLayer.BlendMode.BLEND_MODE_SRC_OVER) - .setSizeUnit(ProtoBrushPaint.TextureLayer.SizeUnit.SIZE_UNIT_BRUSH_SIZE) - .setSizeX(1.0f) - .setSizeY(1.0f) - .build() - - paintBuilder.clearTextureLayers() - paintBuilder.addTextureLayers(textureLayer) - - coatBuilder.setPaintPreferences(0, paintBuilder) - builder.setCoats(index, coatBuilder) - - withContext(Dispatchers.Main) { - repository.updateActiveBrushProto(builder.build()) - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - - fun addBehavior(nodes: List) { - val familyBuilder = repository.activeBrushProto.value.toBuilder() - val index = selectedCoatIndex.value - - if (familyBuilder.coatsCount <= index) return - - val coatBuilder = familyBuilder.getCoats(index).toBuilder() - val tipBuilder = coatBuilder.tip.toBuilder() - - val behavior = BrushBehavior.newBuilder() - .addAllNodes(nodes) - .build() - - tipBuilder.addBehaviors(behavior) - - coatBuilder.setTip(tipBuilder) - familyBuilder.setCoats(index, coatBuilder) - - repository.updateActiveBrushProto(familyBuilder.build()) - } - - fun clearBehaviors() { - val familyBuilder = repository.activeBrushProto.value.toBuilder() - val index = _selectedCoatIndex.value - val coatBuilder = familyBuilder.getCoats(index).toBuilder() - val tipBuilder = coatBuilder.tip.toBuilder() - tipBuilder.clearBehaviors() - coatBuilder.setTip(tipBuilder) - familyBuilder.setCoats(index, coatBuilder) - repository.updateActiveBrushProto(familyBuilder.build()) - } - - /** - * Replaces all paint preferences for the current coat. - * Used by [EditableListWidget] for multi-paint editing. - */ - fun updatePaintPreferences(paints: List) { - val familyBuilder = repository.activeBrushProto.value.toBuilder() - val index = _selectedCoatIndex.value - if (familyBuilder.coatsCount <= index) return - - val coatBuilder = familyBuilder.getCoats(index).toBuilder() - coatBuilder.clearPaintPreferences() - paints.forEach { coatBuilder.addPaintPreferences(it) } - familyBuilder.setCoats(index, coatBuilder) - repository.updateActiveBrushProto(familyBuilder.build()) - } - - /** - * Replaces all behaviors for the current coat's tip. - * Used by [EditableListWidget] for behavior graph editing. - */ - fun updateBehaviorsList(behaviors: List) { - updateTip { tipBuilder -> - tipBuilder.clearBehaviors() - behaviors.forEach { tipBuilder.addBehaviors(it) } - } - } - - /** Returns the loaded bitmap for a texture ID, or null if not loaded. */ - fun getTextureBitmap(textureId: String): Bitmap? = textureStore.get(textureId) - - fun setSelectedCoat(index: Int) { - _selectedCoatIndex.value = index - } - - fun addNewCoat() { - val familyBuilder = repository.activeBrushProto.value.toBuilder() - - val newCoat = ProtoBrushCoat.newBuilder() - .setTip(ProtoBrushTip.newBuilder().setScaleX(1f).setScaleY(1f).setCornerRounding(1f)) - .addPaintPreferences(ProtoBrushPaint.newBuilder().build()) - .build() - - familyBuilder.addCoats(newCoat) - repository.updateActiveBrushProto(familyBuilder.build()) - - _selectedCoatIndex.value = familyBuilder.coatsCount - 1 - } - - fun deleteSelectedCoat() { - val familyBuilder = repository.activeBrushProto.value.toBuilder() - if (familyBuilder.coatsCount <= 1) return - - val indexToRemove = _selectedCoatIndex.value - familyBuilder.removeCoats(indexToRemove) - - repository.updateActiveBrushProto(familyBuilder.build()) - - _selectedCoatIndex.value = (indexToRemove - 1).coerceAtLeast(0) - } - - fun deleteFromPalette(name: String) { - viewModelScope.launch(Dispatchers.IO) { - customBrushDao.deleteCustomBrush(name) - } - } - - companion object { - private const val TAG = "BrushDesignerViewModel" - } -} diff --git a/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/PrefabBehaviors.kt b/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/PrefabBehaviors.kt deleted file mode 100644 index d455acf1..00000000 --- a/app/src/main/java/com/example/cahier/developer/brushdesigner/viewmodel/PrefabBehaviors.kt +++ /dev/null @@ -1,309 +0,0 @@ -/* - * Copyright 2026 Google LLC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.cahier.developer.brushdesigner.viewmodel - -import ink.proto.BrushBehavior -import kotlin.math.PI - -/** - * Pre-built behavior node lists for the Brush Designer's "Quick Add" menu. - * - * Each function returns a fully configured [List] of [BrushBehavior.Node] with - * source/target ranges tuned for the specific use case. The ViewModel's generic - * [BrushDesignerViewModel.addBehavior] method accepts these lists directly. - * - * Organized by input source category: - * - **Pressure-based**: respond to stylus pressure - * - **Tilt-based**: respond to stylus tilt angle - * - **Speed-based**: respond to stroke velocity - * - **Direction-based**: respond to stroke direction - * - **Distance/Time-based**: animate over stroke progress - * - **Jitter (Noise-based)**: add randomized variation - */ -object PrefabBehaviors { - - // ── Standard (un-damped) presets ─────────────────────────────── - - /** Simple Pressure → Size: 50%–150%, no damping. */ - fun simplePressureToSize(): List = direct( - source = BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE, - sourceStart = 0f, sourceEnd = 1f, - target = BrushBehavior.Target.TARGET_SIZE_MULTIPLIER, - targetStart = 0.5f, targetEnd = 1.5f - ) - - /** Simple Speed → Size: 20%–200%, no damping, wide source range. */ - fun simpleSpeedToSize(): List = direct( - source = BrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, - sourceStart = 0f, sourceEnd = 50f, - target = BrushBehavior.Target.TARGET_SIZE_MULTIPLIER, - targetStart = 0.2f, targetEnd = 2.0f - ) - - /** Simple Speed → Opacity: fades at speed, no damping. */ - fun simpleSpeedToOpacity(): List = direct( - source = BrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, - sourceStart = 5f, sourceEnd = 40f, - target = BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER, - targetStart = 1.0f, targetEnd = 0.1f - ) - - // ── Pressure-based ──────────────────────────────────────────── - - /** Pressure → Size: tip scales 50%–150% based on stylus pressure. */ - fun pressureToSize(): List = smoothed( - source = BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE, - sourceStart = 0f, sourceEnd = 1f, - target = BrushBehavior.Target.TARGET_SIZE_MULTIPLIER, - targetStart = 0.5f, targetEnd = 1.5f, - dampingSeconds = 0.15f - ) - - /** Pressure → Width: tip width scales 60%–140% based on stylus pressure. */ - fun pressureToWidth(): List = smoothed( - source = BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE, - sourceStart = 0f, sourceEnd = 1f, - target = BrushBehavior.Target.TARGET_WIDTH_MULTIPLIER, - targetStart = 0.6f, targetEnd = 1.4f, - dampingSeconds = 0.1f - ) - - /** Pressure → Opacity: lighter pressure = more transparent. */ - fun pressureToOpacity(): List = smoothed( - source = BrushBehavior.Source.SOURCE_NORMALIZED_PRESSURE, - sourceStart = 0f, sourceEnd = 1f, - target = BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER, - targetStart = 0.3f, targetEnd = 1.0f, - dampingSeconds = 0.1f - ) - - // ── Tilt-based ──────────────────────────────────────────────── - - /** Tilt → Width: tilting stylus widens stroke (1×–2.5×). */ - fun tiltToWidth(): List = smoothed( - source = BrushBehavior.Source.SOURCE_TILT_IN_RADIANS, - sourceStart = 0f, sourceEnd = (PI / 2).toFloat(), - target = BrushBehavior.Target.TARGET_WIDTH_MULTIPLIER, - targetStart = 1.0f, targetEnd = 2.5f, - dampingSeconds = 0.1f - ) - - /** Tilt → Slant: tilting stylus rotates the tip shape. */ - fun tiltToSlant(): List = smoothed( - source = BrushBehavior.Source.SOURCE_TILT_IN_RADIANS, - sourceStart = 0f, sourceEnd = (PI / 2).toFloat(), - target = BrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS, - targetStart = -0.5f, targetEnd = 0.5f, - dampingSeconds = 0.08f - ) - - /** Tilt → Opacity: flat tilt = more transparent (0.4×–1.0×). */ - fun tiltToOpacity(): List = smoothed( - source = BrushBehavior.Source.SOURCE_TILT_IN_RADIANS, - sourceStart = 0f, sourceEnd = (PI / 2).toFloat(), - target = BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER, - targetStart = 0.4f, targetEnd = 1.0f, - dampingSeconds = 0.1f - ) - - // ── Speed-based ─────────────────────────────────────────────── - - /** Speed → Opacity: fast strokes become transparent (1.0×→0.2×). */ - fun speedToOpacity(): List = smoothed( - source = BrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, - sourceStart = 0f, sourceEnd = 8f, - target = BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER, - targetStart = 1.0f, targetEnd = 0.2f, - dampingSeconds = 0.3f - ) - - /** Speed → Size: fast strokes shrink (1.0×→0.4×). */ - fun speedToSize(): List = smoothed( - source = BrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, - sourceStart = 0f, sourceEnd = 8f, - target = BrushBehavior.Target.TARGET_SIZE_MULTIPLIER, - targetStart = 1.0f, targetEnd = 0.4f, - dampingSeconds = 0.2f - ) - - /** Speed → Width: fast strokes narrow (1.0×→0.5×). */ - fun speedToWidth(): List = smoothed( - source = BrushBehavior.Source.SOURCE_SPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, - sourceStart = 0f, sourceEnd = 8f, - target = BrushBehavior.Target.TARGET_WIDTH_MULTIPLIER, - targetStart = 1.0f, targetEnd = 0.5f, - dampingSeconds = 0.15f - ) - - // ── Direction-based ─────────────────────────────────────────── - - /** Direction → Rotation: tip rotates to follow stroke direction. */ - fun directionToRotation(): List = smoothed( - source = BrushBehavior.Source.SOURCE_DIRECTION_IN_RADIANS, - sourceStart = 0f, sourceEnd = (2 * PI).toFloat(), - target = BrushBehavior.Target.TARGET_ROTATION_OFFSET_IN_RADIANS, - targetStart = (-PI).toFloat(), targetEnd = PI.toFloat(), - dampingSeconds = 0.05f - ) - - // ── Distance / Time-based ───────────────────────────────────── - - /** Distance → Opacity fade: stroke fades out over 5× brush-size distance. */ - fun distanceToOpacityFade(): List = direct( - source = BrushBehavior.Source.SOURCE_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE, - sourceStart = 0f, sourceEnd = 5f, - target = BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER, - targetStart = 1.0f, targetEnd = 0.1f - ) - - /** Time → Opacity fade: stroke fades out over 2 seconds. */ - fun timeToOpacityFade(): List = direct( - source = BrushBehavior.Source.SOURCE_TIME_OF_INPUT_IN_SECONDS, - sourceStart = 0f, sourceEnd = 2f, - target = BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER, - targetStart = 1.0f, targetEnd = 0.2f - ) - - // ── Jitter (Noise-based) ────────────────────────────────────── - - /** Random slant jitter: pencil-like wobble (±0.15 radians). */ - fun slantJitter(): List = jitter( - target = BrushBehavior.Target.TARGET_SLANT_OFFSET_IN_RADIANS, - targetStart = -0.15f, targetEnd = 0.15f, - basePeriod = 0.3f - ) - - /** Random width jitter: organic line variation (85%–115%). */ - fun widthJitter(): List = jitter( - target = BrushBehavior.Target.TARGET_WIDTH_MULTIPLIER, - targetStart = 0.85f, targetEnd = 1.15f, - basePeriod = 0.4f - ) - - /** Random opacity jitter: textured feel (70%–100%). */ - fun opacityJitter(): List = jitter( - target = BrushBehavior.Target.TARGET_OPACITY_MULTIPLIER, - targetStart = 0.7f, targetEnd = 1.0f, - basePeriod = 0.5f - ) - - /** Random hue jitter: color variation (±0.15 radians). */ - fun hueJitter(): List = jitter( - target = BrushBehavior.Target.TARGET_HUE_OFFSET_IN_RADIANS, - targetStart = -0.15f, targetEnd = 0.15f, - basePeriod = 0.8f - ) - - /** Random lateral position jitter: scatter effect (±0.1× brush size). */ - fun positionJitter(): List = jitter( - target = BrushBehavior.Target.TARGET_POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE, - targetStart = -0.1f, targetEnd = 0.1f, - basePeriod = 0.2f - ) - - // ── Private helpers ─────────────────────────────────────────── - - /** - * Builds a smoothed behavior: Source → Damping → Target. - * The damping node smooths the source signal over time. - */ - private fun smoothed( - source: BrushBehavior.Source, - sourceStart: Float, - sourceEnd: Float, - target: BrushBehavior.Target, - targetStart: Float, - targetEnd: Float, - dampingSeconds: Float - ): List = listOf( - sourceNode(source, sourceStart, sourceEnd), - dampingNode(dampingSeconds), - targetNode(target, targetStart, targetEnd) - ) - - /** - * Builds a direct (un-damped) behavior: Source → Target. - * The source signal maps directly to the target without smoothing. - */ - private fun direct( - source: BrushBehavior.Source, - sourceStart: Float, - sourceEnd: Float, - target: BrushBehavior.Target, - targetStart: Float, - targetEnd: Float - ): List = listOf( - sourceNode(source, sourceStart, sourceEnd), - targetNode(target, targetStart, targetEnd) - ) - - /** - * Builds a noise-based jitter behavior: Noise → Target. - * The noise source varies over distance for organic-feeling variation. - */ - private fun jitter( - target: BrushBehavior.Target, - targetStart: Float, - targetEnd: Float, - basePeriod: Float - ): List = listOf( - noiseNode(basePeriod), - targetNode(target, targetStart, targetEnd) - ) - - private fun sourceNode( - source: BrushBehavior.Source, - rangeStart: Float, - rangeEnd: Float - ): BrushBehavior.Node = BrushBehavior.Node.newBuilder().setSourceNode( - BrushBehavior.SourceNode.newBuilder() - .setSource(source) - .setSourceValueRangeStart(rangeStart) - .setSourceValueRangeEnd(rangeEnd) - .setSourceOutOfRangeBehavior(BrushBehavior.OutOfRange.OUT_OF_RANGE_CLAMP) - ).build() - - private fun dampingNode(dampingSeconds: Float): BrushBehavior.Node = - BrushBehavior.Node.newBuilder().setDampingNode( - BrushBehavior.DampingNode.newBuilder() - .setDampingSource( - BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_TIME_IN_SECONDS - ) - .setDampingGap(dampingSeconds) - ).build() - - private fun targetNode( - target: BrushBehavior.Target, - rangeStart: Float, - rangeEnd: Float - ): BrushBehavior.Node = BrushBehavior.Node.newBuilder().setTargetNode( - BrushBehavior.TargetNode.newBuilder() - .setTarget(target) - .setTargetModifierRangeStart(rangeStart) - .setTargetModifierRangeEnd(rangeEnd) - ).build() - - private fun noiseNode(basePeriod: Float): BrushBehavior.Node = - BrushBehavior.Node.newBuilder().setNoiseNode( - BrushBehavior.NoiseNode.newBuilder() - .setSeed(kotlin.random.Random.nextInt()) - .setVaryOver( - BrushBehavior.ProgressDomain.PROGRESS_DOMAIN_DISTANCE_IN_CENTIMETERS - ) - .setBasePeriod(basePeriod) - ).build() -} From a2910053225d29e50c43ba53e9784c1c4240473f Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:00:33 +0000 Subject: [PATCH 2/3] Delete unused brushdesigner string resources --- .../brushgraph/ui/BrushDesignerComponents.kt | 2 +- .../developer/brushgraph/ui/NumericField.kt | 2 +- app/src/main/res/values/strings.xml | 156 +----------------- 3 files changed, 6 insertions(+), 154 deletions(-) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushDesignerComponents.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushDesignerComponents.kt index 9f9d6374..be6880cc 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushDesignerComponents.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/BrushDesignerComponents.kt @@ -129,7 +129,7 @@ fun CustomColorPickerDialog( }, dismissButton = { TextButton(onClick = onDismissRequest) { - Text(stringResource(R.string.brush_designer_cancel)) + Text(stringResource(R.string.bg_cancel)) } } ) diff --git a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/NumericField.kt b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/NumericField.kt index fdc3995a..4b51179e 100644 --- a/app/src/main/java/com/example/cahier/developer/brushgraph/ui/NumericField.kt +++ b/app/src/main/java/com/example/cahier/developer/brushgraph/ui/NumericField.kt @@ -249,7 +249,7 @@ internal fun NumericField( }, dismissButton = { TextButton(onClick = { showTextInput = false }) { - Text(stringResource(R.string.brush_designer_cancel)) + Text(stringResource(R.string.bg_cancel)) } } ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e88315fc..85600924 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -102,135 +102,14 @@ Brush Designer - Editing controls coming in the next update. Use the preview canvas to draw test strokes. - Close - Stock Brushes - My Brushes - No saved brushes yet - Save - Import - Export - More options - Save to Cahier - • This brush will appear in the main Cahier toolbox.\n• Large textures are stored in RAM. Avoid saving many texture-heavy brushes to prevent performance lag or memory issues. - Brush Name - Tip Shape - Paint - Behaviors - Brush Layers (Coats) - Coat %1$d - Add Layer - Delete Layer - Lock text fields - Client brush family ID - Developer comment - Input Model - Model Type - Passthrough Model - Sliding Window Model - Sliding Window Model (Default) - Name Texture - Texture ID (e.g. pattern_1) - Load - Cancel - Tip Geometry - Particle Settings (Stamps) - Paint & Texture - Dynamics & Behaviors - No behaviors defined. - Clear All Behaviors - Standard Dynamics - Add Advanced Dynamics - Invalid Brush Configuration\nCheck Constraints - Pick a color + Pick a color Custom Color… - Import Texture from Gallery - Loaded Textures: %1$d - Textures - Color Functions - - Decrease %1$s Increase %1$s Enter exact value - Lock Scale Ratio - - - Add %1$s - Duplicate - Delete - Editing Item %1$d - - - + Pressure Size - + Speed Size - + Speed affects Opacity - Smooth Pressure ➔ Size - Add Pencil Jitter (Slant) - Smooth Speed ➔ Opacity - Behavior Stack - Nodes in this Behavior: - - - Active Texture - Texture ID - No Texture Selected - Mapping Mode - Blend Mode - Self Overlap - Texture Layers - Paint Preferences - - - %1$d px - Pick a color - - - Source - Source Input - Range Start - Range End - Out of Range - Response Curve - Curve Type - Control X1 - Control Y1 - Control X2 - Control Y2 - Predefined Curve - Step Count - Damping (Smoothing) - Damping Source - Gap (seconds) - Noise (Jitter) - Seed - Vary Over - Base Period - Target - Target Output - Modifier Range Start - Modifier Range End - Constant Value - Value - Binary Operation - Operation - Interpolation - - - No color functions — add one below. - Opacity: %1$.1fx - Replace Color - Unknown - Size Unit - Scale X - Scale Y - Rotation - Origin - Offset X - Offset Y - Wrap X - Wrap Y - Empty + Window size (ms) + Upsampling frequency (Hz) + value Black @@ -239,33 +118,6 @@ Green Yellow - - Function Type - Opacity Multiplier - Window size (ms) - Upsampling frequency (Hz) - value - - - Scale X - Scale Y - Corner Rounding - Slant - Rotation - Pinch - Gap Distance Scale - Gap Duration (ms) - - - Texture: %1$s - Replace Color function — color source is controlled by a behavior node. - Unknown color function type. - - - Brushes - Stock - Cahier - Developer Tools Experimental features and debugging tools. From 78a2621adaad2ff6b7d39ea7286ecfd2df723904 Mon Sep 17 00:00:00 2001 From: Maxwell Metzger Mitchell <60010512+maxmmitchell@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:25:52 +0000 Subject: [PATCH 3/3] Delete unneeded test --- .../viewmodel/BrushDesignerViewModelTest.kt | 170 ------------------ 1 file changed, 170 deletions(-) delete mode 100644 app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt diff --git a/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt b/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt deleted file mode 100644 index 89b09d2d..00000000 --- a/app/src/androidTest/java/com/example/cahier/developer/brushdesigner/viewmodel/BrushDesignerViewModelTest.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.example.cahier.developer.brushdesigner.viewmodel - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.example.cahier.core.data.CustomBrushDao -import com.example.cahier.core.ui.CahierTextureBitmapStore -import com.example.cahier.developer.brushdesigner.data.BrushDesignerRepository -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import javax.inject.Inject - -@RunWith(AndroidJUnit4::class) -@OptIn(ExperimentalCoroutinesApi::class) -@HiltAndroidTest -class BrushDesignerViewModelTest { - - @get:Rule - var hiltRule = HiltAndroidRule(this) - - private val testDispatcher = UnconfinedTestDispatcher() - - @Inject - lateinit var customBrushDao: CustomBrushDao - - @Inject - lateinit var repository: BrushDesignerRepository - - private lateinit var viewModel: BrushDesignerViewModel - - @Before - fun setup() { - hiltRule.inject() - Dispatchers.setMain(testDispatcher) - - // Reset the singleton repository to default state to avoid test pollution - // from previous test runs or app usage within the same process. - repository.updateActiveBrushProto(defaultProto()) - - val context = ApplicationProvider.getApplicationContext() - viewModel = BrushDesignerViewModel( - context, repository, - CahierTextureBitmapStore(context), customBrushDao - ) - } - - /** Produces a clean default [ProtoBrushFamily] matching the repository's initial state. */ - private fun defaultProto(): ink.proto.BrushFamily = ink.proto.BrushFamily.newBuilder() - .addCoats( - ink.proto.BrushCoat.newBuilder() - .setTip( - ink.proto.BrushTip.newBuilder() - .setScaleX(1f).setScaleY(1f).setCornerRounding(1f) - ) - .addPaintPreferences( - ink.proto.BrushPaint.newBuilder() - .setSelfOverlap( - ink.proto.BrushPaint.SelfOverlap.SELF_OVERLAP_ANY - ) - .addColorFunctions( - ink.proto.ColorFunction.newBuilder() - .setOpacityMultiplier(1f) - ) - ) - ) - .setInputModel( - ink.proto.BrushFamily.InputModel.newBuilder() - .setSlidingWindowModel( - ink.proto.BrushFamily.SlidingWindowModel.newBuilder() - .setWindowSizeSeconds(0.02f) - .setExperimentalUpsamplingPeriodSeconds(0.005f) - ) - ) - .build() - - @After - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun updateTip_modifies_correct_coat_index() = runTest { - assertEquals( - 1.0f, - viewModel.activeBrushProto.value.getCoats(0).tip.scaleX, - 0.01f - ) - - viewModel.updateTip { it.setScaleX(2.5f) } - - assertEquals( - 2.5f, - viewModel.activeBrushProto.value.getCoats(0).tip.scaleX, - 0.01f - ) - } - - @Test - fun addBehavior_stacks_multiple_rules() = runTest { - viewModel.clearBehaviors() - assertEquals( - 0, - viewModel.activeBrushProto.value.getCoats(0).tip.behaviorsCount - ) - - val node1 = ink.proto.BrushBehavior.Node.newBuilder().setConstantNode( - ink.proto.BrushBehavior.ConstantNode.newBuilder().setValue(1f) - ).build() - viewModel.addBehavior(listOf(node1)) - - val node2 = ink.proto.BrushBehavior.Node.newBuilder().setConstantNode( - ink.proto.BrushBehavior.ConstantNode.newBuilder().setValue(2f) - ).build() - viewModel.addBehavior(listOf(node2)) - - val behaviors = viewModel.activeBrushProto.value.getCoats(0).tip.behaviorsList - assertEquals(2, behaviors.size) - } - - @Test - fun addNewCoat_increments_count_and_switches_selection() = runTest { - assertEquals(1, viewModel.activeBrushProto.value.coatsCount) - assertEquals(0, viewModel.selectedCoatIndex.value) - - viewModel.addNewCoat() - - assertEquals(2, viewModel.activeBrushProto.value.coatsCount) - assertEquals(1, viewModel.selectedCoatIndex.value) - } - - @Test - fun saveToPalette_persists_to_dao() = runTest { - val brushName = "Test Persistence Brush" - - viewModel.saveToPalette(brushName).join() - - val savedBrushes = customBrushDao.getAllCustomBrushes().first() - assertTrue(savedBrushes.any { it.name == brushName }) - } - - @Test - fun previewBrushFamily_is_null_on_invalid_proto() = runTest { - val invalidRepo = BrushDesignerRepository() - invalidRepo.updateActiveBrushProto(ink.proto.BrushFamily.newBuilder().build()) - val context = ApplicationProvider.getApplicationContext() - val vm = BrushDesignerViewModel( - context, - invalidRepo, - CahierTextureBitmapStore(context), - customBrushDao - ) - - assertNull(vm.previewBrushFamily.value) - } -}