This commit is contained in:
Evgeny @ SimpleX Chat
2026-03-31 10:37:42 +00:00
parent 781df088ed
commit 539f6db8e5
3 changed files with 170 additions and 180 deletions
@@ -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 {
@@ -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<LazyListState>,
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<Job?>(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
@@ -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<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 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<LazyListState>
): 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<Job?>(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