Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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.
*/
Expand All @@ -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)
}
}
Expand All @@ -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)
Comment on lines +128 to +132
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is fairly involved, lightly consider an extension/method


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 -> {
Expand Down Expand Up @@ -148,7 +168,10 @@ class WhiteboardView : View {
currentPath.reset()
invalidate()
}
else -> return false
MotionEvent.ACTION_CANCEL -> {
currentPath.reset()
invalidate()
}
}
return true
}
Expand All @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.ui.windows.reviewer.whiteboard

import android.content.SharedPreferences
import android.graphics.Color
import android.graphics.Path
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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<WhiteboardTool.Eraser> =
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<WhiteboardTool> =
combine(
isEraserActive,
activeBrushIndex,
brushes,
isStylusButtonPressed,
eraserTool,
Comment on lines +137 to +141
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is complex - see if you can convert this into:

  • brush config
  • eraser config
  • selected tool (isEraserActive)

) { 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<DrawingAction>()
private var pathsBeforeGesture: List<DrawingAction> = emptyList()
Expand All @@ -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()
Expand All @@ -160,14 +234,16 @@ class WhiteboardViewModel(
y: Float,
) {
if (paths.value.isEmpty()) return
val currentTool = effectiveTool.value
if (currentTool !is WhiteboardTool.Eraser || currentTool.mode != EraserMode.STROKE) return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment guiding someone to the stroke implementation would be useful


val remainingPaths = paths.value.toMutableList()
var pathWasErased = false

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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}

/**
Expand All @@ -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
}
}
}

/**
Expand All @@ -360,7 +417,6 @@ class WhiteboardViewModel(
brushes.value = updatedBrushes
repository.saveBrushes(updatedBrushes, isDarkMode)
}
activeStrokeWidth.value = newWidth
}

@CheckResult
Expand All @@ -379,7 +435,6 @@ class WhiteboardViewModel(

brushes.value = updatedBrushes
repository.saveBrushes(brushes.value, isDarkMode)
brushColor.value = newColor
}

/**
Expand Down