This commit is contained in:
Evgeny @ SimpleX Chat
2026-04-01 23:13:24 +00:00
parent c2ae0d9937
commit c3eb8b5eb4
2 changed files with 113 additions and 96 deletions
@@ -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<LazyListState>
): 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<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)
}
@Composable
fun SelectionCopyButton(modifier: Modifier = Modifier, onCopy: () -> Unit) {
Row(
@@ -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<Rect?>(null) }
val layoutResultState = remember { mutableStateOf<TextLayoutResult?>(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
)
}
}