From 52ddb21fe68da2cbc0f8b5201e393e5583bce2fd Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:05:27 +0000 Subject: [PATCH] desktop: text selection --- .../common/views/chat/ChatItemsLoader.kt | 8 +- .../common/views/chat/ChatItemsMerger.kt | 4 +- .../simplex/common/views/chat/ChatView.kt | 60 ++++- .../common/views/chat/SelectionOverlay.kt | 137 ++++++++++++ .../common/views/chat/TextSelection.kt | 210 ++++++++++++++++++ .../common/views/chat/item/EmojiItemView.kt | 45 +++- .../common/views/chat/item/FramedItemView.kt | 52 ++++- .../common/views/chat/item/TextItemView.kt | 72 +++++- 8 files changed, 564 insertions(+), 24 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectionOverlay.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 107d427556..6562d40cec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -88,7 +88,8 @@ suspend fun processLoadedChat( val (newIds, _) = mapItemsToIds(chat.chatItems) val wasSize = newItems.size val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination( - unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed + unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed, + selectionActive = chatState.selectionActive ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) @@ -177,13 +178,14 @@ private fun removeDuplicatesAndModifySplitsOnBeforePagination( newItems: SnapshotStateList, newIds: Set, splits: StateFlow>, - visibleItemIndexesNonReversed: () -> IntRange + visibleItemIndexesNonReversed: () -> IntRange, + selectionActive: Boolean = false ): ModifiedSplits { var oldUnreadSplitIndex: Int = -1 var newUnreadSplitIndex: Int = -1 val visibleItemIndexes = visibleItemIndexesNonReversed() var lastSplitIndexTrimmed = -1 - var allowedTrimming = true + var allowedTrimming = !selectionActive var index = 0 /** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */ val trimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT .. newItems.size - TRIM_KEEP_COUNT diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index d98c041478..8e7cde48e3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -202,7 +202,9 @@ data class ActiveChatState ( // exclusive val unreadAfter: MutableStateFlow = MutableStateFlow(0), // exclusive - val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0) + val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0), + // Desktop text selection: disable item eviction during active selection + @Volatile var selectionActive: Boolean = false ) { fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List) { toItemId ?: return diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 5206d0556c..7af40e57b1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* import androidx.compose.ui.draw.* import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.* import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.layout.layoutId @@ -962,11 +963,39 @@ fun ChatLayout( val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chat != null) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { + val selectionManager = if (appPlatform.isDesktop) remember { SelectionManager() } else null + val selectionClipboard = if (appPlatform.isDesktop) LocalClipboardManager.current else null + if (selectionManager != null) { + LaunchedEffect(selectionManager) { + snapshotFlow { selectionManager.selectionActive } + .collect { chatsCtx.chatState.selectionActive = it } + } + } + Box( + Modifier + .fillMaxSize() + .then( + if (selectionManager != null) { + Modifier.onPreviewKeyEvent { event -> + if (selectionManager.captured.isNotEmpty() + && event.isCtrlPressed && event.key == Key.C + && event.type == KeyEventType.KeyDown + ) { + selectionClipboard?.setText(AnnotatedString(selectionManager.getSelectedText())) + true + } else false + } + } else Modifier + ), + contentAlignment = Alignment.BottomCenter + ) { // disables scrolling to top of chat item on click inside the bubble - CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec { - override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f - }) { + CompositionLocalProvider( + LocalSelectionManager provides selectionManager, + LocalBringIntoViewSpec provides object : BringIntoViewSpec { + override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f + } + ) { ChatItemsList( chatsCtx, remoteHostId, chat, unreadCount, composeState, composeViewHeight, searchValue, useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, @@ -1893,7 +1922,7 @@ fun BoxScope.ChatItemsList( } false } - val swipeableModifier = SwipeToDismissModifier( + val swipeableModifier = if (appPlatform.isDesktop) Modifier else SwipeToDismissModifier( state = dismissState, directions = setOf(DismissDirection.EndToStart), swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, @@ -2267,6 +2296,13 @@ fun BoxScope.ChatItemsList( } } } + // Desktop text selection overlay — on top of LazyColumn in Z-order + if (appPlatform.isDesktop) { + val manager = LocalSelectionManager.current + if (manager != null) { + SelectionOverlay(manager, listState) + } + } FloatingButtons( chatsCtx, reversedChatItems, @@ -2288,6 +2324,20 @@ fun BoxScope.ChatItemsList( ) FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) + // Desktop selection copy button + if (appPlatform.isDesktop) { + val manager = LocalSelectionManager.current + if (manager != null && manager.captured.isNotEmpty() && !manager.isSelecting) { + val clipboard = LocalClipboardManager.current + SelectionCopyButton( + onCopy = { + clipboard.setText(AnnotatedString(manager.getSelectedText())) + manager.clearSelection() + } + ) + } + } + LaunchedEffect(Unit) { snapshotFlow { listState.value.isScrollInProgress } .collect { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectionOverlay.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectionOverlay.kt new file mode 100644 index 0000000000..726ac84725 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectionOverlay.kt @@ -0,0 +1,137 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalViewConfiguration +import kotlinx.coroutines.* + +private const val AUTO_SCROLL_ZONE_PX = 40f +private const val MIN_SCROLL_SPEED = 2f +private const val MAX_SCROLL_SPEED = 20f + +@Composable +fun SelectionOverlay( + selectionManager: SelectionManager, + listState: State, + modifier: Modifier = Modifier +) { + val touchSlop = LocalViewConfiguration.current.touchSlop + var positionInWindow by remember { mutableStateOf(Offset.Zero) } + var viewportTop by remember { mutableStateOf(0f) } + var viewportBottom by remember { mutableStateOf(0f) } + val scope = rememberCoroutineScope() + var autoScrollJob by remember { mutableStateOf(null) } + + // Re-evaluate selection on scroll (handles mouse wheel and auto-scroll) + LaunchedEffect(selectionManager) { + snapshotFlow { listState.value.firstVisibleItemScrollOffset } + .collect { + if (selectionManager.isSelecting) { + selectionManager.updateSelection( + selectionManager.lastPointerWindowY, + selectionManager.lastPointerWindowX + ) + } + } + } + + Box( + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { + positionInWindow = it.positionInWindow() + val bounds = it.boundsInWindow() + viewportTop = bounds.top + viewportBottom = bounds.bottom + } + .pointerInput(selectionManager) { + awaitEachGesture { + val down = awaitPointerEvent(PointerEventPass.Initial) + val firstChange = down.changes.first() + val localStart = firstChange.position + val windowStart = localStart + positionInWindow + var totalDrag = Offset.Zero + var isDragging = false + + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.first() + + if (!change.pressed) { + autoScrollJob?.cancel() + autoScrollJob = null + if (isDragging) { + selectionManager.endSelection() + } + // Non-drag pointer up: do nothing to selection. + // Selection persists. Links/right-click work via pass-through. + // New drag clears old selection in startSelection(). + break + } + + totalDrag += change.positionChange() + + if (!isDragging && totalDrag.getDistance() > touchSlop) { + isDragging = true + selectionManager.startSelection(windowStart.y, windowStart.x) + change.consume() + } + + if (isDragging) { + val windowPos = change.position + positionInWindow + selectionManager.updateSelection(windowPos.y, windowPos.x) + change.consume() + + // Auto-scroll: direction-aware + val draggingDown = windowPos.y > windowStart.y + val edgeDistance = if (draggingDown) { + viewportBottom - windowPos.y + } else { + windowPos.y - viewportTop + } + val shouldAutoScroll = edgeDistance in 0f..AUTO_SCROLL_ZONE_PX + + if (shouldAutoScroll && autoScrollJob?.isActive != true) { + autoScrollJob = scope.launch { + while (isActive && selectionManager.isSelecting) { + val curEdge = if (draggingDown) { + viewportBottom - selectionManager.lastPointerWindowY + } else { + selectionManager.lastPointerWindowY - viewportTop + } + if (curEdge >= AUTO_SCROLL_ZONE_PX) break + + val speed = lerp( + MIN_SCROLL_SPEED, MAX_SCROLL_SPEED, + 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f) + ) + // reverseLayout = true: + // drag down (toward newer) = scrollBy(-speed) + // drag up (toward older) = scrollBy(speed) + // VERIFY EMPIRICALLY — if wrong, flip sign + listState.value.scrollBy(if (draggingDown) -speed else speed) + delay(16) + } + } + } else if (!shouldAutoScroll) { + autoScrollJob?.cancel() + autoScrollJob = null + } + } + } + } + } + ) +} + +private fun lerp(start: Float, stop: Float, fraction: Float): Float = + start + (stop - start) * fraction diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt new file mode 100644 index 0000000000..480cfe6908 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -0,0 +1,210 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.dp +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +val SelectionHighlightColor = Color(0x4D0066FF) + +@Stable +data class SelectionCoords( + val startY: Float, + val startX: Float, + val endY: Float, + val endX: Float +) { + val isReversed: Boolean get() = startY > endY + val topY: Float get() = minOf(startY, endY) + val bottomY: Float get() = maxOf(startY, endY) + val topX: Float get() = if (isReversed) endX else startX + val bottomX: Float get() = if (isReversed) startX else endX +} + +data class CapturedText( + val itemId: Long, + val yPosition: Float, + val highlightRange: IntRange, + val text: String +) + +interface SelectionParticipant { + val itemId: Long + fun getYBounds(): ClosedFloatingPointRange? + fun getTextLayoutResult(): TextLayoutResult? + fun getSelectableEnd(): Int + fun getAnnotatedText(): String + fun calculateHighlightRange(coords: SelectionCoords): IntRange? +} + +class SelectionManager { + var coords by mutableStateOf(null) + private set + + var isSelecting by mutableStateOf(false) + private set + + val selectionActive: Boolean get() = coords != null + + var lastPointerWindowY: Float = 0f + private set + var lastPointerWindowX: Float = 0f + private set + + private val participants = mutableListOf() + val captured = mutableStateMapOf() + + fun register(participant: SelectionParticipant) { + participants.add(participant) + coords?.let { recomputeParticipant(participant, it) } + } + + fun unregister(participant: SelectionParticipant) { + participants.remove(participant) + } + + fun startSelection(startY: Float, startX: Float) { + coords = SelectionCoords(startY, startX, startY, startX) + isSelecting = true + lastPointerWindowY = startY + lastPointerWindowX = startX + captured.clear() + } + + fun updateSelection(endY: Float, endX: Float) { + val current = coords ?: return + coords = current.copy(endY = endY, endX = endX) + lastPointerWindowY = endY + lastPointerWindowX = endX + recomputeAll() + } + + fun endSelection() { + isSelecting = false + } + + fun clearSelection() { + coords = null + isSelecting = false + captured.clear() + } + + private fun recomputeAll() { + val c = coords ?: return + val visibleInRange = mutableMapOf() + val visibleOutOfRange = mutableSetOf() + + for (p in participants) { + val bounds = p.getYBounds() + if (bounds != null && bounds.start <= c.bottomY && bounds.endInclusive >= c.topY) { + visibleInRange[p.itemId] = p + } else { + visibleOutOfRange.add(p.itemId) + } + } + + visibleOutOfRange.forEach { captured.remove(it) } + + for ((_, p) in visibleInRange) { + recomputeParticipant(p, c) + } + } + + private fun recomputeParticipant(participant: SelectionParticipant, coords: SelectionCoords) { + val bounds = participant.getYBounds() ?: return + val highlightRange = participant.calculateHighlightRange(coords) ?: return + val selectableEnd = participant.getSelectableEnd() + val clampedStart = highlightRange.first.coerceIn(0, selectableEnd) + val clampedEnd = highlightRange.last.coerceIn(0, selectableEnd) + if (clampedStart >= clampedEnd) return + + val annotatedText = participant.getAnnotatedText() + val text = if (clampedEnd <= annotatedText.length) { + annotatedText.substring(clampedStart, clampedEnd) + } else { + annotatedText.substring(clampedStart.coerceAtMost(annotatedText.length)) + } + + captured[participant.itemId] = CapturedText( + itemId = participant.itemId, + yPosition = bounds.start, + highlightRange = clampedStart until clampedEnd, + text = text + ) + } + + fun getSelectedText(): String { + return captured.values + .sortedBy { it.yPosition } + .joinToString("\n") { it.text } + } + + fun getHighlightRange(itemId: Long): IntRange? { + return captured[itemId]?.highlightRange + } +} + +fun calculateRangeForElement( + bounds: Rect?, + layout: TextLayoutResult?, + selectableEnd: Int, + coords: SelectionCoords +): IntRange? { + bounds ?: return null + layout ?: return null + if (selectableEnd <= 0) return null + + val isFirst = bounds.top <= coords.topY && bounds.bottom > coords.topY + val isLast = bounds.top < coords.bottomY && bounds.bottom >= coords.bottomY + val isMiddle = bounds.top > coords.topY && bounds.bottom < coords.bottomY + + return when { + isMiddle -> 0 until selectableEnd + isFirst && isLast -> { + val s = layout.getOffsetForPosition(Offset(coords.topX - bounds.left, coords.topY - bounds.top)) + val e = layout.getOffsetForPosition(Offset(coords.bottomX - bounds.left, coords.bottomY - bounds.top)) + minOf(s, e) until maxOf(s, e) + } + isFirst -> { + val s = layout.getOffsetForPosition(Offset(coords.topX - bounds.left, coords.topY - bounds.top)) + s until selectableEnd + } + isLast -> { + val e = layout.getOffsetForPosition(Offset(coords.bottomX - bounds.left, coords.bottomY - bounds.top)) + 0 until e + } + else -> null + } +} + +val LocalSelectionManager = staticCompositionLocalOf { null } + +@Composable +fun SelectionCopyButton(onCopy: () -> Unit) { + Row( + Modifier + .padding(8.dp) + .background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp)) + .clickable { onCopy() } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(MR.images.ic_content_copy), null, Modifier.size(16.dp), tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(6.dp)) + Text(generalGetString(MR.strings.copy_verb), color = MaterialTheme.colors.primary) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt index 7aca0466f9..65bdae72ae 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt @@ -1,17 +1,22 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MREmojiChar import chat.simplex.common.ui.theme.EmojiFont +import chat.simplex.common.views.chat.* import java.sql.Timestamp val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont) @@ -19,11 +24,43 @@ val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiF @Composable fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) { + val selectionManager = LocalSelectionManager.current + val boundsState = remember { mutableStateOf(null) } + val currentEmojiText = rememberUpdatedState(chatItem.content.text.trim()) + + if (selectionManager != null) { + val participant = remember(chatItem.id) { + object : SelectionParticipant { + override val itemId = chatItem.id + override fun getYBounds() = boundsState.value?.let { it.top..it.bottom } + override fun getTextLayoutResult() = null + override fun getSelectableEnd() = currentEmojiText.value.length + override fun getAnnotatedText() = currentEmojiText.value + override fun calculateHighlightRange(coords: SelectionCoords): IntRange? { + val bounds = boundsState.value ?: return null + return if (bounds.top <= coords.bottomY && bounds.bottom >= coords.topY) + 0 until currentEmojiText.value.length + else null + } + } + } + DisposableEffect(participant) { + selectionManager.register(participant) + onDispose { selectionManager.unregister(participant) } + } + } + + val isSelected = selectionManager?.getHighlightRange(chatItem.id) != null + Column( - Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + Modifier + .padding(vertical = 8.dp, horizontal = 12.dp) + .onGloballyPositioned { boundsState.value = it.boundsInWindow() }, horizontalAlignment = Alignment.CenterHorizontally ) { - EmojiText(chatItem.content.text) + Box(if (isSelected) Modifier.background(SelectionHighlightColor) else Modifier) { + EmojiText(chatItem.content.text) + } CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 10cb86fceb..e7af5b9b2f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -20,8 +20,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* +import androidx.compose.ui.geometry.Rect import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.ComposeState +import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers @@ -368,9 +369,46 @@ fun CIMarkdownText( showTimestamp: Boolean, prefix: AnnotatedString? = null ) { - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { - val chatInfo = chat.chatInfo - val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text + val selectionManager = LocalSelectionManager.current + val boundsState = remember { mutableStateOf(null) } + val layoutResultState = remember { mutableStateOf(null) } + val selectableEnd = remember { mutableIntStateOf(Int.MAX_VALUE) } + val annotatedTextState = remember { mutableStateOf("") } + val chatInfo = chat.chatInfo + val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text + + if (selectionManager != null && ci.meta.isLive != true) { + val currentText = rememberUpdatedState(text) + val participant = remember(ci.id) { + object : SelectionParticipant { + override val itemId = ci.id + override fun getYBounds() = boundsState.value?.let { it.top..it.bottom } + override fun getTextLayoutResult() = layoutResultState.value + override fun getSelectableEnd() = selectableEnd.intValue + override fun getAnnotatedText(): String { + val at = annotatedTextState.value + return if (at.isNotEmpty()) at else currentText.value + } + override fun calculateHighlightRange(coords: SelectionCoords) = + calculateRangeForElement( + boundsState.value, layoutResultState.value, + selectableEnd.intValue, coords + ) + } + } + DisposableEffect(participant) { + selectionManager.register(participant) + onDispose { selectionManager.unregister(participant) } + } + } + + val highlightRange = selectionManager?.getHighlightRange(ci.id) + + Box( + Modifier + .padding(vertical = 7.dp, horizontal = 12.dp) + .onGloballyPositioned { boundsState.value = it.boundsInWindow() } + ) { MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, @@ -379,7 +417,11 @@ fun CIMarkdownText( chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null }, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, + selectableEnd = selectableEnd, + annotatedTextState = annotatedTextState, + selectionRange = highlightRange, + onTextLayoutResult = { layoutResultState.value = it } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 3984e5bc40..f3d3ec4c3e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -10,6 +10,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.* @@ -22,6 +23,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.chat.SelectionHighlightColor import chat.simplex.common.views.helpers.* import chat.simplex.res.* import kotlinx.coroutines.* @@ -77,7 +79,11 @@ fun MarkdownText ( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean = false, showTimestamp: Boolean = true, - prefix: AnnotatedString? = null + prefix: AnnotatedString? = null, + selectableEnd: MutableIntState? = null, + annotatedTextState: MutableState? = null, + selectionRange: IntRange? = null, + onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null ) { val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr @@ -132,12 +138,18 @@ fun MarkdownText ( if (prefix != null) append(prefix) if (text is String) append(text) else if (text is AnnotatedString) append(text) + selectableEnd?.intValue = this.length if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + annotatedTextState?.value = annotatedText.text + if (meta?.isLive == true) { + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + } else { + SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = selectionRange, onTextLayoutResult = onTextLayoutResult) + } } else { var hasLinks = false var hasSecrets = false @@ -247,6 +259,7 @@ fun MarkdownText ( is Format.Unknown -> append(ft.text) } } + selectableEnd?.intValue = this.length if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } @@ -255,9 +268,10 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } + annotatedTextState?.value = annotatedText.text if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) { val icon = remember { mutableStateOf(PointerIcon.Default) } - ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + ClickableText(annotatedText, style = style, selectionRange = selectionRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -300,10 +314,11 @@ fun MarkdownText ( annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) - } + }, + onTextLayout = { onTextLayoutResult?.invoke(it) } ) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = selectionRange, onTextLayoutResult = onTextLayoutResult) } } } @@ -314,6 +329,7 @@ fun ClickableText( text: AnnotatedString, modifier: Modifier = Modifier, style: TextStyle = TextStyle.Default, + selectionRange: IntRange? = null, softWrap: Boolean = true, overflow: TextOverflow = TextOverflow.Clip, maxLines: Int = Int.MAX_VALUE, @@ -354,9 +370,19 @@ fun ClickableText( } } + val selectionHighlight = if (selectionRange != null) { + Modifier.drawBehind { + layoutResult.value?.let { result -> + if (selectionRange.first < selectionRange.last && selectionRange.last <= text.length) { + drawPath(result.getPathForRange(selectionRange.first, selectionRange.last), SelectionHighlightColor) + } + } + } + } else Modifier + BasicText( text = text, - modifier = modifier.then(pressIndicator), + modifier = modifier.then(selectionHighlight).then(pressIndicator), style = style, softWrap = softWrap, overflow = overflow, @@ -368,6 +394,40 @@ fun ClickableText( ) } +@Composable +private fun SelectableText( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + selectionRange: IntRange? = null, + onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null +) { + val layoutResult = remember { mutableStateOf(null) } + val highlight = if (selectionRange != null) { + Modifier.drawBehind { + layoutResult.value?.let { result -> + if (selectionRange.first < selectionRange.last && selectionRange.last <= text.length) { + drawPath(result.getPathForRange(selectionRange.first, selectionRange.last), SelectionHighlightColor) + } + } + } + } else Modifier + + BasicText( + text = text, + modifier = modifier.then(highlight), + style = style, + maxLines = maxLines, + overflow = overflow, + onTextLayout = { + layoutResult.value = it + onTextLayoutResult?.invoke(it) + } + ) +} + fun openBrowserAlert(uri: String, uriHandler: UriHandler) { val (res, err) = sanitizeUri(uri) if (res == null) {