mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-06 13:31:18 +01:00
Add support for brush width in media-send.
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ fun ImageEditorToolbar(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
when {
|
||||
imageEditorController.shouldDisplayColorBar -> {
|
||||
imageEditorController.shouldDisplayTextColorBar -> {
|
||||
HSVColorBar(
|
||||
state = imageEditorController.textColorBarState,
|
||||
onColorChanged = imageEditorController::setTextColor,
|
||||
|
||||
Reference in New Issue
Block a user