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 index 21bc269182..4a186f3c3d 100644 --- 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 @@ -35,7 +35,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.common.views.chat.item.displayText +import chat.simplex.common.views.chat.item.itemSegmentDisplayText import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource @@ -110,6 +110,25 @@ class SelectionManager { selectionState = SelectionState.Selected } + // Snaps boundary offsets to include full transformed segments (mentions, links with showText). + fun snapSelection(items: List, linkMode: SimplexLinkMode) { + val r = range ?: return + val startCi = items.getOrNull(r.startIndex)?.newest()?.item + val endCi = items.getOrNull(r.endIndex)?.newest()?.item + // expandRight: snap in the direction that grows the selection + val startExpandRight = if (r.startIndex == r.endIndex) r.startOffset > r.endOffset else r.startIndex < r.endIndex + val endExpandRight = if (r.startIndex == r.endIndex) r.endOffset > r.startOffset else r.endIndex < r.startIndex + val snappedStart = if (startCi != null && r.startOffset >= 0) + snapOffset(startCi, r.startOffset, linkMode, expandRight = startExpandRight) + else r.startOffset + val snappedEnd = if (endCi != null && r.endOffset >= 0) + snapOffset(endCi, r.endOffset, linkMode, expandRight = endExpandRight) + else r.endOffset + if (snappedStart != r.startOffset || snappedEnd != r.endOffset) { + range = r.copy(startOffset = snappedStart, endOffset = snappedEnd) + } + } + fun clearSelection() { range = null selectionState = SelectionState.Idle @@ -176,18 +195,14 @@ class SelectionManager { } } - fun getSelectedText(items: List, linkMode: SimplexLinkMode): String { + fun getSelectedCopiedText(items: List, linkMode: SimplexLinkMode): String { val r = range ?: return "" val lo = minOf(r.startIndex, r.endIndex) val hi = maxOf(r.startIndex, r.endIndex) return (lo..hi).mapNotNull { idx -> val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null val sel = selectedRange(range, idx) ?: return@mapNotNull null - val text = displayText(ci, linkMode, sendCommandMsg = false) - text.substring( - sel.first.coerceAtMost(text.length), - (sel.last + 1).coerceAtMost(text.length) - ) + selectedItemCopiedText(ci, sel, linkMode) }.reversed().joinToString("\n") } } @@ -222,6 +237,48 @@ fun selectedRange(range: SelectionRange?, index: Int): IntRange? { } } +// Extracts source text for the selected range within one item. +// Selection offsets are in display-text space. For transformed segments (mentions, links with showText), +// the full source is emitted if any part is selected. For untransformed segments, partial substring works. +private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: SimplexLinkMode): String { + val formattedText = ci.formattedText ?: return ci.text.substring( + sel.first.coerceAtMost(ci.text.length), + (sel.last + 1).coerceAtMost(ci.text.length) + ) + val sb = StringBuilder() + var displayOffset = 0 + for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + val overlapStart = maxOf(displayOffset, sel.first) + val overlapEnd = minOf(displayEnd, sel.last + 1) + if (overlapStart < overlapEnd) { + if (ft.text.length == segDisplay.length) { + sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset) + } else { + sb.append(ft.text) + } + } + displayOffset = displayEnd + } + return sb.toString() +} + +// Snaps a boundary offset to include full transformed segments. +private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int { + val formattedText = ci.formattedText ?: return offset + var displayOffset = 0 + for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) { + return if (expandRight) displayEnd else displayOffset + } + displayOffset = displayEnd + } + return offset +} + val LocalSelectionManager = staticCompositionLocalOf { null } private const val AUTO_SCROLL_ZONE_PX = 40f @@ -253,7 +310,7 @@ fun BoxScope.SelectionHandler( manager.listState = listState manager.onCopySelection = { - clipboard.setText(AnnotatedString(manager.getSelectedText(mergedItems.value.items, linkMode))) + clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, linkMode))) manager.clearSelection() showToast(generalGetString(MR.strings.copied)) } @@ -314,6 +371,7 @@ fun BoxScope.SelectionHandler( SelectionState.Selecting -> { if (!event.pressed) { manager.endSelection() + manager.snapSelection(mergedItems.value.items, linkMode) return@awaitEachGesture } val windowPos = event.position + manager.viewportPosition 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 4c238b2076..9e8583a79b 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 @@ -57,35 +57,35 @@ private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) -// Must be coordinated with MarkdownText — same text transformations for: -// Mention, HyperLink, SimplexLink, Command -fun displayText(ci: ChatItem, linkMode: SimplexLinkMode, sendCommandMsg: Boolean): String { - val formattedText = ci.formattedText - if (formattedText == null) return ci.text - return formattedText.joinToString("") { ft -> - when (ft.format) { - is Format.Mention -> { - val mention = ci.mentions?.get(ft.format.memberName) - if (mention?.memberRef != null) { - val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName - else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" - mentionText(name) - } else if (mention != null) mentionText(ft.format.memberName) - else ft.text - } - is Format.HyperLink -> ft.format.showText ?: ft.text - is Format.SimplexLink -> { - val t = ft.format.showText - ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null - if (t != null) "$t ${ft.format.viaHosts}" else ft.text - } - is Format.Command -> if (sendCommandMsg) "/${ft.format.commandStr}" else ft.text - else -> ft.text +// Display text for a single formatted segment — must be coordinated with MarkdownText. +fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String = + when (ft.format) { + is Format.Mention -> { + val mention = ci.mentions?.get(ft.format.memberName) + if (mention?.memberRef != null) { + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName + else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" + mentionText(name) + } else if (mention != null) mentionText(ft.format.memberName) + else ft.text } + is Format.HyperLink -> ft.format.showText ?: ft.text + is Format.SimplexLink -> { + val t = ft.format.showText + ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null + if (t != null) "$t ${ft.format.viaHosts}" else ft.text + } + is Format.Command -> ft.text + else -> ft.text } + +// Full display text for a chat item — joins segment display texts. +fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String { + val formattedText = ci.formattedText ?: return ci.text + return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) } } -// Text transformations in this function must match displayText above +// Text transformations in MarkdownText must match itemSegmentDisplayText above @Composable fun MarkdownText ( text: CharSequence, @@ -531,4 +531,4 @@ private fun isRtl(s: CharSequence): Boolean { return false } -private fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" +fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name"