desktop: select text in messages (#6725)

* desktop: text selection plan

* update the plan

* desktop: text selection

* improve

* state management

* fix selection tracking

* rewrite

* revert

* update plan

* new implementation

* fix

* fix 2, logs

* fix 3, logging

* better cursor position

* fix last character highlight and one-line message selection

* fix copied text

* fix copy order for multi-message copy

* position copy button

* better copy button position

* moare better

* copy position

* condition

* debug copy

* comment more

* copy button fix

* minor fixes

* fix ranges

* refactor

* clip copy button

* refactor 2

* refactor 3

* clean up

* refactor event handling

* toast on copy

* copy actual message text with links

* move copy button in z-order

* move more

* try alignment

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-04-02 22:32:00 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
parent d44dba630c
commit bb6fb8f2e9
8 changed files with 1103 additions and 25 deletions
@@ -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<ChatItem>,
newIds: Set<Long>,
splits: StateFlow<List<Long>>,
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
@@ -202,7 +202,8 @@ data class ActiveChatState (
// exclusive
val unreadAfter: MutableStateFlow<Int> = MutableStateFlow(0),
// exclusive
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0)
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0),
@Volatile var selectionActive: Boolean = false
) {
fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List<ChatItem>) {
toItemId ?: return
@@ -148,6 +148,7 @@ fun ChatView(
val showCommandsMenu = rememberSaveable { mutableStateOf(false) }
val contentFilter = rememberSaveable { mutableStateOf<ContentFilter?>(null) }
val availableContent = remember { mutableStateOf<List<ContentFilter>>(ContentFilter.initialList) }
val selectionManager = if (appPlatform.isDesktop) remember { SelectionManager() } else null
if (appPlatform.isAndroid) {
DisposableEffect(Unit) {
@@ -178,6 +179,7 @@ fun ChatView(
contentFilter.value = null
availableContent.value = ContentFilter.initialList
selectedChatItems.value = null
selectionManager?.clearSelection()
val cInfo = activeChat.value?.chatInfo
if (chatsCtx.secondaryContextFilter == null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group || cInfo is ChatInfo.Local)) {
updateAvailableContent(chatRh, activeChat, availableContent)
@@ -227,6 +229,7 @@ fun ChatView(
val clipboard = LocalClipboardManager.current
CompositionLocalProvider(
LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false),
LocalSelectionManager provides selectionManager,
) {
when (chatInfo) {
is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> {
@@ -962,11 +965,20 @@ fun ChatLayout(
val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null }
AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) {
if (chat != null) {
val selectionManager = LocalSelectionManager.current
if (selectionManager != null) {
LaunchedEffect(selectionManager) {
snapshotFlow { selectionManager.selectionState != SelectionState.Idle }
.collect { chatsCtx.chatState.selectionActive = it }
}
}
Box(Modifier.fillMaxSize(), 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(
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,
@@ -995,6 +1007,13 @@ fun ChatLayout(
CommandsMenuView(chatsCtx, chat, composeState, showCommandsMenu)
}
}
// Copy button inside TopStart-aligned wrapper — above messages,
// behind compose (ABPL paints compose after) and toolbars (outer Box paints after ABPL)
if (appPlatform.isDesktop) {
Box(Modifier.matchParentSize()) {
SelectionCopyButton()
}
}
}
}
if (chatsCtx.contentTag == MsgContentTag.Report) {
@@ -1894,7 +1913,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() },
@@ -2195,8 +2214,11 @@ fun BoxScope.ChatItemsList(
}
}
val manager = LocalSelectionManager.current
val modifier = if (appPlatform.isDesktop && manager != null) SelectionHandler(manager, listState, mergedItems, linkMode) else Modifier
LazyColumnWithScrollBar(
Modifier.align(Alignment.BottomCenter),
modifier.align(Alignment.BottomCenter),
state = listState.value,
contentPadding = PaddingValues(
top = topPaddingToContent,
@@ -2250,8 +2272,10 @@ fun BoxScope.ChatItemsList(
itemSeparation = getItemSeparation(item, null)
prevItemSeparationLargeGap = false
}
ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) {
if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems)
CompositionLocalProvider(LocalItemContext provides ItemContext(selectionIndex = index)) {
ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) {
if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems)
}
}
if (last != null) {
@@ -0,0 +1,518 @@
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.focusable
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.IntOffset
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.itemSegmentDisplayText
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.*
val SelectionHighlightColor = Color(0x4D0066FF)
data class ItemContext(
val selectionIndex: Int = -1
)
val LocalItemContext = compositionLocalOf { ItemContext() }
data class SelectionRange(
val startIndex: Int,
val startOffset: Int,
val endIndex: Int,
val endOffset: Int
)
enum class SelectionState { Idle, Selecting, Selected }
class SelectionManager {
var selectionState by mutableStateOf(SelectionState.Idle)
private set
var range by mutableStateOf<SelectionRange?>(null)
private set
var anchorWindowY by mutableStateOf(0f)
private set
var anchorWindowX by mutableStateOf(0f)
private set
var focusWindowY by mutableStateOf(0f)
var focusWindowX by mutableStateOf(0f)
var viewportWidth by mutableStateOf(0f)
var viewportHeight by mutableStateOf(0f)
var viewportTop by mutableStateOf(0f)
var viewportBottom by mutableStateOf(0f)
var viewportPosition by mutableStateOf(Offset.Zero)
var focusCharRect by mutableStateOf(Rect.Zero) // X: absolute window, Y: relative to item
var listState: State<LazyListState>? = null
var onCopySelection: (() -> Unit)? = null
private var autoScrollJob: Job? = null
fun startSelection(startIndex: Int, anchorY: Float, anchorX: Float) {
range = SelectionRange(startIndex, -1, startIndex, -1)
selectionState = SelectionState.Selecting
anchorWindowY = anchorY
anchorWindowX = anchorX
}
fun setAnchorOffset(offset: Int) {
val r = range ?: return
range = r.copy(startOffset = offset)
}
fun updateFocusIndex(index: Int) {
val r = range ?: return
range = r.copy(endIndex = index)
}
fun updateFocusOffset(offset: Int, charRect: Rect = Rect.Zero) {
val r = range ?: return
range = r.copy(endOffset = offset)
focusCharRect = charRect
}
fun endSelection() {
autoScrollJob?.cancel()
autoScrollJob = null
selectionState = SelectionState.Selected
}
// Snaps boundary offsets to include full transformed segments (mentions, links with showText).
fun snapSelection(items: List<MergedItem>, 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
}
// Computes copy button position relative to the viewport (called during layout phase).
// Dragging down: button below focus char (top-left at char's bottom-right corner).
// Dragging up: button above focus char (bottom-right at char's top-left corner).
// focusCharRect X is absolute window coords, Y is relative to item.
fun copyButtonOffset(draggingDown: Boolean, gap: Float, buttonSize: IntSize): IntOffset {
val r = range ?: return IntOffset.Zero
val ls = listState?.value ?: return IntOffset.Zero
val itemInfo = ls.layoutInfo.visibleItemsInfo.find { it.index == r.endIndex }
?: return IntOffset(-10000, -10000) // focus item scrolled off screen
// Item top in viewport coords (reversed layout: viewportEnd - offset - size)
val itemWindowY = (ls.layoutInfo.viewportEndOffset - itemInfo.offset - itemInfo.size).toFloat()
val cr = focusCharRect
val vp = viewportPosition
// Convert from window coords to viewport-relative
val charX = (if (draggingDown) cr.right else cr.left) - vp.x
val charY = itemWindowY + (if (draggingDown) cr.bottom else cr.top) - vp.y
// Anchor button corner at char corner with gap
val x = if (draggingDown) charX else (charX - buttonSize.width).coerceAtLeast(0f)
val y = if (draggingDown) charY + gap else charY - buttonSize.height - gap
val clampedX = x.coerceIn(0f, (viewportWidth - buttonSize.width).coerceAtLeast(0f))
return IntOffset(clampedX.toInt(), y.toInt())
}
fun startDragSelection(localStart: Offset, windowStart: Offset, focusRequester: FocusRequester) {
val ls = listState?.value ?: return
val idx = resolveIndexAtY(ls, localStart.y) ?: return
startSelection(idx, windowStart.y, windowStart.x)
focusWindowY = windowStart.y
focusWindowX = windowStart.x
try { focusRequester.requestFocus() } catch (_: Exception) {}
}
fun updateDragFocus(windowPos: Offset, localY: Float) {
focusWindowY = windowPos.y
focusWindowX = windowPos.x
val ls = listState?.value ?: return
val idx = resolveIndexAtY(ls, localY) ?: return
updateFocusIndex(idx)
}
fun updateAutoScroll(draggingDown: Boolean, pointerY: Float, scope: CoroutineScope) {
val edgeDistance = if (draggingDown) viewportBottom - pointerY else pointerY - viewportTop
if (edgeDistance !in 0f..AUTO_SCROLL_ZONE_PX) {
autoScrollJob?.cancel()
autoScrollJob = null
return
}
if (autoScrollJob?.isActive == true) return
val ls = listState ?: return
autoScrollJob = scope.launch {
while (isActive && selectionState == SelectionState.Selecting) {
val curEdge = if (draggingDown) viewportBottom - focusWindowY else focusWindowY - viewportTop
if (curEdge >= AUTO_SCROLL_ZONE_PX) break
val fraction = 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f)
val speed = MIN_SCROLL_SPEED + (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED) * fraction
ls.value.scrollBy(if (draggingDown) -speed else speed)
delay(16)
}
}
}
fun getSelectedCopiedText(items: List<MergedItem>, 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
selectedItemCopiedText(ci, sel, linkMode)
}.reversed().joinToString("\n")
}
}
// Returns the character range selected within a given item.
// Offsets are cursor positions (between characters), so the selected characters
// are those between min and max cursors: range is min..(max - 1).
// In reversed layout: higher index = higher on screen.
// startIndex/startOffset = anchor, endIndex/endOffset = focus.
fun selectedRange(range: SelectionRange?, index: Int): IntRange? {
val r = range ?: return null
val lo = minOf(r.startIndex, r.endIndex)
val hi = maxOf(r.startIndex, r.endIndex)
if (index < lo || index > hi) return null
return when {
// Single-item selection: characters between the two cursor positions
index == r.startIndex && index == r.endIndex ->
if (r.startOffset < 0 || r.endOffset < 0 || r.startOffset == r.endOffset) null
else minOf(r.startOffset, r.endOffset) .. (maxOf(r.startOffset, r.endOffset) - 1)
// Anchor item in multi-item selection: from cursor to end, or from start to cursor
index == r.startIndex ->
if (r.startOffset < 0) null
else if (r.startIndex > r.endIndex) r.startOffset until Int.MAX_VALUE
else 0 until r.startOffset
// Focus item in multi-item selection: symmetric to anchor
index == r.endIndex ->
if (r.endOffset < 0) null
else if (r.endIndex < r.startIndex) 0 until r.endOffset
else r.endOffset until Int.MAX_VALUE
// Interior items: fully selected
else -> 0 until Int.MAX_VALUE
}
}
// 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<SelectionManager?> { null }
private const val AUTO_SCROLL_ZONE_PX = 40f
private const val MIN_SCROLL_SPEED = 2f
private const val MAX_SCROLL_SPEED = 20f
@Composable
fun BoxScope.SelectionHandler(
manager: SelectionManager,
listState: State<LazyListState>,
mergedItems: State<MergedItems>,
linkMode: SimplexLinkMode
): Modifier {
val touchSlop = LocalViewConfiguration.current.touchSlop
val clipboard = LocalClipboardManager.current
val focusRequester = remember { FocusRequester() }
val scope = rememberCoroutineScope()
// Re-evaluate focus index on scroll during active drag
LaunchedEffect(manager) {
snapshotFlow { listState.value.firstVisibleItemScrollOffset }
.collect {
if (manager.selectionState == SelectionState.Selecting) {
val idx = resolveIndexAtY(listState.value, manager.focusWindowY - manager.viewportPosition.y)
if (idx != null) manager.updateFocusIndex(idx)
}
}
}
manager.listState = listState
manager.onCopySelection = {
clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, linkMode)))
manager.clearSelection()
showToast(generalGetString(MR.strings.copied))
}
return Modifier
.focusRequester(focusRequester)
.focusable()
.onKeyEvent { event ->
if (manager.selectionState == SelectionState.Selected
&& (event.isCtrlPressed || event.isMetaPressed)
&& event.key == Key.C
&& event.type == KeyEventType.KeyDown
) {
manager.onCopySelection?.invoke()
true
} else false
}
.onGloballyPositioned {
val pos = it.positionInWindow()
val bounds = it.boundsInWindow()
manager.viewportTop = bounds.top
manager.viewportBottom = bounds.bottom
manager.viewportWidth = bounds.right - bounds.left
manager.viewportHeight = bounds.bottom - bounds.top
manager.viewportPosition = pos
}
.pointerInput(manager) {
awaitEachGesture {
val initialEvent = awaitPointerEvent(PointerEventPass.Initial).changes.first()
if (!initialEvent.pressed) return@awaitEachGesture
val localStart = initialEvent.position
val windowStart = localStart + manager.viewportPosition
if (manager.selectionState == SelectionState.Selected) initialEvent.consume()
var totalDrag = Offset.Zero
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial).changes.first()
when (manager.selectionState) {
SelectionState.Idle -> {
if (!event.pressed) return@awaitEachGesture
totalDrag += event.positionChange()
if (totalDrag.getDistance() > touchSlop) {
manager.startDragSelection(localStart, windowStart, focusRequester)
event.consume()
}
}
SelectionState.Selected -> {
if (!event.pressed) {
manager.clearSelection()
return@awaitEachGesture
}
event.consume()
totalDrag += event.positionChange()
if (totalDrag.getDistance() > touchSlop) {
manager.startDragSelection(localStart, windowStart, focusRequester)
}
}
SelectionState.Selecting -> {
if (!event.pressed) {
manager.endSelection()
manager.snapSelection(mergedItems.value.items, linkMode)
return@awaitEachGesture
}
val windowPos = event.position + manager.viewportPosition
manager.updateDragFocus(windowPos, event.position.y)
event.consume()
manager.updateAutoScroll(windowPos.y > windowStart.y, windowPos.y, scope)
}
}
}
}
}
}
private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? {
val reversedY = listState.layoutInfo.viewportEndOffset - localY
val idx = listState.layoutInfo.visibleItemsInfo.find { item ->
reversedY >= item.offset && reversedY < item.offset + item.size
}?.index
return idx
}
class ItemSelection(
val highlightRange: IntRange?,
val positionModifier: Modifier,
val onTextLayoutResult: ((TextLayoutResult) -> Unit)?
)
// Sets up selection tracking for a text item: anchor/focus offset resolution,
// highlight range computation, and position/layout result capture.
@Composable
fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection {
val boundsState = remember { mutableStateOf<Rect?>(null) }
val layoutResultState = remember { mutableStateOf<TextLayoutResult?>(null) }
if (selectionManager != null && selectionIndex >= 0 && !isLive) {
val isAnchor = remember(selectionIndex) {
derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting }
}
LaunchedEffect(isAnchor.value) {
if (!isAnchor.value) return@LaunchedEffect
val bounds = boundsState.value ?: return@LaunchedEffect
val layout = layoutResultState.value ?: return@LaunchedEffect
val offset = layout.getOffsetForPosition(
Offset(selectionManager.anchorWindowX - bounds.left, selectionManager.anchorWindowY - bounds.top)
)
selectionManager.setAnchorOffset(offset)
}
val isFocus = remember(selectionIndex) {
derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting }
}
if (isFocus.value) {
LaunchedEffect(Unit) {
snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX }
.collect { (py, px) ->
val bounds = boundsState.value ?: return@collect
val layout = layoutResultState.value ?: return@collect
val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top))
val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1))
val ls = selectionManager.listState?.value
val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex }
val charRect = if (ls != null && itemInfo != null) {
val itemWindowY = (ls.layoutInfo.viewportEndOffset - itemInfo.offset - itemInfo.size).toFloat()
Rect(
left = bounds.left + charBox.left,
top = bounds.top + charBox.top - itemWindowY,
right = bounds.left + charBox.right,
bottom = bounds.top + charBox.bottom - itemWindowY
)
} else Rect.Zero
selectionManager.updateFocusOffset(offset, charRect)
}
}
}
}
val highlightRange = if (selectionManager != null && selectionIndex >= 0) {
remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value
} else null
val positionModifier = if (selectionManager != null) {
Modifier.onGloballyPositioned {
val pos = it.positionInWindow()
boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height)
}
} else Modifier
val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) {
{ layoutResultState.value = it }
} else null
return ItemSelection(highlightRange, positionModifier, onTextLayoutResult)
}
// Sets up full-item selection for emoji items (no character-level tracking).
@Composable
fun setupEmojiSelection(selectionManager: SelectionManager?, selectionIndex: Int, textLength: Int): Boolean {
if (selectionManager == null || selectionIndex < 0) return false
val isAnchor = remember(selectionIndex) {
derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting }
}
LaunchedEffect(isAnchor.value) {
if (!isAnchor.value) return@LaunchedEffect
selectionManager.setAnchorOffset(0)
}
val isFocus = remember(selectionIndex) {
derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting }
}
if (isFocus.value) {
LaunchedEffect(Unit) {
snapshotFlow { selectionManager.focusWindowY }
.collect { selectionManager.updateFocusOffset(textLength) }
}
}
return remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) != null } }.value
}
@Composable
fun SelectionCopyButton() {
val manager = LocalSelectionManager.current ?: return
val range = manager.range ?: return
if (manager.selectionState != SelectionState.Selected || manager.focusCharRect == Rect.Zero) return
val draggingDown = range.startIndex > range.endIndex || (range.startIndex == range.endIndex && range.startOffset < range.endOffset)
val gap = with(LocalDensity.current) { 4.dp.toPx() }
var buttonSize by remember { mutableStateOf(IntSize.Zero) }
Row(
Modifier
.offset { manager.copyButtonOffset(draggingDown, gap, buttonSize) }
.onSizeChanged { buttonSize = it }
.background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp))
.clip(RoundedCornerShape(20.dp))
.clickable { manager.onCopySelection?.invoke() }
.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)
}
}
@@ -1,9 +1,11 @@
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.text.TextStyle
@@ -12,6 +14,7 @@ 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 +22,20 @@ val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiF
@Composable
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) {
val emojiText = chatItem.content.text.trim()
val isSelected = setupEmojiSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, emojiText.length)
Column(
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(chatItem.content.text)
if (isSelected) {
Box(Modifier.background(SelectionHighlightColor)) {
EmojiText(chatItem.content.text)
}
} else {
EmojiText(chatItem.content.text)
}
CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}
@@ -21,7 +21,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
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 +368,11 @@ 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 chatInfo = chat.chatInfo
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true)
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) {
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 +381,9 @@ 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,
selectionRange = selection.highlightRange,
onTextLayoutResult = selection.onTextLayoutResult
)
}
}
@@ -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.*
@@ -55,6 +57,35 @@ private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString =
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
AnnotatedString(".", SpanStyle(fontWeight = w))
// 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 MarkdownText must match itemSegmentDisplayText above
@Composable
fun MarkdownText (
text: CharSequence,
@@ -77,7 +108,9 @@ fun MarkdownText (
onLinkLongClick: (link: String) -> Unit = {},
showViaProxy: Boolean = false,
showTimestamp: Boolean = true,
prefix: AnnotatedString? = null
prefix: AnnotatedString? = 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
@@ -126,19 +159,27 @@ fun MarkdownText (
)
}
if (formattedText == null) {
var selectableEnd = 0
val annotatedText = buildAnnotatedString {
inlineContent?.first?.invoke(this)
appendSender(this, sender, senderBold)
if (prefix != null) append(prefix)
if (text is String) append(text)
else if (text is AnnotatedString) append(text)
selectableEnd = 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())
val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) }
if (onTextLayoutResult != null) {
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult)
} else {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
}
} else {
var selectableEnd = 0
var hasLinks = false
var hasSecrets = false
var hasCommands = false
@@ -247,6 +288,7 @@ fun MarkdownText (
is Format.Unknown -> append(ft.text)
}
}
selectableEnd = this.length
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
@@ -255,9 +297,10 @@ fun MarkdownText (
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) }
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 = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
if (hasLinks) {
val withAnnotation: (String, (Range<String>) -> Unit) -> Unit = { tag, f ->
@@ -300,10 +343,15 @@ 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())
if (onTextLayoutResult != null) {
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult)
} else {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
}
}
}
}
@@ -314,6 +362,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,
@@ -356,7 +405,7 @@ fun ClickableText(
BasicText(
text = text,
modifier = modifier.then(pressIndicator),
modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
@@ -368,6 +417,42 @@ 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<TextLayoutResult?>(null) }
BasicText(
text = text,
modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)),
style = style,
maxLines = maxLines,
overflow = overflow,
onTextLayout = {
layoutResult.value = it
onTextLayoutResult?.invoke(it)
}
)
}
private fun selectionHighlight(selectionRange: IntRange?, textLength: Int, layoutResult: State<TextLayoutResult?>): Modifier =
if (selectionRange != null) {
Modifier.drawBehind {
layoutResult.value?.let { result ->
if (selectionRange.first <= selectionRange.last && selectionRange.last + 1 <= textLength) {
drawPath(result.getPathForRange(selectionRange.first, selectionRange.last + 1), SelectionHighlightColor)
}
}
}
} else Modifier
fun openBrowserAlert(uri: String, uriHandler: UriHandler) {
val (res, err) = sanitizeUri(uri)
if (res == null) {
@@ -446,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"