mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-17 01:35:08 +00:00
refactor
This commit is contained in:
+109
-28
@@ -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(
|
||||
|
||||
+4
-68
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user