From c3eb8b5eb47e9f7999da15f75f729172e812890c Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:06:26 +0000 Subject: [PATCH] refactor --- .../common/views/chat/TextSelection.kt | 137 ++++++++++++++---- .../common/views/chat/item/FramedItemView.kt | 72 +-------- 2 files changed, 113 insertions(+), 96 deletions(-) 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 824c4f4166..e2a8f0d54c 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalClipboardManager 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 @@ -138,23 +139,32 @@ class SelectionManager { } } +// 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 } } @@ -278,41 +288,39 @@ fun BoxScope.SelectionHandler( 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 && manager.selectionState == SelectionState.Selecting) { - val curEdge = if (draggingDown) { - viewportBottom - manager.focusWindowY - } else { - manager.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 - listState.value.scrollBy(if (draggingDown) -speed else speed) - delay(16) - } - } - } else if (!shouldAutoScroll) { - autoScrollJob?.cancel() - autoScrollJob = null - } + autoScrollJob = updateAutoScroll( + draggingDown, windowPos.y, viewportTop, viewportBottom, + autoScrollJob, scope, manager, listState + ) } } } } } +private fun updateAutoScroll( + draggingDown: Boolean, pointerY: Float, viewportTop: Float, viewportBottom: Float, + currentJob: Job?, scope: CoroutineScope, manager: SelectionManager, listState: State +): Job? { + val edgeDistance = if (draggingDown) viewportBottom - pointerY else pointerY - viewportTop + if (edgeDistance !in 0f..AUTO_SCROLL_ZONE_PX) { + currentJob?.cancel() + return null + } + if (currentJob?.isActive == true) return currentJob + return scope.launch { + while (isActive && manager.selectionState == SelectionState.Selecting) { + val curEdge = if (draggingDown) viewportBottom - manager.focusWindowY else manager.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 + listState.value.scrollBy(if (draggingDown) -speed else speed) + delay(16) + } + } +} + private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { val reversedY = listState.layoutInfo.viewportEndOffset - localY val idx = listState.layoutInfo.visibleItemsInfo.find { item -> @@ -322,6 +330,79 @@ private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { 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(null) } + val layoutResultState = remember { mutableStateOf(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) +} + @Composable fun SelectionCopyButton(modifier: Modifier = Modifier, onCopy: () -> Unit) { Row( 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 fec797dfa6..8aab0bbbb6 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,10 +20,6 @@ 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.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -372,71 +368,11 @@ fun CIMarkdownText( showTimestamp: Boolean, prefix: AnnotatedString? = null ) { - val selectionManager = LocalSelectionManager.current - val selectionIndex = LocalItemContext.current.selectionIndex 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) - val boundsState = remember { mutableStateOf(null) } - val layoutResultState = remember { mutableStateOf(null) } - - if (selectionManager != null && selectionIndex >= 0 && ci.meta.isLive != true) { - 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) - ) - Log.e(TAG, "anchorOffset idx=$selectionIndex offset=$offset bounds=$bounds pointer=(${selectionManager.anchorWindowX},${selectionManager.anchorWindowY})") - 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, // absolute window X - top = bounds.top + charBox.top - itemWindowY, // relative to item Y - right = bounds.left + charBox.right, // absolute window X - bottom = bounds.top + charBox.bottom - itemWindowY // relative to item Y - ) - } else Rect.Zero - Log.e(TAG, "focusOffset idx=$selectionIndex offset=$offset bounds=$bounds pointer=($px,$py) charRect=$charRect") - selectionManager.updateFocusOffset(offset, charRect) - } - } - } - } - - val highlightRange = if (selectionManager != null && selectionIndex >= 0) { - remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value - } else null - if (highlightRange != null) Log.e(TAG, "highlight idx=$selectionIndex range=$highlightRange") - - Box( - Modifier - .padding(vertical = 7.dp, horizontal = 12.dp) - .then(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) - ) { + 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, @@ -446,8 +382,8 @@ fun CIMarkdownText( else -> null }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, - selectionRange = highlightRange, - onTextLayoutResult = if (selectionManager != null) { { layoutResultState.value = it } } else null + selectionRange = selection.highlightRange, + onTextLayoutResult = selection.onTextLayoutResult ) } }