diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 7af40e57b1..dfebe93907 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -964,31 +964,13 @@ fun ChatLayout( AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chat != null) { val selectionManager = if (appPlatform.isDesktop) remember { SelectionManager() } else null - val selectionClipboard = if (appPlatform.isDesktop) LocalClipboardManager.current else null if (selectionManager != null) { LaunchedEffect(selectionManager) { snapshotFlow { selectionManager.selectionActive } .collect { chatsCtx.chatState.selectionActive = it } } } - Box( - Modifier - .fillMaxSize() - .then( - if (selectionManager != null) { - Modifier.onPreviewKeyEvent { event -> - if (selectionManager.captured.isNotEmpty() - && event.isCtrlPressed && event.key == Key.C - && event.type == KeyEventType.KeyDown - ) { - selectionClipboard?.setText(AnnotatedString(selectionManager.getSelectedText())) - true - } else false - } - } else Modifier - ), - contentAlignment = Alignment.BottomCenter - ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // disables scrolling to top of chat item on click inside the bubble CompositionLocalProvider( LocalSelectionManager provides selectionManager, @@ -2223,8 +2205,10 @@ fun BoxScope.ChatItemsList( } } + val selectionModifier = SelectionHandler(LocalSelectionManager.current, listState) + LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter), + Modifier.align(Alignment.BottomCenter).then(selectionModifier), state = listState.value, contentPadding = PaddingValues( top = topPaddingToContent, @@ -2296,13 +2280,6 @@ fun BoxScope.ChatItemsList( } } } - // Desktop text selection overlay — on top of LazyColumn in Z-order - if (appPlatform.isDesktop) { - val manager = LocalSelectionManager.current - if (manager != null) { - SelectionOverlay(manager, listState) - } - } FloatingButtons( chatsCtx, reversedChatItems, @@ -2324,20 +2301,6 @@ fun BoxScope.ChatItemsList( ) FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) - // Desktop selection copy button - if (appPlatform.isDesktop) { - val manager = LocalSelectionManager.current - if (manager != null && manager.captured.isNotEmpty() && !manager.isSelecting) { - val clipboard = LocalClipboardManager.current - SelectionCopyButton( - onCopy = { - clipboard.setText(AnnotatedString(manager.getSelectedText())) - manager.clearSelection() - } - ) - } - } - LaunchedEffect(Unit) { snapshotFlow { listState.value.isScrollInProgress } .collect { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectionOverlay.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectionOverlay.kt deleted file mode 100644 index 726ac84725..0000000000 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectionOverlay.kt +++ /dev/null @@ -1,137 +0,0 @@ -package chat.simplex.common.views.chat - -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.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.* -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalViewConfiguration -import kotlinx.coroutines.* - -private const val AUTO_SCROLL_ZONE_PX = 40f -private const val MIN_SCROLL_SPEED = 2f -private const val MAX_SCROLL_SPEED = 20f - -@Composable -fun SelectionOverlay( - selectionManager: SelectionManager, - listState: State, - modifier: Modifier = Modifier -) { - val touchSlop = LocalViewConfiguration.current.touchSlop - var positionInWindow by remember { mutableStateOf(Offset.Zero) } - var viewportTop by remember { mutableStateOf(0f) } - var viewportBottom by remember { mutableStateOf(0f) } - val scope = rememberCoroutineScope() - var autoScrollJob by remember { mutableStateOf(null) } - - // Re-evaluate selection on scroll (handles mouse wheel and auto-scroll) - LaunchedEffect(selectionManager) { - snapshotFlow { listState.value.firstVisibleItemScrollOffset } - .collect { - if (selectionManager.isSelecting) { - selectionManager.updateSelection( - selectionManager.lastPointerWindowY, - selectionManager.lastPointerWindowX - ) - } - } - } - - Box( - modifier = modifier - .fillMaxSize() - .onGloballyPositioned { - positionInWindow = it.positionInWindow() - val bounds = it.boundsInWindow() - viewportTop = bounds.top - viewportBottom = bounds.bottom - } - .pointerInput(selectionManager) { - awaitEachGesture { - val down = awaitPointerEvent(PointerEventPass.Initial) - val firstChange = down.changes.first() - val localStart = firstChange.position - val windowStart = localStart + positionInWindow - var totalDrag = Offset.Zero - var isDragging = false - - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - val change = event.changes.first() - - if (!change.pressed) { - autoScrollJob?.cancel() - autoScrollJob = null - if (isDragging) { - selectionManager.endSelection() - } - // Non-drag pointer up: do nothing to selection. - // Selection persists. Links/right-click work via pass-through. - // New drag clears old selection in startSelection(). - break - } - - totalDrag += change.positionChange() - - if (!isDragging && totalDrag.getDistance() > touchSlop) { - isDragging = true - selectionManager.startSelection(windowStart.y, windowStart.x) - change.consume() - } - - if (isDragging) { - val windowPos = change.position + positionInWindow - selectionManager.updateSelection(windowPos.y, windowPos.x) - 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 && selectionManager.isSelecting) { - val curEdge = if (draggingDown) { - viewportBottom - selectionManager.lastPointerWindowY - } else { - selectionManager.lastPointerWindowY - viewportTop - } - if (curEdge >= AUTO_SCROLL_ZONE_PX) break - - val speed = lerp( - MIN_SCROLL_SPEED, MAX_SCROLL_SPEED, - 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f) - ) - // reverseLayout = true: - // drag down (toward newer) = scrollBy(-speed) - // drag up (toward older) = scrollBy(speed) - // VERIFY EMPIRICALLY — if wrong, flip sign - listState.value.scrollBy(if (draggingDown) -speed else speed) - delay(16) - } - } - } else if (!shouldAutoScroll) { - autoScrollJob?.cancel() - autoScrollJob = null - } - } - } - } - } - ) -} - -private fun lerp(start: Float, stop: Float, fraction: Float): Float = - start + (stop - start) * fraction 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 480cfe6908..2c0ae3e336 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 @@ -3,20 +3,36 @@ 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.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.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.dp +import chat.simplex.common.platform.appPlatform 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) @@ -192,11 +208,156 @@ fun calculateRangeForElement( val LocalSelectionManager = staticCompositionLocalOf { null } +private const val AUTO_SCROLL_ZONE_PX = 40f +private const val MIN_SCROLL_SPEED = 2f +private const val MAX_SCROLL_SPEED = 20f + +/** + * Composable that installs selection effects and returns a Modifier for the LazyColumn. + * Also emits the copy button UI in the BoxScope. + */ @Composable -fun SelectionCopyButton(onCopy: () -> Unit) { +fun BoxScope.SelectionHandler( + manager: SelectionManager?, + listState: State +): Modifier { + if (manager == null || !appPlatform.isDesktop) return Modifier + + val touchSlop = LocalViewConfiguration.current.touchSlop + val clipboard = LocalClipboardManager.current + val focusRequester = remember { FocusRequester() } + var positionInWindow by remember { mutableStateOf(Offset.Zero) } + var viewportTop by remember { mutableStateOf(0f) } + var viewportBottom by remember { mutableStateOf(0f) } + val scope = rememberCoroutineScope() + var autoScrollJob by remember { mutableStateOf(null) } + + // Re-evaluate selection on scroll (handles mouse wheel and auto-scroll) + LaunchedEffect(manager) { + snapshotFlow { listState.value.firstVisibleItemScrollOffset } + .collect { + if (manager.isSelecting) { + manager.updateSelection( + manager.lastPointerWindowY, + manager.lastPointerWindowX + ) + } + } + } + + // Copy button + if (manager.captured.isNotEmpty() && !manager.isSelecting) { + SelectionCopyButton( + onCopy = { + clipboard.setText(AnnotatedString(manager.getSelectedText())) + } + ) + } + + return Modifier + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { event -> + if (manager.captured.isNotEmpty() + && (event.isCtrlPressed || event.isMetaPressed) + && event.key == Key.C + && event.type == KeyEventType.KeyDown + ) { + clipboard.setText(AnnotatedString(manager.getSelectedText())) + true + } else false + } + .onGloballyPositioned { + positionInWindow = it.positionInWindow() + val bounds = it.boundsInWindow() + viewportTop = bounds.top + viewportBottom = bounds.bottom + } + .pointerInput(manager) { + awaitEachGesture { + val down = awaitPointerEvent(PointerEventPass.Initial) + val firstChange = down.changes.first() + val localStart = firstChange.position + val windowStart = localStart + positionInWindow + var totalDrag = Offset.Zero + var isDragging = false + + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.first() + + if (!change.pressed) { + autoScrollJob?.cancel() + autoScrollJob = null + if (isDragging) { + manager.endSelection() + } else if (manager.captured.isNotEmpty()) { + // Click without drag clears selection + manager.clearSelection() + } + break + } + + totalDrag += change.positionChange() + + if (!isDragging && totalDrag.getDistance() > touchSlop) { + isDragging = true + manager.startSelection(windowStart.y, windowStart.x) + try { focusRequester.requestFocus() } catch (_: Exception) {} + change.consume() + } + + if (isDragging) { + val windowPos = change.position + positionInWindow + manager.updateSelection(windowPos.y, windowPos.x) + 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.isSelecting) { + val curEdge = if (draggingDown) { + viewportBottom - manager.lastPointerWindowY + } else { + manager.lastPointerWindowY - viewportTop + } + if (curEdge >= AUTO_SCROLL_ZONE_PX) break + + val speed = lerp( + MIN_SCROLL_SPEED, MAX_SCROLL_SPEED, + 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f) + ) + // reverseLayout = true: + // drag down (toward newer) = scrollBy(-speed) + // drag up (toward older) = scrollBy(speed) + listState.value.scrollBy(if (draggingDown) -speed else speed) + delay(16) + } + } + } else if (!shouldAutoScroll) { + autoScrollJob?.cancel() + autoScrollJob = null + } + } + } + } + } +} + +@Composable +private fun BoxScope.SelectionCopyButton(onCopy: () -> Unit) { Row( Modifier - .padding(8.dp) + .align(Alignment.BottomCenter) + .padding(bottom = 8.dp) .background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp)) .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp)) .clickable { onCopy() } @@ -208,3 +369,6 @@ fun SelectionCopyButton(onCopy: () -> Unit) { Text(generalGetString(MR.strings.copy_verb), color = MaterialTheme.colors.primary) } } + +private fun lerp(start: Float, stop: Float, fraction: Float): Float = + start + (stop - start) * fraction