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
}
/**