From 419761e34d3dd0258760e224c3e8fa9b8c17365c Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 12 Apr 2026 15:03:35 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20TextSelectionOverlay=20=E2=80=94=20highlight,=20?= =?UTF-8?q?handles,=20drag=20interaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chats/components/TextSelectionHelper.kt | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt index e798cf2..30f98a0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/TextSelectionHelper.kt @@ -7,11 +7,25 @@ import android.text.Layout import android.view.HapticFeedbackConstants import android.view.View import android.widget.Toast +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.rosetta.messenger.ui.onboarding.PrimaryBlue data class LayoutInfo( val layout: Layout, @@ -34,6 +48,17 @@ class TextSelectionHelper { private set var handleViewProgress by mutableFloatStateOf(0f) private set + var movingHandle by mutableStateOf(false) + private set + var movingHandleStart by mutableStateOf(false) + private set + private var movingOffsetX = 0f + private var movingOffsetY = 0f + + var startHandleX by mutableFloatStateOf(0f) + var startHandleY by mutableFloatStateOf(0f) + var endHandleX by mutableFloatStateOf(0f) + var endHandleY by mutableFloatStateOf(0f) val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart @@ -89,6 +114,26 @@ class TextSelectionHelper { selectionEnd = newEnd } + fun beginHandleDrag(isStart: Boolean, touchX: Float, touchY: Float) { + movingHandle = true + movingHandleStart = isStart + movingOffsetX = if (isStart) startHandleX - touchX else endHandleX - touchX + movingOffsetY = if (isStart) startHandleY - touchY else endHandleY - touchY + } + + fun moveHandle(touchX: Float, touchY: Float) { + if (!movingHandle) return + val x = (touchX + movingOffsetX).toInt() + val y = (touchY + movingOffsetY).toInt() + val offset = getCharOffsetFromCoords(x, y) + if (offset < 0) return + if (movingHandleStart) updateSelectionStart(offset) else updateSelectionEnd(offset) + } + + fun endHandleDrag() { + movingHandle = false + } + fun getCharOffsetFromCoords(x: Int, y: Int): Int { val info = layoutInfo ?: return -1 val localX = x - info.windowX @@ -128,5 +173,154 @@ class TextSelectionHelper { layoutInfo = null isActive = false handleViewProgress = 0f + movingHandle = false } } + +private val HandleSize = 22.dp +private val HandleInset = 8.dp +private val HighlightCorner = 6.dp +private val HighlightColor = PrimaryBlue.copy(alpha = 0.3f) +private val HandleColor = PrimaryBlue + +@Composable +fun TextSelectionOverlay( + helper: TextSelectionHelper, + modifier: Modifier = Modifier +) { + if (!helper.isInSelectionMode) return + + val density = LocalDensity.current + val handleSizePx = with(density) { HandleSize.toPx() } + val handleInsetPx = with(density) { HandleInset.toPx() } + val highlightCornerPx = with(density) { HighlightCorner.toPx() } + + Box(modifier = modifier.fillMaxSize()) { + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(helper.isActive) { + if (!helper.isActive) return@pointerInput + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull() ?: continue + + when { + change.pressed && !helper.movingHandle -> { + val x = change.position.x + val y = change.position.y + val startRect = Rect( + helper.startHandleX - handleSizePx / 2 - handleInsetPx, + helper.startHandleY - handleInsetPx, + helper.startHandleX + handleSizePx / 2 + handleInsetPx, + helper.startHandleY + handleSizePx + handleInsetPx + ) + val endRect = Rect( + helper.endHandleX - handleSizePx / 2 - handleInsetPx, + helper.endHandleY - handleInsetPx, + helper.endHandleX + handleSizePx / 2 + handleInsetPx, + helper.endHandleY + handleSizePx + handleInsetPx + ) + when { + startRect.contains(Offset(x, y)) -> { + helper.beginHandleDrag(isStart = true, x, y) + change.consume() + } + endRect.contains(Offset(x, y)) -> { + helper.beginHandleDrag(isStart = false, x, y) + change.consume() + } + else -> { + helper.clear() + } + } + } + change.pressed && helper.movingHandle -> { + helper.moveHandle(change.position.x, change.position.y) + change.consume() + } + !change.pressed && helper.movingHandle -> { + helper.endHandleDrag() + change.consume() + } + } + } + } + } + ) { + val info = helper.layoutInfo ?: return@Canvas + val layout = info.layout + val text = info.text + + val startOffset = helper.selectionStart.coerceIn(0, text.length) + val endOffset = helper.selectionEnd.coerceIn(0, text.length) + if (startOffset >= endOffset) return@Canvas + + val startLine = layout.getLineForOffset(startOffset) + val endLine = layout.getLineForOffset(endOffset) + + for (line in startLine..endLine) { + val lineTop = layout.getLineTop(line).toFloat() + info.windowY + val lineBottom = layout.getLineBottom(line).toFloat() + info.windowY + val left = if (line == startLine) { + layout.getPrimaryHorizontal(startOffset) + info.windowX + } else { + layout.getLineLeft(line) + info.windowX + } + val right = if (line == endLine) { + layout.getPrimaryHorizontal(endOffset) + info.windowX + } else { + layout.getLineRight(line) + info.windowX + } + drawRoundRect( + color = HighlightColor, + topLeft = Offset(left, lineTop), + size = Size(right - left, lineBottom - lineTop), + cornerRadius = CornerRadius(highlightCornerPx) + ) + } + + val startHx = layout.getPrimaryHorizontal(startOffset) + info.windowX + val startHy = layout.getLineBottom(startLine).toFloat() + info.windowY + val endHx = layout.getPrimaryHorizontal(endOffset) + info.windowX + val endHy = layout.getLineBottom(endLine).toFloat() + info.windowY + + helper.startHandleX = startHx + helper.startHandleY = startHy + helper.endHandleX = endHx + helper.endHandleY = endHy + + drawStartHandle(startHx, startHy, handleSizePx) + drawEndHandle(endHx, endHy, handleSizePx) + } + } +} + +private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float) { + val half = size / 2f + drawCircle( + color = HandleColor, + radius = half, + center = Offset(x, y + half) + ) + drawRect( + color = HandleColor, + topLeft = Offset(x, y), + size = Size(half, half) + ) +} + +private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float) { + val half = size / 2f + drawCircle( + color = HandleColor, + radius = half, + center = Offset(x, y + half) + ) + drawRect( + color = HandleColor, + topLeft = Offset(x - half, y), + size = Size(half, half) + ) +}