diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt index 5273e8b1194e..36debb9a1a4a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt @@ -85,6 +85,7 @@ class WhiteboardFragment : observeViewModel(binding.whiteboardView) binding.whiteboardView.onNewPath = viewModel::addPath + binding.whiteboardView.onStylusButtonStateChanged = viewModel::setStylusButtonPressed binding.whiteboardView.onEraseGestureStart = viewModel::startPathEraseGesture binding.whiteboardView.onEraseGestureMove = viewModel::erasePathsAtPoint binding.whiteboardView.onEraseGestureEnd = viewModel::endPathEraseGesture @@ -136,24 +137,17 @@ class WhiteboardFragment : private fun observeViewModel(whiteboardView: WhiteboardView) { viewModel.paths.onEach(whiteboardView::setHistory).launchIn(lifecycleScope) - combine( - viewModel.brushColor, - viewModel.activeStrokeWidth, - ) { color, width -> - whiteboardView.setCurrentBrush(color, width) - }.launchIn(lifecycleScope) + viewModel.effectiveTool + .onEach { tool -> + whiteboardView.setTool(tool) + }.launchIn(lifecycleScope) combine( viewModel.isEraserActive, - viewModel.eraserMode, - viewModel.eraserDisplayWidth, - ) { isActive, mode, width -> - whiteboardView.isEraserActive = isActive - binding.eraserButton.updateState(isActive, mode, width) - whiteboardView.eraserMode = mode - if (!isActive) { - eraserPopup?.dismiss() - } + viewModel.eraserTool, + ) { isActive, eraser -> + binding.eraserButton.updateState(isActive, eraser.mode, eraser.width) + if (!isActive) eraserPopup?.dismiss() }.launchIn(lifecycleScope) viewModel.brushes @@ -388,7 +382,7 @@ class WhiteboardFragment : val inflater = LayoutInflater.from(requireContext()) val eraserWidthBinding = PopupEraserOptionsBinding.inflate(inflater) - eraserWidthBinding.eraserWidthSlider.value = viewModel.eraserDisplayWidth.value + eraserWidthBinding.eraserWidthSlider.value = viewModel.eraserTool.value.width eraserWidthBinding.eraserWidthSlider.addOnChangeListener { _, value, fromUser -> if (fromUser) viewModel.setActiveStrokeWidth(value) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt index 9956fe5ae5c3..468e442e966d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt @@ -38,14 +38,14 @@ class WhiteboardView : View { // Callbacks for user actions var onNewPath: ((Path) -> Unit)? = null + var onStylusButtonStateChanged: ((Boolean) -> Unit)? = null var onEraseGestureStart: (() -> Unit)? = null var onEraseGestureMove: ((Float, Float) -> Unit)? = null var onEraseGestureEnd: (() -> Unit)? = null // Public properties for tool state - var isEraserActive: Boolean = false - var eraserMode: EraserMode = EraserMode.INK var isStylusOnlyMode: Boolean = false + private var currentTool: WhiteboardTool = WhiteboardTool.Brush(Color.BLACK, 10f) // Internal drawing state private val currentPath = Path() @@ -68,6 +68,22 @@ class WhiteboardView : View { private var hasMoved = false + fun setTool(tool: WhiteboardTool) { + currentTool = tool + when (tool) { + is WhiteboardTool.Brush -> { + currentPaint.color = tool.color + currentPaint.strokeWidth = tool.width + currentPaint.xfermode = null + } + is WhiteboardTool.Eraser -> { + currentPaint.strokeWidth = tool.width + eraserPreviewPaint.strokeWidth = tool.width + } + } + invalidate() + } + /** * Recreates the drawing buffer when the view size changes. */ @@ -93,11 +109,9 @@ class WhiteboardView : View { // Draw the committed history canvas.drawBitmap(bufferBitmap, 0f, 0f, canvasPaint) - // Draw the live preview path for the current gesture - if (isEraserActive) { + if (currentTool is WhiteboardTool.Eraser) { canvas.drawPath(currentPath, eraserPreviewPaint) } else { - // Draw the normal brush or pixel eraser preview canvas.drawPath(currentPath, currentPaint) } } @@ -111,9 +125,15 @@ class WhiteboardView : View { return false } + val toolType = event.getToolType(0) + val isButtonPressed = + (event.buttonState and MotionEvent.BUTTON_STYLUS_PRIMARY != 0) || + (toolType == MotionEvent.TOOL_TYPE_ERASER) + onStylusButtonStateChanged?.invoke(isButtonPressed) + val touchX = event.x val touchY = event.y - val isPathEraser = isEraserActive && eraserMode == EraserMode.STROKE + val isPathEraser = (currentTool as? WhiteboardTool.Eraser)?.mode == EraserMode.STROKE when (event.action) { MotionEvent.ACTION_DOWN -> { @@ -148,7 +168,10 @@ class WhiteboardView : View { currentPath.reset() invalidate() } - else -> return false + MotionEvent.ACTION_CANCEL -> { + currentPath.reset() + invalidate() + } } return true } @@ -161,21 +184,6 @@ class WhiteboardView : View { redrawHistory() } - /** - * Configures the paint for the live drawing preview based on the current tool. - */ - fun setCurrentBrush( - color: Int, - strokeWidth: Float, - ) { - currentPaint.strokeWidth = strokeWidth - currentPaint.xfermode = null - currentPaint.color = color - - // Configure the stroke eraser's preview paint separately - eraserPreviewPaint.strokeWidth = strokeWidth - } - /** * Redraws all historical paths onto the offscreen buffer. */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt index 967811e47e29..4a2d7ca669df 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt @@ -14,6 +14,7 @@ * this program. If not, see . */ package com.ichi2.anki.ui.windows.reviewer.whiteboard + import android.content.SharedPreferences import android.graphics.Color import android.graphics.Path @@ -26,12 +27,25 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import timber.log.Timber +sealed class WhiteboardTool { + data class Brush( + val color: Int, + val width: Float, + ) : WhiteboardTool() + + data class Eraser( + val mode: EraserMode, + val width: Float, + ) : WhiteboardTool() +} + /** * Represents a single drawing action on the whiteboard, such as a brush stroke or an eraser mark. */ @@ -92,17 +106,60 @@ class WhiteboardViewModel( val inkEraserStrokeWidth = MutableStateFlow(WhiteboardRepository.DEFAULT_ERASER_WIDTH) val strokeEraserStrokeWidth = MutableStateFlow(WhiteboardRepository.DEFAULT_ERASER_WIDTH) - val brushColor = MutableStateFlow(Color.BLACK) - val activeStrokeWidth = MutableStateFlow(WhiteboardRepository.DEFAULT_STROKE_WIDTH) val isEraserActive = MutableStateFlow(false) val eraserMode = MutableStateFlow(EraserMode.INK) val isStylusOnlyMode = MutableStateFlow(false) val toolbarAlignment = MutableStateFlow(ToolbarAlignment.BOTTOM) + private val isStylusButtonPressed = MutableStateFlow(false) - val eraserDisplayWidth = - combine(eraserMode, inkEraserStrokeWidth, strokeEraserStrokeWidth) { mode, inkWidth, strokeWidth -> - if (mode == EraserMode.INK) inkWidth else strokeWidth - }.stateIn(viewModelScope, SharingStarted.Eagerly, WhiteboardRepository.DEFAULT_ERASER_WIDTH) + /** + * The current configuration of the eraser tool. + */ + val eraserTool: StateFlow = + combine( + eraserMode, + inkEraserStrokeWidth, + strokeEraserStrokeWidth, + ) { mode, inkW, strokeW -> + WhiteboardTool.Eraser(mode, if (mode == EraserMode.INK) inkW else strokeW) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + WhiteboardTool.Eraser(EraserMode.INK, WhiteboardRepository.DEFAULT_ERASER_WIDTH), + ) + + /** + * The single source of truth for what tool is currently active. + * It combines the UI selection with real-time physical stylus input. + */ + val effectiveTool: StateFlow = + combine( + isEraserActive, + activeBrushIndex, + brushes, + isStylusButtonPressed, + eraserTool, + ) { eraserActive, brushIndex, brushList, stylusOverride, eraserTool -> + when { + eraserActive || stylusOverride -> eraserTool + else -> { + val brush = brushList.getOrNull(brushIndex) ?: BrushInfo(Color.BLACK, 10f) + WhiteboardTool.Brush(brush.color, brush.width) + } + } + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + WhiteboardTool.Brush(Color.BLACK, WhiteboardRepository.DEFAULT_STROKE_WIDTH), + ) + + /** + * The color of the currently selected brush. + */ + val brushColor = + combine(brushes, activeBrushIndex) { list, index -> + list.getOrNull(index)?.color ?: Color.BLACK + }.stateIn(viewModelScope, SharingStarted.Eagerly, Color.BLACK) private val pathsErasedInCurrentGesture = mutableListOf() private var pathsBeforeGesture: List = emptyList() @@ -127,18 +184,35 @@ class WhiteboardViewModel( } } + /** + * Informs the ViewModel about physical stylus button state. + */ + fun setStylusButtonPressed(isPressed: Boolean) { + isStylusButtonPressed.value = isPressed + } + /** * Adds a new completed path to the drawing history. */ fun addPath(path: Path) { - val isPixelEraser = isEraserActive.value && eraserMode.value == EraserMode.INK val newAction = - DrawingAction( - path, - brushColor.value, - activeStrokeWidth.value, - isPixelEraser, - ) + when (val tool = effectiveTool.value) { + is WhiteboardTool.Brush -> + DrawingAction( + path = path, + color = tool.color, + strokeWidth = tool.width, + isEraser = false, + ) + is WhiteboardTool.Eraser -> + DrawingAction( + path = path, + color = Color.TRANSPARENT, + strokeWidth = tool.width, + isEraser = true, + ) + } + paths.update { it + newAction } undoStack.update { it + AddAction(newAction) } redoStack.value = emptyList() @@ -160,6 +234,8 @@ class WhiteboardViewModel( y: Float, ) { if (paths.value.isEmpty()) return + val currentTool = effectiveTool.value + if (currentTool !is WhiteboardTool.Eraser || currentTool.mode != EraserMode.STROKE) return val remainingPaths = paths.value.toMutableList() var pathWasErased = false @@ -167,7 +243,7 @@ class WhiteboardViewModel( val pathsToEvaluate = remainingPaths.filter { it !in pathsErasedInCurrentGesture && !it.isEraser } for (action in pathsToEvaluate) { - if (isPathIntersectingWithCircle(action, x, y, activeStrokeWidth.value / 2)) { + if (isPathIntersectingWithCircle(action, x, y, currentTool.width / 2)) { remainingPaths.remove(action) pathsErasedInCurrentGesture.add(action) pathWasErased = true @@ -188,15 +264,11 @@ class WhiteboardViewModel( cy: Float, eraserRadius: Float, ): Boolean { - val path = action.path - val pathStrokeWidth = action.strokeWidth - - val pathMeasure = PathMeasure(path, false) + val pathMeasure = PathMeasure(action.path, false) val length = pathMeasure.length val pos = FloatArray(2) - val pathRadius = pathStrokeWidth / 2 - val totalRadius = eraserRadius + pathRadius + val totalRadius = eraserRadius + action.strokeWidth / 2 val totalRadiusSquared = totalRadius * totalRadius if (length == 0f) { @@ -307,22 +379,15 @@ class WhiteboardViewModel( * Sets the active brush by its index and deactivates the eraser if it was active. */ fun setActiveBrush(index: Int) { - val brush = brushes.value.getOrNull(index) ?: return - - isEraserActive.value = false - activeBrushIndex.value = index - repository.saveLastActiveBrushIndex(index, isDarkMode) - - brushColor.value = brush.color - activeStrokeWidth.value = brush.width + brushes.value.getOrNull(index)?.let { + isEraserActive.value = false + activeBrushIndex.value = index + repository.saveLastActiveBrushIndex(index, isDarkMode) + } } - /** - * Toggles the eraser tool on or off. - */ fun enableEraser() { isEraserActive.value = true - activeStrokeWidth.value = eraserDisplayWidth.value } /** @@ -331,14 +396,6 @@ class WhiteboardViewModel( fun setEraserMode(mode: EraserMode) { eraserMode.value = mode repository.eraserMode = mode - if (isEraserActive.value) { - activeStrokeWidth.value = - if (mode == EraserMode.INK) { - inkEraserStrokeWidth.value - } else { - strokeEraserStrokeWidth.value - } - } } /** @@ -360,7 +417,6 @@ class WhiteboardViewModel( brushes.value = updatedBrushes repository.saveBrushes(updatedBrushes, isDarkMode) } - activeStrokeWidth.value = newWidth } @CheckResult @@ -379,7 +435,6 @@ class WhiteboardViewModel( brushes.value = updatedBrushes repository.saveBrushes(brushes.value, isDarkMode) - brushColor.value = newColor } /**