Add support for brush width in media-send.

This commit is contained in:
Alex Hart
2026-07-29 15:36:38 -03:00
parent a92304c222
commit 2e6f54ad0a
14 changed files with 466 additions and 36 deletions
@@ -36,6 +36,7 @@ import org.signal.mediasend.SendRequest
import org.signal.mediasend.SendResult
import org.signal.mediasend.SentMediaQuality
import org.signal.mediasend.StorySendRequirements
import org.signal.mediasend.edit.image.BrushWidths
import org.signal.mediasend.preupload.PreUploadResult
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
@@ -63,6 +64,7 @@ import java.io.InputStream
import java.util.Optional
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -264,6 +266,21 @@ object MediaSendV3Repository : MediaSendRepository {
override var storyMaxVideoDuration: Duration = Stories.MAX_VIDEO_DURATION_MILLIS.milliseconds
/**
* Stored as whole percentages so that the v2 and v3 editors stay in sync.
*/
override var brushWidths: BrushWidths
get() = BrushWidths(
marker = SignalStore.imageEditor.getMarkerPercentage() / 100f,
highlighter = SignalStore.imageEditor.getHighlighterPercentage() / 100f,
blur = SignalStore.imageEditor.getBlurPercentage() / 100f
)
set(value) {
SignalStore.imageEditor.setMarkerPercentage((value.marker * 100).roundToInt())
SignalStore.imageEditor.setHighlighterPercentage((value.highlighter * 100).roundToInt())
SignalStore.imageEditor.setBlurPercentage((value.blur * 100).roundToInt())
}
private fun PreUploadResult.toLegacyPreUploadResult(): MessageSender.PreUploadResult {
return MessageSender.PreUploadResult(media, AttachmentId(attachmentId), jobIds)
}
@@ -11,6 +11,7 @@ import android.os.Parcelable
import kotlinx.coroutines.flow.Flow
import org.signal.core.models.media.Media
import org.signal.core.models.media.MediaFolder
import org.signal.mediasend.edit.image.BrushWidths
import org.signal.mediasend.preupload.PreUploadResult
import java.io.InputStream
import kotlin.time.Duration
@@ -113,6 +114,11 @@ interface MediaSendRepository {
fun getMediaConstraints(): MediaConstraints
var storyMaxVideoDuration: Duration
/**
* The image editor's per-tool brush widths, shared with the v2 editor.
*/
var brushWidths: BrushWidths
}
/**
@@ -15,6 +15,7 @@ import org.signal.camera.CameraDependencies
import org.signal.core.models.media.Media
import org.signal.core.models.media.MediaFolder
import org.signal.core.models.parcelers.NullableCharSequenceParceler
import org.signal.mediasend.edit.image.BrushWidths
import org.signal.mediasend.edit.video.VideoTrimData
import kotlin.time.Duration
@@ -132,7 +133,12 @@ data class MediaSendState(
val storiesEnabled: Boolean = CameraDependencies.isStoriesFeatureEnabled(),
val storyMaxVideoDuration: Duration = MediaSendDependencies.mediaSendRepository.storyMaxVideoDuration
val storyMaxVideoDuration: Duration = MediaSendDependencies.mediaSendRepository.storyMaxVideoDuration,
/**
* The image editor's per-tool brush widths. Seeded from storage and written back as the user adjusts them.
*/
val brushWidths: BrushWidths = MediaSendDependencies.mediaSendRepository.brushWidths
) : Parcelable {
fun getOrCreateVideoTrimData(uri: Uri): VideoTrimData {
@@ -53,6 +53,7 @@ import org.signal.imageeditor.core.renderers.UriGlideRenderer
import org.signal.mediasend.capture.CameraXScreenEvent
import org.signal.mediasend.capture.MediaCaptureScreenEvent
import org.signal.mediasend.edit.MediaEditScreenEvent
import org.signal.mediasend.edit.image.BrushTool
import org.signal.mediasend.edit.video.VideoTrimData
import org.signal.mediasend.preupload.PreUploadController
import org.signal.mediasend.preupload.PreUploadResult
@@ -302,9 +303,20 @@ class MediaSendViewModel(
is MediaEditScreenEvent.RemoveMedia -> {
removeMedia(mediaEditScreenEvent.media)
}
is MediaEditScreenEvent.BrushWidthChanged -> {
setBrushWidth(mediaEditScreenEvent.tool, mediaEditScreenEvent.fraction)
}
}
}
private fun setBrushWidth(tool: BrushTool, fraction: Float) {
val brushWidths = state.value.brushWidths.with(tool, fraction)
updateState { copy(brushWidths = brushWidths) }
repository.brushWidths = brushWidths
}
private fun handleImageCaptured(imageCaptured: CameraXScreenEvent.ImageCaptured) {
viewModelScope.launch {
val media: Media? = withContext(Dispatchers.IO) {
@@ -18,6 +18,8 @@ import org.signal.imageeditor.core.SelectableRenderer
import org.signal.imageeditor.core.model.EditorElement
import org.signal.imageeditor.core.model.EditorModel
import org.signal.imageeditor.core.renderers.MultiLineTextRenderer
import org.signal.mediasend.edit.image.BrushTool
import org.signal.mediasend.edit.image.BrushWidthsState
import org.signal.mediasend.edit.image.HSVColorBarState
import org.signal.mediasend.edit.image.ImageEditorState
@@ -30,7 +32,8 @@ import org.signal.mediasend.edit.image.ImageEditorState
*/
@Stable
class ImageController @RememberInComposition constructor(
val editorModel: EditorModel
val editorModel: EditorModel,
private val brushWidths: BrushWidthsState = BrushWidthsState()
) {
val isUserInEdit: Boolean by derivedStateOf { mode != Mode.NONE }
@@ -60,6 +63,7 @@ class ImageController @RememberInComposition constructor(
private set
val textColorBarState = HSVColorBarState()
val drawColorBarState = HSVColorBarState()
var showDiscardDialog: Boolean by mutableStateOf(false)
private set
@@ -75,12 +79,26 @@ class ImageController @RememberInComposition constructor(
}
}
val shouldDisplayColorBar: Boolean by derivedStateOf {
val shouldDisplayTextColorBar: Boolean by derivedStateOf {
textEditingElement != null || mode == Mode.MOVE_TEXT
}
val isUserDrawing: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT }
val isUserBlurring: Boolean by derivedStateOf { mode == Mode.BLUR }
val brushTool: BrushTool? by derivedStateOf {
when (mode) {
Mode.DRAW -> BrushTool.MARKER
Mode.HIGHLIGHT -> BrushTool.HIGHLIGHTER
Mode.BLUR -> BrushTool.BLUR
else -> null
}
}
val brushWidthFraction: Float by derivedStateOf { brushTool?.let { brushWidths[it] } ?: 0f }
val brushThickness: Float by derivedStateOf { brushTool?.thicknessAt(brushWidthFraction) ?: 0f }
val isUserEnteringText: Boolean by derivedStateOf { mode == Mode.TEXT }
val isUserInsertingSticker: Boolean by derivedStateOf { mode == Mode.INSERT_STICKER }
@@ -176,14 +194,18 @@ class ImageController @RememberInComposition constructor(
imageEditorState.drawColor = color
}
fun setDrawThickness(thickness: Float) {
imageEditorState.drawThickness = thickness
fun setBrushWidthFraction(fraction: Float) {
val tool = brushTool ?: return
brushWidths.set(tool, fraction)
imageEditorState.drawThickness = tool.thicknessAt(fraction)
}
private fun syncDrawingState() {
imageEditorState.isDrawing = true
imageEditorState.isBlur = mode == Mode.BLUR
imageEditorState.drawCap = if (mode == Mode.HIGHLIGHT) Paint.Cap.SQUARE else Paint.Cap.ROUND
imageEditorState.drawThickness = brushThickness
imageEditorState.drawColor = drawColorBarState.color
}
fun enterCropMode() {
@@ -332,11 +354,13 @@ class ImageController @RememberInComposition constructor(
}
@Stable
class Container @RememberInComposition constructor() {
class Container @RememberInComposition constructor(
private val brushWidths: BrushWidthsState = BrushWidthsState()
) {
private val controllers = SnapshotStateMap<Uri, ImageController>()
fun getOrCreate(uri: Uri, editorModel: EditorModel): ImageController {
return controllers.getOrPut(uri) { ImageController(editorModel) }
return controllers.getOrPut(uri) { ImageController(editorModel, brushWidths) }
}
}
}
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -31,6 +32,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.dp
@@ -51,6 +53,11 @@ import org.signal.mediasend.EditorState
import org.signal.mediasend.MediaSendDependencies
import org.signal.mediasend.MediaSendState
import org.signal.mediasend.edit.document.DocumentPage
import org.signal.mediasend.edit.image.BrushWidthBar
import org.signal.mediasend.edit.image.BrushWidthPreview
import org.signal.mediasend.edit.image.BrushWidthsState
import org.signal.mediasend.edit.image.ColorBarOrientation
import org.signal.mediasend.edit.image.HSVColorBar
import org.signal.mediasend.edit.image.ImageEditor
import org.signal.mediasend.edit.image.ImageEditorToolbar
import org.signal.mediasend.edit.image.RotationDial
@@ -89,13 +96,9 @@ fun MediaEditScreen(
onEvent(MediaEditScreenEvent.NavigateBack)
}
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
Box(modifier = Modifier.fillMaxSize()) {
val isSmallWindowBreakpoint = rememberWindowBreakpoint() is WindowBreakpoint.Small
val imageControllers = remember { ImageController.Container() }
val imageControllers = remember { ImageController.Container(BrushWidthsState(state.brushWidths)) }
val videoEditorViewModel = rememberVideoEditorViewModel()
@@ -108,6 +111,7 @@ fun MediaEditScreen(
}
var isVideoInteracting by remember(focusedUri) { mutableStateOf(false) }
var isAdjustingBrushWidth by remember(focusedUri) { mutableStateOf(false) }
val isInteracting = imageController?.isUserInEdit == true || isVideoInteracting
HorizontalPager(
@@ -180,6 +184,36 @@ fun MediaEditScreen(
}
}
if (imageController != null && (imageController.isUserDrawing || imageController.isUserBlurring)) {
// A multi-touch commit/discard can tear down the bar mid-drag, so the terminal gesture callback is not guaranteed.
DisposableEffect(Unit) {
onDispose { isAdjustingBrushWidth = false }
}
BrushWidthPreview(
visible = isAdjustingBrushWidth,
thickness = imageController.brushThickness,
viewMatrix = imageController.imageEditorState.viewMatrix,
color = Color(imageController.imageEditorState.drawColor),
isBlur = imageController.isUserBlurring,
modifier = Modifier.fillMaxSize()
)
BrushWidthBar(
fraction = imageController.brushWidthFraction,
onFractionChanged = { fraction, gestureComplete ->
isAdjustingBrushWidth = !gestureComplete
val tool = imageController.brushTool
imageController.setBrushWidthFraction(fraction)
if (gestureComplete && tool != null) {
onEvent(MediaEditScreenEvent.BrushWidthChanged(tool, fraction))
}
},
modifier = Modifier.align(Alignment.CenterStart)
)
}
val isTextEditing = imageController?.textEditingElement != null
Column(
@@ -187,6 +221,8 @@ fun MediaEditScreen(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 10.dp)
.navigationBarsPadding()
.then(if (isTextEditing) Modifier.imePadding() else Modifier)
) {
if (state.selectedMedia.size > 1 && !isInteracting) {
@@ -222,6 +258,15 @@ fun MediaEditScreen(
.padding(horizontal = 16.dp)
)
}
if (controller.isUserDrawing) {
HSVColorBar(
state = controller.drawColorBarState,
onColorChanged = controller::setDrawColor,
orientation = ColorBarOrientation.HORIZONTAL
)
}
if (isSmallWindowBreakpoint) {
ImageEditorToolbar(imageEditorController = controller, state = state, onEvent = onEvent)
}
@@ -271,17 +316,20 @@ fun MediaEditScreen(
is EditorState.Document, EditorState.VideoGif, EditorState.Gif, null -> Unit
}
AddAMessageRow(
enabled = !isInteracting && !state.isSending,
message = state.message,
onEvent = onEvent,
onNextClick = { onEvent(MediaEditScreenEvent.NextClick) },
modifier = Modifier
.widthIn(max = 624.dp)
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
.alpha(if (isInteracting) 0f else 1f)
)
val showAddMessageRow = !(isInteracting && focusedEditorState !is EditorState.VideoTrim)
if (showAddMessageRow) {
AddAMessageRow(
enabled = !isInteracting && !state.isSending,
message = state.message,
onEvent = onEvent,
onNextClick = { onEvent(MediaEditScreenEvent.NextClick) },
modifier = Modifier
.widthIn(max = 624.dp)
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
.alpha(if (isInteracting) 0f else 1f)
)
}
}
if (!isSmallWindowBreakpoint && imageController != null) {
@@ -291,6 +339,7 @@ fun MediaEditScreen(
onEvent = onEvent,
modifier = Modifier
.align(Alignment.CenterEnd)
.navigationBarsPadding()
.padding(end = 24.dp)
.then(if (isTextEditing) Modifier.imePadding() else Modifier)
)
@@ -6,6 +6,7 @@
package org.signal.mediasend.edit
import org.signal.core.models.media.Media
import org.signal.mediasend.edit.image.BrushTool
import org.signal.mediasend.edit.video.VideoTrimData
sealed interface MediaEditScreenEvent {
@@ -17,6 +18,7 @@ sealed interface MediaEditScreenEvent {
data object NavigateBack : MediaEditScreenEvent
data object NavigateToGallery : MediaEditScreenEvent
data object ToggleMediaQuality : MediaEditScreenEvent
data class BrushWidthChanged(val tool: BrushTool, val fraction: Float) : MediaEditScreenEvent
data class VideoTrimChanged(val videoTrimData: VideoTrimData, val editingComplete: Boolean) : MediaEditScreenEvent
data class VideoSeek(val positionUs: Long, val editingComplete: Boolean) : MediaEditScreenEvent
}
@@ -0,0 +1,130 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit.image
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput
import org.signal.core.ui.compose.PhonePortraitDayPreview
import org.signal.core.ui.compose.PhonePortraitNightPreview
import org.signal.core.ui.compose.Previews
/**
* Vertical brush thickness control, anchored to the start edge of the screen.
*
* Sits half off-screen at rest and slides inward for the duration of a drag. The top of the track is the
* thickest setting.
*
* The touchable area extends past the track towards the center of the screen so that the on-screen portion stays a
* full [BrushWidthMetrics.BarThickness] wide even while the track is half clipped.
*/
@Composable
fun BrushWidthBar(
fraction: Float,
onFractionChanged: (fraction: Float, gestureComplete: Boolean) -> Unit,
modifier: Modifier = Modifier
) {
var isDragging by remember { mutableStateOf(false) }
val trackPath = remember { Path() }
val offsetX by animateDpAsState(
targetValue = with(BrushWidthMetrics) { if (isDragging) RestingOffset + SlideInDistance else RestingOffset },
animationSpec = tween(durationMillis = BrushWidthMetrics.SlideDurationMillis, easing = BrushWidthMetrics.SlideEasing)
)
Canvas(
modifier = modifier
.offset(x = offsetX)
.size(width = BrushWidthMetrics.TouchThickness, height = BrushWidthMetrics.BarLength)
.pointerInput(Unit) {
awaitEachGesture {
val thumbRadius = BrushWidthMetrics.ThumbDiameter.toPx() / 2f
val down = awaitFirstDown()
down.consume()
isDragging = true
var latest = fractionAt(down.position.y, size.height.toFloat(), thumbRadius)
onFractionChanged(latest, false)
drag(down.id) { change ->
change.consume()
latest = fractionAt(change.position.y, size.height.toFloat(), thumbRadius)
onFractionChanged(latest, false)
}
isDragging = false
onFractionChanged(latest, true)
}
}
) {
drawTrack(trackPath)
drawThumb(fraction)
}
}
private fun fractionAt(y: Float, height: Float, thumbRadius: Float): Float {
val travel = height - thumbRadius * 2f
if (travel <= 0f) {
return 0f
}
return (1f - (y - thumbRadius) / travel).coerceIn(0f, 1f)
}
private fun DrawScope.drawTrack(path: Path) = with(BrushWidthMetrics) {
val centerX = BarThickness.toPx() / 2f
val topHalfThickness = TrackTopThickness.toPx() / 2f
val bottomHalfThickness = TrackBottomThickness.toPx() / 2f
path.rewind()
path.moveTo(centerX - topHalfThickness, 0f)
path.lineTo(centerX + topHalfThickness, 0f)
path.lineTo(centerX + bottomHalfThickness, size.height)
path.lineTo(centerX - bottomHalfThickness, size.height)
path.close()
drawPath(path = path, color = Color.White, alpha = TrackAlpha)
}
private fun DrawScope.drawThumb(fraction: Float) = with(BrushWidthMetrics) {
val radius = ThumbDiameter.toPx() / 2f
val travel = size.height - radius * 2f
drawCircle(
color = Color.White,
radius = radius,
center = Offset(BarThickness.toPx() / 2f, radius + (1f - fraction) * travel)
)
}
@PhonePortraitDayPreview
@PhonePortraitNightPreview
@Composable
private fun BrushWidthBarPreview() {
Previews.Preview {
BrushWidthBar(
fraction = 0.5f,
onFractionChanged = { _, _ -> },
modifier = Modifier.offset(x = BrushWidthMetrics.BarThickness)
)
}
}
@@ -0,0 +1,37 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit.image
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.ui.unit.dp
/**
* Dimensions and animation values shared by [BrushWidthBar] and [BrushWidthPreview], carried over from the v2 editor.
*/
internal object BrushWidthMetrics {
val BarLength = 174.dp
val BarThickness = 48.dp
/** How much of [BarThickness] hangs off the start edge of the screen at rest. */
val RestingClip = BarThickness / 2
/** Cross-axis size of the layout box, extended inwards to keep [BarThickness] of it touchable at rest. */
val TouchThickness = BarThickness + RestingClip
val RestingOffset = -RestingClip
val SlideInDistance = 36.dp
val SlideEasing = CubicBezierEasing(0.17f, 0.17f, 0f, 1f)
const val SlideDurationMillis = 250
val TrackTopThickness = 16.dp
val TrackBottomThickness = 0.3.dp
const val TrackAlpha = 0.6f
val ThumbDiameter = 32.dp
val PreviewBackdropWidth = 1.dp
const val PreviewFadeDurationMillis = 150
}
@@ -0,0 +1,62 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit.image
import android.graphics.Matrix
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import org.signal.imageeditor.core.Bounds
/**
* Shows the size of the brush the user is about to draw with, centered on the editor.
*
* Blur has no color of its own, so it gets the backdrop ring only.
*/
@Composable
fun BrushWidthPreview(
visible: Boolean,
thickness: Float,
viewMatrix: Matrix,
color: Color,
isBlur: Boolean,
modifier: Modifier = Modifier
) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = tween(durationMillis = BrushWidthMetrics.PreviewFadeDurationMillis)
)
if (alpha <= 0f) {
return
}
Canvas(modifier = modifier) {
val radius = viewMatrix.mapRadius(thickness * Bounds.FULL_BOUNDS.width() / 2f)
val center = Offset(size.width / 2f, size.height / 2f)
drawCircle(
color = Color.White,
radius = radius + BrushWidthMetrics.PreviewBackdropWidth.toPx(),
center = center,
alpha = alpha
)
if (!isBlur) {
drawCircle(
color = color,
radius = radius,
center = center,
alpha = alpha
)
}
}
}
@@ -0,0 +1,72 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit.image
import android.os.Parcelable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.annotation.RememberInComposition
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.parcelize.Parcelize
/**
* The image editor tools which support a user-adjustable stroke width.
*
* Thicknesses are expressed as a fraction of the editor's coordinate space width, matching what
* [ImageEditorState.drawThickness] expects.
*/
enum class BrushTool(val minThickness: Float, val maxThickness: Float) {
MARKER(0.01f, 0.05f),
HIGHLIGHTER(0.03f, 0.08f),
BLUR(0.052f, 0.092f);
fun thicknessAt(fraction: Float): Float = minThickness + (maxThickness - minThickness) * fraction
}
/**
* The stroke width each [BrushTool] is set to, as a fraction of that tool's own thickness range.
*/
@Parcelize
data class BrushWidths(
val marker: Float = 0f,
val highlighter: Float = 0f,
val blur: Float = 0f
) : Parcelable {
operator fun get(tool: BrushTool): Float {
return when (tool) {
BrushTool.MARKER -> marker
BrushTool.HIGHLIGHTER -> highlighter
BrushTool.BLUR -> blur
}
}
fun with(tool: BrushTool, fraction: Float): BrushWidths {
return when (tool) {
BrushTool.MARKER -> copy(marker = fraction)
BrushTool.HIGHLIGHTER -> copy(highlighter = fraction)
BrushTool.BLUR -> copy(blur = fraction)
}
}
}
/**
* Mutable holder for [BrushWidths], scoped to the edit screen rather than to a single image so that every image in the
* selection draws with the same brush.
*/
@Stable
class BrushWidthsState @RememberInComposition constructor(initialWidths: BrushWidths = BrushWidths()) {
var widths: BrushWidths by mutableStateOf(initialWidths)
private set
operator fun get(tool: BrushTool): Float = widths[tool]
fun set(tool: BrushTool, fraction: Float) {
widths = widths.with(tool, fraction)
}
}
@@ -41,13 +41,9 @@ import org.signal.core.ui.rememberWindowBreakpoint
fun HSVColorBar(
state: HSVColorBarState,
onColorChanged: (Int) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
orientation: ColorBarOrientation = rememberDefaultColorBarOrientation()
) {
val orientation = if (rememberWindowBreakpoint() is WindowBreakpoint.Small) {
ColorBarOrientation.HORIZONTAL
} else {
ColorBarOrientation.VERTICAL
}
val colors = remember { HSVColors.composeColors }
val thumbColor = SignalTheme.colors.colorSurface5
@@ -85,7 +81,11 @@ fun HSVColorBar(
}
}
private enum class ColorBarOrientation(val barModifier: Modifier) {
/**
* Whichever axis the bar is laid out along. Defaults to [rememberDefaultColorBarOrientation], but call sites that
* always host the bar in a fixed slot should pass their own.
*/
enum class ColorBarOrientation(internal val barModifier: Modifier) {
HORIZONTAL(
Modifier
.widthIn(max = MAX_BAR_LENGTH_DP.dp)
@@ -99,7 +99,7 @@ private enum class ColorBarOrientation(val barModifier: Modifier) {
.width(THUMB_DIAMETER_DP.dp)
);
fun fractionAt(offset: Offset, width: Float, height: Float): Float {
internal fun fractionAt(offset: Offset, width: Float, height: Float): Float {
val distance = when (this) {
HORIZONTAL -> offset.x
VERTICAL -> offset.y
@@ -113,7 +113,7 @@ private enum class ColorBarOrientation(val barModifier: Modifier) {
return (distance / maxDistance).coerceIn(0f, 1f)
}
fun thumbCenter(fraction: Float, size: Size): Offset {
internal fun thumbCenter(fraction: Float, size: Size): Offset {
return when (this) {
HORIZONTAL -> Offset(fraction * size.width, size.height / 2f)
VERTICAL -> Offset(size.width / 2f, fraction * size.height)
@@ -185,6 +185,18 @@ fun rememberHSVColorBarState(): HSVColorBarState {
return remember { HSVColorBarState() }
}
/**
* Lays the bar out along whichever axis has room in the current window.
*/
@Composable
fun rememberDefaultColorBarOrientation(): ColorBarOrientation {
return if (rememberWindowBreakpoint() is WindowBreakpoint.Small) {
ColorBarOrientation.HORIZONTAL
} else {
ColorBarOrientation.VERTICAL
}
}
private object HSVColors {
private const val MAX_HUE = 360
private const val BLACK_DIVISIONS = 175
@@ -245,7 +257,7 @@ private object HSVColors {
}
private const val DEFAULT_FRACTION = 0.14f
private const val MAX_BAR_LENGTH_DP = 320
private const val MAX_BAR_LENGTH_DP = 324
private const val TRACK_THICKNESS_DP = 20
private const val THUMB_DIAMETER_DP = 24
private const val THUMB_BORDER_DP = 6
@@ -14,6 +14,7 @@ import android.graphics.RectF
import android.graphics.Typeface
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -47,7 +48,7 @@ class ImageEditorState(
var textEditingElement: EditorElement? = null
var isDrawing: Boolean = false
var isBlur: Boolean = false
var drawColor: Int = 0xff000000.toInt()
var drawColor: Int by mutableIntStateOf(0xff000000.toInt())
var drawThickness: Float = 0.02f
var drawCap: Paint.Cap = Paint.Cap.ROUND
var onGestureCompleted: (() -> Unit)? = null
@@ -52,7 +52,7 @@ fun ImageEditorToolbar(
modifier: Modifier = Modifier
) {
when {
imageEditorController.shouldDisplayColorBar -> {
imageEditorController.shouldDisplayTextColorBar -> {
HSVColorBar(
state = imageEditorController.textColorBarState,
onColorChanged = imageEditorController::setTextColor,