Compare commits
10 Commits
b57e48fe20
...
ad08af7f0c
| Author | SHA1 | Date | |
|---|---|---|---|
| ad08af7f0c | |||
| 9fe5f35923 | |||
| 78925dd61d | |||
| 1ac3d93f74 | |||
| 6ad24974e0 | |||
| e825a1ef30 | |||
| 7fcf1195e1 | |||
| a10482b794 | |||
| 419761e34d | |||
| 988896c080 |
@@ -394,6 +394,10 @@ fun ChatDetailScreen(
|
|||||||
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
|
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
|
||||||
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
|
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
// 🔤 TEXT SELECTION - Telegram-style character-level selection
|
||||||
|
val textSelectionHelper = remember { com.rosetta.messenger.ui.chats.components.TextSelectionHelper() }
|
||||||
|
LaunchedEffect(Unit) { textSelectionHelper.setMagnifierView(view) }
|
||||||
|
|
||||||
// 💬 MESSAGE CONTEXT MENU STATE
|
// 💬 MESSAGE CONTEXT MENU STATE
|
||||||
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
||||||
var showContextMenu by remember { mutableStateOf(false) }
|
var showContextMenu by remember { mutableStateOf(false) }
|
||||||
@@ -838,6 +842,7 @@ fun ChatDetailScreen(
|
|||||||
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
|
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
|
||||||
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
|
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
|
||||||
{ messageId, canSelect ->
|
{ messageId, canSelect ->
|
||||||
|
textSelectionHelper.clear()
|
||||||
if (canSelect && !selectedMessages.contains(messageId)) {
|
if (canSelect && !selectedMessages.contains(messageId)) {
|
||||||
selectedMessages = selectedMessages + messageId
|
selectedMessages = selectedMessages + messageId
|
||||||
}
|
}
|
||||||
@@ -886,6 +891,13 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔤 Сброс текстового выделения при скролле
|
||||||
|
LaunchedEffect(listState.isScrollInProgress) {
|
||||||
|
if (listState.isScrollInProgress && textSelectionHelper.isActive) {
|
||||||
|
textSelectionHelper.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 Display reply messages - получаем полную информацию о сообщениях для reply
|
// 🔥 Display reply messages - получаем полную информацию о сообщениях для reply
|
||||||
val displayReplyMessages =
|
val displayReplyMessages =
|
||||||
remember(replyMessages, messages) {
|
remember(replyMessages, messages) {
|
||||||
@@ -3025,6 +3037,7 @@ fun ChatDetailScreen(
|
|||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
userScrollEnabled = !textSelectionHelper.movingHandle,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
.nestedScroll(
|
.nestedScroll(
|
||||||
@@ -3164,6 +3177,8 @@ fun ChatDetailScreen(
|
|||||||
MessageBubble(
|
MessageBubble(
|
||||||
message =
|
message =
|
||||||
message,
|
message,
|
||||||
|
textSelectionHelper =
|
||||||
|
textSelectionHelper,
|
||||||
isDarkTheme =
|
isDarkTheme =
|
||||||
isDarkTheme,
|
isDarkTheme,
|
||||||
hasWallpaper =
|
hasWallpaper =
|
||||||
@@ -3644,6 +3659,11 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 🔤 Text selection overlay
|
||||||
|
com.rosetta.messenger.ui.chats.components.TextSelectionOverlay(
|
||||||
|
helper = textSelectionHelper,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ fun TypingIndicator(
|
|||||||
@Composable
|
@Composable
|
||||||
fun MessageBubble(
|
fun MessageBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
|
textSelectionHelper: com.rosetta.messenger.ui.chats.components.TextSelectionHelper? = null,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
hasWallpaper: Boolean = false,
|
hasWallpaper: Boolean = false,
|
||||||
isSystemSafeChat: Boolean = false,
|
isSystemSafeChat: Boolean = false,
|
||||||
@@ -400,6 +401,10 @@ fun MessageBubble(
|
|||||||
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
|
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
|
||||||
else Color(0xFF2196F3) // Стандартный Material Blue для входящих
|
else Color(0xFF2196F3) // Стандартный Material Blue для входящих
|
||||||
}
|
}
|
||||||
|
var textViewRef by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiTextView?>(null) }
|
||||||
|
val selectionDragEndHandler: (() -> Unit)? = if (textSelectionHelper != null) {
|
||||||
|
{ textSelectionHelper.hideMagnifier(); textSelectionHelper.endHandleDrag() }
|
||||||
|
} else null
|
||||||
val linksEnabled = !isSelectionMode
|
val linksEnabled = !isSelectionMode
|
||||||
val textClickHandler: (() -> Unit)? = onClick
|
val textClickHandler: (() -> Unit)? = onClick
|
||||||
val mentionClickHandler: ((String) -> Unit)? =
|
val mentionClickHandler: ((String) -> Unit)? =
|
||||||
@@ -1066,7 +1071,31 @@ fun MessageBubble(
|
|||||||
onClick =
|
onClick =
|
||||||
textClickHandler,
|
textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
onLongClick // 🔥 Long press для selection
|
onLongClick, // 🔥 Long press для selection
|
||||||
|
onViewCreated = { textViewRef = it },
|
||||||
|
onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
|
||||||
|
val info = textViewRef?.getLayoutInfo()
|
||||||
|
if (info != null) {
|
||||||
|
textSelectionHelper.startSelection(
|
||||||
|
messageId = message.id,
|
||||||
|
info = info,
|
||||||
|
touchX = touchX,
|
||||||
|
touchY = touchY,
|
||||||
|
view = textViewRef
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
onSelectionDrag = if (textSelectionHelper != null) { tx, ty ->
|
||||||
|
textSelectionHelper.moveHandle(
|
||||||
|
(tx - textSelectionHelper.overlayWindowX),
|
||||||
|
(ty - textSelectionHelper.overlayWindowY)
|
||||||
|
)
|
||||||
|
textSelectionHelper.showMagnifier(
|
||||||
|
(tx - textSelectionHelper.overlayWindowX),
|
||||||
|
(ty - textSelectionHelper.overlayWindowY)
|
||||||
|
)
|
||||||
|
} else null,
|
||||||
|
onSelectionDragEnd = selectionDragEndHandler
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
timeContent = {
|
timeContent = {
|
||||||
@@ -1157,11 +1186,20 @@ fun MessageBubble(
|
|||||||
suppressBubbleTapFromSpan,
|
suppressBubbleTapFromSpan,
|
||||||
onClick = textClickHandler,
|
onClick = textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
onLongClick // 🔥
|
onLongClick, // 🔥 Long press для selection
|
||||||
// Long
|
onViewCreated = { textViewRef = it },
|
||||||
// press
|
onTextLongPress = if (textSelectionHelper != null && !isSelectionMode) { touchX, touchY ->
|
||||||
// для
|
val info = textViewRef?.getLayoutInfo()
|
||||||
// selection
|
if (info != null) {
|
||||||
|
textSelectionHelper.startSelection(
|
||||||
|
messageId = message.id,
|
||||||
|
info = info,
|
||||||
|
touchX = touchX,
|
||||||
|
touchY = touchY,
|
||||||
|
view = textViewRef
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
timeContent = {
|
timeContent = {
|
||||||
@@ -1261,11 +1299,31 @@ fun MessageBubble(
|
|||||||
suppressBubbleTapFromSpan,
|
suppressBubbleTapFromSpan,
|
||||||
onClick = textClickHandler,
|
onClick = textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
onLongClick // 🔥
|
onLongClick, // 🔥 Long press для selection
|
||||||
// Long
|
onViewCreated = { textViewRef = it },
|
||||||
// press
|
onTextLongPress = if (textSelectionHelper != null && isSelected) { touchX, touchY ->
|
||||||
// для
|
val info = textViewRef?.getLayoutInfo()
|
||||||
// selection
|
if (info != null) {
|
||||||
|
textSelectionHelper.startSelection(
|
||||||
|
messageId = message.id,
|
||||||
|
info = info,
|
||||||
|
touchX = touchX,
|
||||||
|
touchY = touchY,
|
||||||
|
view = textViewRef
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
onSelectionDrag = if (textSelectionHelper != null) { tx, ty ->
|
||||||
|
textSelectionHelper.moveHandle(
|
||||||
|
(tx - textSelectionHelper.overlayWindowX),
|
||||||
|
(ty - textSelectionHelper.overlayWindowY)
|
||||||
|
)
|
||||||
|
textSelectionHelper.showMagnifier(
|
||||||
|
(tx - textSelectionHelper.overlayWindowX),
|
||||||
|
(ty - textSelectionHelper.overlayWindowY)
|
||||||
|
)
|
||||||
|
} else null,
|
||||||
|
onSelectionDragEnd = selectionDragEndHandler
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
timeContent = {
|
timeContent = {
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
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.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
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.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInWindow
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
data class LayoutInfo(
|
||||||
|
val layout: Layout,
|
||||||
|
val windowX: Int,
|
||||||
|
val windowY: Int,
|
||||||
|
val text: CharSequence
|
||||||
|
)
|
||||||
|
|
||||||
|
class TextSelectionHelper {
|
||||||
|
|
||||||
|
var selectionStart by mutableIntStateOf(-1)
|
||||||
|
private set
|
||||||
|
var selectionEnd by mutableIntStateOf(-1)
|
||||||
|
private set
|
||||||
|
var selectedMessageId by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
var layoutInfo by mutableStateOf<LayoutInfo?>(null)
|
||||||
|
private set
|
||||||
|
var isActive by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var handleViewProgress by mutableFloatStateOf(0f)
|
||||||
|
private set
|
||||||
|
var movingHandle by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
var movingHandleStart by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
// Telegram: isOneTouch = true during initial long-press drag (before finger lifts)
|
||||||
|
var isOneTouch by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
// Telegram: direction not determined yet — first drag decides start or end handle
|
||||||
|
private var movingDirectionSettling = false
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Overlay position in window — set by TextSelectionOverlay
|
||||||
|
var overlayWindowX = 0f
|
||||||
|
var overlayWindowY = 0f
|
||||||
|
|
||||||
|
val isInSelectionMode: Boolean get() = isActive && selectionStart >= 0 && selectionEnd > selectionStart
|
||||||
|
|
||||||
|
fun startSelection(
|
||||||
|
messageId: String,
|
||||||
|
info: LayoutInfo,
|
||||||
|
touchX: Int,
|
||||||
|
touchY: Int,
|
||||||
|
view: View?
|
||||||
|
) {
|
||||||
|
val layout = info.layout
|
||||||
|
val localX = touchX - info.windowX
|
||||||
|
val localY = touchY - info.windowY
|
||||||
|
|
||||||
|
val line = layout.getLineForVertical(localY)
|
||||||
|
val hx = localX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line))
|
||||||
|
val offset = layout.getOffsetForHorizontal(line, hx)
|
||||||
|
|
||||||
|
val text = info.text
|
||||||
|
var start = offset
|
||||||
|
var end = offset
|
||||||
|
|
||||||
|
while (start > 0 && Character.isLetterOrDigit(text[start - 1])) start--
|
||||||
|
while (end < text.length && Character.isLetterOrDigit(text[end])) end++
|
||||||
|
|
||||||
|
if (start == end && end < text.length) end++
|
||||||
|
|
||||||
|
selectedMessageId = messageId
|
||||||
|
layoutInfo = info
|
||||||
|
selectionStart = start
|
||||||
|
selectionEnd = end
|
||||||
|
isActive = true
|
||||||
|
handleViewProgress = 1f
|
||||||
|
|
||||||
|
// Telegram: immediately enter drag mode — user can drag without lifting finger
|
||||||
|
movingHandle = true
|
||||||
|
movingDirectionSettling = true
|
||||||
|
isOneTouch = true
|
||||||
|
movingOffsetX = 0f
|
||||||
|
movingOffsetY = 0f
|
||||||
|
|
||||||
|
view?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||||
|
showToolbar = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSelectionStart(charOffset: Int) {
|
||||||
|
if (!isActive) return
|
||||||
|
val text = layoutInfo?.text ?: return
|
||||||
|
val newStart = charOffset.coerceIn(0, text.length)
|
||||||
|
if (newStart >= selectionEnd) return
|
||||||
|
val changed = newStart != selectionStart
|
||||||
|
selectionStart = newStart
|
||||||
|
if (changed) hapticOnSelectionChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSelectionEnd(charOffset: Int) {
|
||||||
|
if (!isActive) return
|
||||||
|
val text = layoutInfo?.text ?: return
|
||||||
|
val newEnd = charOffset.coerceIn(0, text.length)
|
||||||
|
if (newEnd <= selectionStart) return
|
||||||
|
val changed = newEnd != selectionEnd
|
||||||
|
selectionEnd = newEnd
|
||||||
|
if (changed) hapticOnSelectionChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hapticOnSelectionChange() {
|
||||||
|
magnifierView?.performHapticFeedback(
|
||||||
|
HapticFeedbackConstants.TEXT_HANDLE_MOVE,
|
||||||
|
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Telegram: first drag determines which handle to move
|
||||||
|
if (movingDirectionSettling) {
|
||||||
|
if (offset < selectionStart) {
|
||||||
|
movingDirectionSettling = false
|
||||||
|
movingHandleStart = true
|
||||||
|
} else if (offset > selectionEnd) {
|
||||||
|
movingDirectionSettling = false
|
||||||
|
movingHandleStart = false
|
||||||
|
} else {
|
||||||
|
return // still within selected word, wait for more movement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movingHandleStart) updateSelectionStart(offset) else updateSelectionEnd(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endHandleDrag() {
|
||||||
|
movingHandle = false
|
||||||
|
movingDirectionSettling = false
|
||||||
|
isOneTouch = false
|
||||||
|
showFloatingToolbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
var showToolbar by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun showFloatingToolbar() {
|
||||||
|
if (isInSelectionMode && !movingHandle) {
|
||||||
|
showToolbar = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideFloatingToolbar() {
|
||||||
|
showToolbar = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var magnifier: android.widget.Magnifier? = null
|
||||||
|
private var magnifierView: View? = null
|
||||||
|
|
||||||
|
fun setMagnifierView(view: View?) {
|
||||||
|
magnifierView = view
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showMagnifier(overlayLocalX: Float, overlayLocalY: Float) {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
|
||||||
|
val view = magnifierView ?: return
|
||||||
|
if (!movingHandle) return
|
||||||
|
if (magnifier == null) {
|
||||||
|
magnifier = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||||
|
android.widget.Magnifier.Builder(view)
|
||||||
|
.setSize(240, 64)
|
||||||
|
.setCornerRadius(12f)
|
||||||
|
.setElevation(4f)
|
||||||
|
.setDefaultSourceToMagnifierOffset(0, -96)
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
android.widget.Magnifier(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val info = layoutInfo ?: return
|
||||||
|
|
||||||
|
// Magnifier should show at the HANDLE position (current char), not finger
|
||||||
|
// Use handle X for horizontal, and line center for vertical
|
||||||
|
val handleX = if (movingHandleStart) startHandleX else endHandleX
|
||||||
|
val handleY = if (movingHandleStart) startHandleY else endHandleY
|
||||||
|
val activeOffset = if (movingHandleStart) selectionStart else selectionEnd
|
||||||
|
val layout = info.layout
|
||||||
|
val line = layout.getLineForOffset(activeOffset.coerceIn(0, info.text.length))
|
||||||
|
val lineCenter = (layout.getLineTop(line) + layout.getLineBottom(line)) / 2f
|
||||||
|
|
||||||
|
// Convert to view-local coordinates
|
||||||
|
val viewLoc = IntArray(2)
|
||||||
|
view.getLocationInWindow(viewLoc)
|
||||||
|
val sourceX = (handleX + overlayWindowX - viewLoc[0]).coerceIn(0f, view.width.toFloat())
|
||||||
|
val sourceY = (lineCenter + info.windowY - viewLoc[1]).coerceIn(0f, view.height.toFloat())
|
||||||
|
magnifier?.show(sourceX, sourceY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideMagnifier() {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.P) return
|
||||||
|
magnifier?.dismiss()
|
||||||
|
magnifier = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCharOffsetFromCoords(overlayLocalX: Int, overlayLocalY: Int): Int {
|
||||||
|
val info = layoutInfo ?: return -1
|
||||||
|
// overlay-local → text-local: subtract text position relative to overlay
|
||||||
|
val textLocalX = overlayLocalX - (info.windowX - overlayWindowX)
|
||||||
|
val textLocalY = overlayLocalY - (info.windowY - overlayWindowY)
|
||||||
|
val layout = info.layout
|
||||||
|
val line = layout.getLineForVertical(textLocalY.toInt().coerceIn(0, layout.height))
|
||||||
|
val hx = textLocalX.toFloat().coerceIn(layout.getLineLeft(line), layout.getLineRight(line))
|
||||||
|
return layout.getOffsetForHorizontal(line, hx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSelectedText(): CharSequence? {
|
||||||
|
if (!isInSelectionMode) return null
|
||||||
|
val text = layoutInfo?.text ?: return null
|
||||||
|
val start = selectionStart.coerceIn(0, text.length)
|
||||||
|
val end = selectionEnd.coerceIn(start, text.length)
|
||||||
|
return text.subSequence(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copySelectedText(context: Context) {
|
||||||
|
val selectedText = getSelectedText() ?: return
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("selected_text", selectedText))
|
||||||
|
Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectAll() {
|
||||||
|
val text = layoutInfo?.text ?: return
|
||||||
|
selectionStart = 0
|
||||||
|
selectionEnd = text.length
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
selectionStart = -1
|
||||||
|
selectionEnd = -1
|
||||||
|
selectedMessageId = null
|
||||||
|
layoutInfo = null
|
||||||
|
isActive = false
|
||||||
|
handleViewProgress = 0f
|
||||||
|
movingHandle = false
|
||||||
|
movingDirectionSettling = false
|
||||||
|
isOneTouch = false
|
||||||
|
showToolbar = false
|
||||||
|
hideMagnifier()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
private fun FloatingToolbarPopup(
|
||||||
|
helper: TextSelectionHelper
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
LaunchedEffect(helper.isActive, helper.movingHandle) {
|
||||||
|
if (helper.isActive && !helper.movingHandle && !helper.showToolbar) {
|
||||||
|
delay(200)
|
||||||
|
helper.showFloatingToolbar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!helper.showToolbar || !helper.isInSelectionMode) return
|
||||||
|
|
||||||
|
val info = helper.layoutInfo ?: return
|
||||||
|
val layout = info.layout
|
||||||
|
val startLine = layout.getLineForOffset(helper.selectionStart.coerceIn(0, info.text.length))
|
||||||
|
|
||||||
|
// Toolbar position: centered above selection, in overlay-local coords
|
||||||
|
// startHandleX/endHandleX are already overlay-local
|
||||||
|
val selectionCenterX = (helper.startHandleX + helper.endHandleX) / 2f
|
||||||
|
val selectionTopY = layout.getLineTop(startLine).toFloat() +
|
||||||
|
(info.windowY - helper.overlayWindowY)
|
||||||
|
val toolbarHeight = 40f // approximate toolbar height in px
|
||||||
|
val toolbarWidth = 160f // approximate toolbar width in px
|
||||||
|
val toolbarX = (selectionCenterX - toolbarWidth / 2f).coerceAtLeast(8f)
|
||||||
|
val toolbarY = (selectionTopY - toolbarHeight - 12f).coerceAtLeast(0f)
|
||||||
|
|
||||||
|
Popup(
|
||||||
|
alignment = Alignment.TopStart,
|
||||||
|
offset = IntOffset(toolbarX.toInt(), toolbarY.toInt())
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.shadow(4.dp, RoundedCornerShape(8.dp))
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color(0xFF333333))
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Copy",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { helper.copySelectedText(context) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
val allSelected = helper.selectionStart <= 0 &&
|
||||||
|
helper.selectionEnd >= info.text.length
|
||||||
|
if (!allSelected) {
|
||||||
|
Text(
|
||||||
|
text = "Select All",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
helper.selectAll()
|
||||||
|
helper.hideFloatingToolbar()
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
val pos = coords.positionInWindow()
|
||||||
|
helper.overlayWindowX = pos.x
|
||||||
|
helper.overlayWindowY = pos.y
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
FloatingToolbarPopup(helper = helper)
|
||||||
|
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)
|
||||||
|
helper.hideFloatingToolbar()
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
endRect.contains(Offset(x, y)) -> {
|
||||||
|
helper.beginHandleDrag(isStart = false, x, y)
|
||||||
|
helper.hideFloatingToolbar()
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
helper.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
change.pressed && helper.movingHandle -> {
|
||||||
|
helper.moveHandle(change.position.x, change.position.y)
|
||||||
|
helper.showMagnifier(change.position.x, change.position.y)
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
!change.pressed && helper.movingHandle -> {
|
||||||
|
helper.hideMagnifier()
|
||||||
|
helper.endHandleDrag()
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val info = helper.layoutInfo ?: return@Canvas
|
||||||
|
val layout = info.layout
|
||||||
|
val text = info.text
|
||||||
|
|
||||||
|
// Convert window coords to overlay-local coords
|
||||||
|
val offsetX = info.windowX - helper.overlayWindowX
|
||||||
|
val offsetY = info.windowY - helper.overlayWindowY
|
||||||
|
|
||||||
|
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() + offsetY
|
||||||
|
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY
|
||||||
|
val left = if (line == startLine) {
|
||||||
|
layout.getPrimaryHorizontal(startOffset) + offsetX
|
||||||
|
} else {
|
||||||
|
layout.getLineLeft(line) + offsetX
|
||||||
|
}
|
||||||
|
val right = if (line == endLine) {
|
||||||
|
layout.getPrimaryHorizontal(endOffset) + offsetX
|
||||||
|
} else {
|
||||||
|
layout.getLineRight(line) + offsetX
|
||||||
|
}
|
||||||
|
drawRoundRect(
|
||||||
|
color = HighlightColor,
|
||||||
|
topLeft = Offset(left, lineTop),
|
||||||
|
size = Size(right - left, lineBottom - lineTop),
|
||||||
|
cornerRadius = CornerRadius(highlightCornerPx)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX
|
||||||
|
val startHy = layout.getLineBottom(startLine).toFloat() + offsetY
|
||||||
|
val endHx = layout.getPrimaryHorizontal(endOffset) + offsetX
|
||||||
|
val endHy = layout.getLineBottom(endLine).toFloat() + offsetY
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -558,6 +558,10 @@ fun AppleEmojiText(
|
|||||||
onClickableSpanPressStart: (() -> Unit)? = null,
|
onClickableSpanPressStart: (() -> Unit)? = null,
|
||||||
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
||||||
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
|
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
|
||||||
|
onTextLongPress: ((touchX: Int, touchY: Int) -> Unit)? = null,
|
||||||
|
onSelectionDrag: ((touchX: Int, touchY: Int) -> Unit)? = null,
|
||||||
|
onSelectionDragEnd: (() -> Unit)? = null,
|
||||||
|
onViewCreated: ((com.rosetta.messenger.ui.components.AppleEmojiTextView) -> Unit)? = null,
|
||||||
minHeightMultiplier: Float = 1.5f
|
minHeightMultiplier: Float = 1.5f
|
||||||
) {
|
) {
|
||||||
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
|
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
|
||||||
@@ -601,6 +605,10 @@ fun AppleEmojiText(
|
|||||||
enableMentionHighlight(enableMentions)
|
enableMentionHighlight(enableMentions)
|
||||||
setOnMentionClickListener(onMentionClick)
|
setOnMentionClickListener(onMentionClick)
|
||||||
setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||||
|
onTextLongPressCallback = onTextLongPress
|
||||||
|
this.onSelectionDrag = onSelectionDrag
|
||||||
|
this.onSelectionDragEnd = onSelectionDragEnd
|
||||||
|
onViewCreated?.invoke(this)
|
||||||
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
||||||
val canUseTextViewClick = !enableLinks
|
val canUseTextViewClick = !enableLinks
|
||||||
setOnClickListener(
|
setOnClickListener(
|
||||||
@@ -634,6 +642,9 @@ fun AppleEmojiText(
|
|||||||
view.enableMentionHighlight(enableMentions)
|
view.enableMentionHighlight(enableMentions)
|
||||||
view.setOnMentionClickListener(onMentionClick)
|
view.setOnMentionClickListener(onMentionClick)
|
||||||
view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||||
|
view.onTextLongPressCallback = onTextLongPress
|
||||||
|
view.onSelectionDrag = onSelectionDrag
|
||||||
|
view.onSelectionDragEnd = onSelectionDragEnd
|
||||||
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
||||||
val canUseTextViewClick = !enableLinks
|
val canUseTextViewClick = !enableLinks
|
||||||
view.setOnClickListener(
|
view.setOnClickListener(
|
||||||
@@ -695,13 +706,23 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
|
|
||||||
// 🔥 Long press callback для selection в MessageBubble
|
// 🔥 Long press callback для selection в MessageBubble
|
||||||
var onLongClickCallback: (() -> Unit)? = null
|
var onLongClickCallback: (() -> Unit)? = null
|
||||||
|
var onTextLongPressCallback: ((touchX: Int, touchY: Int) -> Unit)? = null
|
||||||
|
// Telegram flow: forward drag/up events after long press fires
|
||||||
|
var onSelectionDrag: ((touchX: Int, touchY: Int) -> Unit)? = null
|
||||||
|
var onSelectionDragEnd: (() -> Unit)? = null
|
||||||
private var downOnClickableSpan: Boolean = false
|
private var downOnClickableSpan: Boolean = false
|
||||||
private var suppressPerformClickOnce: Boolean = false
|
private var suppressPerformClickOnce: Boolean = false
|
||||||
|
private var selectionDragActive: Boolean = false
|
||||||
|
|
||||||
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
|
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
|
||||||
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||||
override fun onLongPress(e: MotionEvent) {
|
override fun onLongPress(e: MotionEvent) {
|
||||||
if (!downOnClickableSpan) {
|
if (downOnClickableSpan) return
|
||||||
|
if (onTextLongPressCallback != null) {
|
||||||
|
onTextLongPressCallback?.invoke(e.rawX.toInt(), e.rawY.toInt())
|
||||||
|
selectionDragActive = true
|
||||||
|
parent?.requestDisallowInterceptTouchEvent(true) // block scroll during drag
|
||||||
|
} else {
|
||||||
onLongClickCallback?.invoke()
|
onLongClickCallback?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -721,21 +742,33 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
downOnClickableSpan = isTouchOnClickableSpan(event)
|
downOnClickableSpan = isTouchOnClickableSpan(event)
|
||||||
suppressPerformClickOnce = downOnClickableSpan
|
suppressPerformClickOnce = downOnClickableSpan
|
||||||
|
selectionDragActive = false
|
||||||
if (downOnClickableSpan) {
|
if (downOnClickableSpan) {
|
||||||
clickableSpanPressStartCallback?.invoke()
|
clickableSpanPressStartCallback?.invoke()
|
||||||
parent?.requestDisallowInterceptTouchEvent(true)
|
parent?.requestDisallowInterceptTouchEvent(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
if (selectionDragActive) {
|
||||||
|
onSelectionDrag?.invoke(event.rawX.toInt(), event.rawY.toInt())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
MotionEvent.ACTION_CANCEL,
|
MotionEvent.ACTION_CANCEL,
|
||||||
MotionEvent.ACTION_UP -> {
|
MotionEvent.ACTION_UP -> {
|
||||||
|
if (selectionDragActive) {
|
||||||
|
selectionDragActive = false
|
||||||
|
onSelectionDragEnd?.invoke()
|
||||||
|
downOnClickableSpan = false
|
||||||
|
parent?.requestDisallowInterceptTouchEvent(false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
downOnClickableSpan = false
|
downOnClickableSpan = false
|
||||||
parent?.requestDisallowInterceptTouchEvent(false)
|
parent?.requestDisallowInterceptTouchEvent(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Позволяем GestureDetector обработать событие (для long press)
|
|
||||||
gestureDetector.onTouchEvent(event)
|
gestureDetector.onTouchEvent(event)
|
||||||
// Передаем событие дальше для обработки ссылок
|
|
||||||
return super.dispatchTouchEvent(event)
|
return super.dispatchTouchEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,6 +855,18 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLayoutInfo(): com.rosetta.messenger.ui.chats.components.LayoutInfo? {
|
||||||
|
val l = layout ?: return null
|
||||||
|
val loc = IntArray(2)
|
||||||
|
getLocationInWindow(loc)
|
||||||
|
return com.rosetta.messenger.ui.chats.components.LayoutInfo(
|
||||||
|
layout = l,
|
||||||
|
windowX = loc[0] + totalPaddingLeft,
|
||||||
|
windowY = loc[1] + totalPaddingTop,
|
||||||
|
text = text ?: return null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun setTextWithEmojis(text: String) {
|
fun setTextWithEmojis(text: String) {
|
||||||
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD
|
val isLargeText = text.length > LARGE_TEXT_RENDER_THRESHOLD
|
||||||
val processMentions = mentionsEnabled && !isLargeText
|
val processMentions = mentionsEnabled && !isLargeText
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TextSelectionHelperTest {
|
||||||
|
|
||||||
|
private lateinit var helper: TextSelectionHelper
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
helper = TextSelectionHelper()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state is not active`() {
|
||||||
|
assertFalse(helper.isActive)
|
||||||
|
assertFalse(helper.isInSelectionMode)
|
||||||
|
assertEquals(-1, helper.selectionStart)
|
||||||
|
assertEquals(-1, helper.selectionEnd)
|
||||||
|
assertNull(helper.selectedMessageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clear resets all state`() {
|
||||||
|
helper.clear()
|
||||||
|
assertFalse(helper.isActive)
|
||||||
|
assertEquals(-1, helper.selectionStart)
|
||||||
|
assertEquals(-1, helper.selectionEnd)
|
||||||
|
assertNull(helper.selectedMessageId)
|
||||||
|
assertNull(helper.layoutInfo)
|
||||||
|
assertFalse(helper.showToolbar)
|
||||||
|
assertFalse(helper.movingHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getSelectedText returns null when not active`() {
|
||||||
|
assertNull(helper.getSelectedText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateSelectionEnd does not change when not active`() {
|
||||||
|
helper.updateSelectionEnd(5)
|
||||||
|
assertEquals(-1, helper.selectionEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateSelectionStart does not change when not active`() {
|
||||||
|
helper.updateSelectionStart(0)
|
||||||
|
assertEquals(-1, helper.selectionStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getCharOffsetFromCoords returns -1 when no layout`() {
|
||||||
|
assertEquals(-1, helper.getCharOffsetFromCoords(100, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectAll does nothing when no layout`() {
|
||||||
|
helper.selectAll()
|
||||||
|
assertEquals(-1, helper.selectionStart)
|
||||||
|
assertEquals(-1, helper.selectionEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moveHandle does nothing when not moving`() {
|
||||||
|
helper.moveHandle(100f, 100f)
|
||||||
|
assertFalse(helper.movingHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `endHandleDrag sets movingHandle to false`() {
|
||||||
|
helper.endHandleDrag()
|
||||||
|
assertFalse(helper.movingHandle)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user