desktop: text selection

This commit is contained in:
Evgeny @ SimpleX Chat
2026-03-31 09:05:27 +00:00
parent 887fa70750
commit 52ddb21fe6
8 changed files with 564 additions and 24 deletions

View File

@@ -88,7 +88,8 @@ suspend fun processLoadedChat(
val (newIds, _) = mapItemsToIds(chat.chatItems)
val wasSize = newItems.size
val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination(
unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed
unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed,
selectionActive = chatState.selectionActive
)
val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0)
newItems.addAll(insertAt, chat.chatItems)
@@ -177,13 +178,14 @@ private fun removeDuplicatesAndModifySplitsOnBeforePagination(
newItems: SnapshotStateList<ChatItem>,
newIds: Set<Long>,
splits: StateFlow<List<Long>>,
visibleItemIndexesNonReversed: () -> IntRange
visibleItemIndexesNonReversed: () -> IntRange,
selectionActive: Boolean = false
): ModifiedSplits {
var oldUnreadSplitIndex: Int = -1
var newUnreadSplitIndex: Int = -1
val visibleItemIndexes = visibleItemIndexesNonReversed()
var lastSplitIndexTrimmed = -1
var allowedTrimming = true
var allowedTrimming = !selectionActive
var index = 0
/** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */
val trimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT .. newItems.size - TRIM_KEEP_COUNT

View File

@@ -202,7 +202,9 @@ data class ActiveChatState (
// exclusive
val unreadAfter: MutableStateFlow<Int> = MutableStateFlow(0),
// exclusive
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0)
val unreadAfterNewestLoaded: MutableStateFlow<Int> = MutableStateFlow(0),
// Desktop text selection: disable item eviction during active selection
@Volatile var selectionActive: Boolean = false
) {
fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List<ChatItem>) {
toItemId ?: return

View File

@@ -14,6 +14,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.*
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.layout.layoutId
@@ -962,11 +963,39 @@ fun ChatLayout(
val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null }
AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) {
if (chat != null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
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
) {
// disables scrolling to top of chat item on click inside the bubble
CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec {
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f
}) {
CompositionLocalProvider(
LocalSelectionManager provides selectionManager,
LocalBringIntoViewSpec provides object : BringIntoViewSpec {
override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f
}
) {
ChatItemsList(
chatsCtx, remoteHostId, chat, unreadCount, composeState, composeViewHeight, searchValue,
useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports,
@@ -1893,7 +1922,7 @@ fun BoxScope.ChatItemsList(
}
false
}
val swipeableModifier = SwipeToDismissModifier(
val swipeableModifier = if (appPlatform.isDesktop) Modifier else SwipeToDismissModifier(
state = dismissState,
directions = setOf(DismissDirection.EndToStart),
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
@@ -2267,6 +2296,13 @@ 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,
@@ -2288,6 +2324,20 @@ 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 {

View File

@@ -0,0 +1,137 @@
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

View File

@@ -0,0 +1,210 @@
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.layout.*
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.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
val SelectionHighlightColor = Color(0x4D0066FF)
@Stable
data class SelectionCoords(
val startY: Float,
val startX: Float,
val endY: Float,
val endX: Float
) {
val isReversed: Boolean get() = startY > endY
val topY: Float get() = minOf(startY, endY)
val bottomY: Float get() = maxOf(startY, endY)
val topX: Float get() = if (isReversed) endX else startX
val bottomX: Float get() = if (isReversed) startX else endX
}
data class CapturedText(
val itemId: Long,
val yPosition: Float,
val highlightRange: IntRange,
val text: String
)
interface SelectionParticipant {
val itemId: Long
fun getYBounds(): ClosedFloatingPointRange<Float>?
fun getTextLayoutResult(): TextLayoutResult?
fun getSelectableEnd(): Int
fun getAnnotatedText(): String
fun calculateHighlightRange(coords: SelectionCoords): IntRange?
}
class SelectionManager {
var coords by mutableStateOf<SelectionCoords?>(null)
private set
var isSelecting by mutableStateOf(false)
private set
val selectionActive: Boolean get() = coords != null
var lastPointerWindowY: Float = 0f
private set
var lastPointerWindowX: Float = 0f
private set
private val participants = mutableListOf<SelectionParticipant>()
val captured = mutableStateMapOf<Long, CapturedText>()
fun register(participant: SelectionParticipant) {
participants.add(participant)
coords?.let { recomputeParticipant(participant, it) }
}
fun unregister(participant: SelectionParticipant) {
participants.remove(participant)
}
fun startSelection(startY: Float, startX: Float) {
coords = SelectionCoords(startY, startX, startY, startX)
isSelecting = true
lastPointerWindowY = startY
lastPointerWindowX = startX
captured.clear()
}
fun updateSelection(endY: Float, endX: Float) {
val current = coords ?: return
coords = current.copy(endY = endY, endX = endX)
lastPointerWindowY = endY
lastPointerWindowX = endX
recomputeAll()
}
fun endSelection() {
isSelecting = false
}
fun clearSelection() {
coords = null
isSelecting = false
captured.clear()
}
private fun recomputeAll() {
val c = coords ?: return
val visibleInRange = mutableMapOf<Long, SelectionParticipant>()
val visibleOutOfRange = mutableSetOf<Long>()
for (p in participants) {
val bounds = p.getYBounds()
if (bounds != null && bounds.start <= c.bottomY && bounds.endInclusive >= c.topY) {
visibleInRange[p.itemId] = p
} else {
visibleOutOfRange.add(p.itemId)
}
}
visibleOutOfRange.forEach { captured.remove(it) }
for ((_, p) in visibleInRange) {
recomputeParticipant(p, c)
}
}
private fun recomputeParticipant(participant: SelectionParticipant, coords: SelectionCoords) {
val bounds = participant.getYBounds() ?: return
val highlightRange = participant.calculateHighlightRange(coords) ?: return
val selectableEnd = participant.getSelectableEnd()
val clampedStart = highlightRange.first.coerceIn(0, selectableEnd)
val clampedEnd = highlightRange.last.coerceIn(0, selectableEnd)
if (clampedStart >= clampedEnd) return
val annotatedText = participant.getAnnotatedText()
val text = if (clampedEnd <= annotatedText.length) {
annotatedText.substring(clampedStart, clampedEnd)
} else {
annotatedText.substring(clampedStart.coerceAtMost(annotatedText.length))
}
captured[participant.itemId] = CapturedText(
itemId = participant.itemId,
yPosition = bounds.start,
highlightRange = clampedStart until clampedEnd,
text = text
)
}
fun getSelectedText(): String {
return captured.values
.sortedBy { it.yPosition }
.joinToString("\n") { it.text }
}
fun getHighlightRange(itemId: Long): IntRange? {
return captured[itemId]?.highlightRange
}
}
fun calculateRangeForElement(
bounds: Rect?,
layout: TextLayoutResult?,
selectableEnd: Int,
coords: SelectionCoords
): IntRange? {
bounds ?: return null
layout ?: return null
if (selectableEnd <= 0) return null
val isFirst = bounds.top <= coords.topY && bounds.bottom > coords.topY
val isLast = bounds.top < coords.bottomY && bounds.bottom >= coords.bottomY
val isMiddle = bounds.top > coords.topY && bounds.bottom < coords.bottomY
return when {
isMiddle -> 0 until selectableEnd
isFirst && isLast -> {
val s = layout.getOffsetForPosition(Offset(coords.topX - bounds.left, coords.topY - bounds.top))
val e = layout.getOffsetForPosition(Offset(coords.bottomX - bounds.left, coords.bottomY - bounds.top))
minOf(s, e) until maxOf(s, e)
}
isFirst -> {
val s = layout.getOffsetForPosition(Offset(coords.topX - bounds.left, coords.topY - bounds.top))
s until selectableEnd
}
isLast -> {
val e = layout.getOffsetForPosition(Offset(coords.bottomX - bounds.left, coords.bottomY - bounds.top))
0 until e
}
else -> null
}
}
val LocalSelectionManager = staticCompositionLocalOf<SelectionManager?> { null }
@Composable
fun SelectionCopyButton(onCopy: () -> Unit) {
Row(
Modifier
.padding(8.dp)
.background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp))
.clickable { onCopy() }
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_content_copy), null, Modifier.size(16.dp), tint = MaterialTheme.colors.primary)
Spacer(Modifier.width(6.dp))
Text(generalGetString(MR.strings.copy_verb), color = MaterialTheme.colors.primary)
}
}

View File

@@ -1,17 +1,22 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.MREmojiChar
import chat.simplex.common.ui.theme.EmojiFont
import chat.simplex.common.views.chat.*
import java.sql.Timestamp
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont)
@@ -19,11 +24,43 @@ val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiF
@Composable
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) {
val selectionManager = LocalSelectionManager.current
val boundsState = remember { mutableStateOf<Rect?>(null) }
val currentEmojiText = rememberUpdatedState(chatItem.content.text.trim())
if (selectionManager != null) {
val participant = remember(chatItem.id) {
object : SelectionParticipant {
override val itemId = chatItem.id
override fun getYBounds() = boundsState.value?.let { it.top..it.bottom }
override fun getTextLayoutResult() = null
override fun getSelectableEnd() = currentEmojiText.value.length
override fun getAnnotatedText() = currentEmojiText.value
override fun calculateHighlightRange(coords: SelectionCoords): IntRange? {
val bounds = boundsState.value ?: return null
return if (bounds.top <= coords.bottomY && bounds.bottom >= coords.topY)
0 until currentEmojiText.value.length
else null
}
}
}
DisposableEffect(participant) {
selectionManager.register(participant)
onDispose { selectionManager.unregister(participant) }
}
}
val isSelected = selectionManager?.getHighlightRange(chatItem.id) != null
Column(
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
Modifier
.padding(vertical = 8.dp, horizontal = 12.dp)
.onGloballyPositioned { boundsState.value = it.boundsInWindow() },
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(chatItem.content.text)
Box(if (isSelected) Modifier.background(SelectionHighlightColor) else Modifier) {
EmojiText(chatItem.content.text)
}
CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
}

View File

@@ -20,8 +20,9 @@ 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.Rect
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.Dispatchers
@@ -368,9 +369,46 @@ fun CIMarkdownText(
showTimestamp: Boolean,
prefix: AnnotatedString? = null
) {
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) {
val chatInfo = chat.chatInfo
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
val selectionManager = LocalSelectionManager.current
val boundsState = remember { mutableStateOf<Rect?>(null) }
val layoutResultState = remember { mutableStateOf<TextLayoutResult?>(null) }
val selectableEnd = remember { mutableIntStateOf(Int.MAX_VALUE) }
val annotatedTextState = remember { mutableStateOf("") }
val chatInfo = chat.chatInfo
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
if (selectionManager != null && ci.meta.isLive != true) {
val currentText = rememberUpdatedState(text)
val participant = remember(ci.id) {
object : SelectionParticipant {
override val itemId = ci.id
override fun getYBounds() = boundsState.value?.let { it.top..it.bottom }
override fun getTextLayoutResult() = layoutResultState.value
override fun getSelectableEnd() = selectableEnd.intValue
override fun getAnnotatedText(): String {
val at = annotatedTextState.value
return if (at.isNotEmpty()) at else currentText.value
}
override fun calculateHighlightRange(coords: SelectionCoords) =
calculateRangeForElement(
boundsState.value, layoutResultState.value,
selectableEnd.intValue, coords
)
}
}
DisposableEffect(participant) {
selectionManager.register(participant)
onDispose { selectionManager.unregister(participant) }
}
}
val highlightRange = selectionManager?.getHighlightRange(ci.id)
Box(
Modifier
.padding(vertical = 7.dp, horizontal = 12.dp)
.onGloballyPositioned { boundsState.value = it.boundsInWindow() }
) {
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,
@@ -379,7 +417,11 @@ fun CIMarkdownText(
chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId
else -> null
},
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix,
selectableEnd = selectableEnd,
annotatedTextState = annotatedTextState,
selectionRange = highlightRange,
onTextLayoutResult = { layoutResultState.value = it }
)
}
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.platform.*
@@ -22,6 +23,7 @@ import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.chat.SelectionHighlightColor
import chat.simplex.common.views.helpers.*
import chat.simplex.res.*
import kotlinx.coroutines.*
@@ -77,7 +79,11 @@ fun MarkdownText (
onLinkLongClick: (link: String) -> Unit = {},
showViaProxy: Boolean = false,
showTimestamp: Boolean = true,
prefix: AnnotatedString? = null
prefix: AnnotatedString? = null,
selectableEnd: MutableIntState? = null,
annotatedTextState: MutableState<String>? = null,
selectionRange: IntRange? = null,
onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null
) {
val textLayoutDirection = remember (text) {
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
@@ -132,12 +138,18 @@ fun MarkdownText (
if (prefix != null) append(prefix)
if (text is String) append(text)
else if (text is AnnotatedString) append(text)
selectableEnd?.intValue = this.length
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
annotatedTextState?.value = annotatedText.text
if (meta?.isLive == true) {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
} else {
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = selectionRange, onTextLayoutResult = onTextLayoutResult)
}
} else {
var hasLinks = false
var hasSecrets = false
@@ -247,6 +259,7 @@ fun MarkdownText (
is Format.Unknown -> append(ft.text)
}
}
selectableEnd?.intValue = this.length
if (meta?.isLive == true) {
append(typingIndicator(meta.recent, typingIdx))
}
@@ -255,9 +268,10 @@ fun MarkdownText (
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
annotatedTextState?.value = annotatedText.text
if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) {
val icon = remember { mutableStateOf(PointerIcon.Default) }
ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
ClickableText(annotatedText, style = style, selectionRange = selectionRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
if (hasLinks) {
val withAnnotation: (String, (Range<String>) -> Unit) -> Unit = { tag, f ->
@@ -300,10 +314,11 @@ fun MarkdownText (
annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset)
|| annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
|| annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset)
}
},
onTextLayout = { onTextLayoutResult?.invoke(it) }
)
} else {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf())
SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = selectionRange, onTextLayoutResult = onTextLayoutResult)
}
}
}
@@ -314,6 +329,7 @@ fun ClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
selectionRange: IntRange? = null,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
@@ -354,9 +370,19 @@ fun ClickableText(
}
}
val selectionHighlight = if (selectionRange != null) {
Modifier.drawBehind {
layoutResult.value?.let { result ->
if (selectionRange.first < selectionRange.last && selectionRange.last <= text.length) {
drawPath(result.getPathForRange(selectionRange.first, selectionRange.last), SelectionHighlightColor)
}
}
}
} else Modifier
BasicText(
text = text,
modifier = modifier.then(pressIndicator),
modifier = modifier.then(selectionHighlight).then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
@@ -368,6 +394,40 @@ fun ClickableText(
)
}
@Composable
private fun SelectableText(
text: AnnotatedString,
style: TextStyle,
modifier: Modifier = Modifier,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
selectionRange: IntRange? = null,
onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val highlight = if (selectionRange != null) {
Modifier.drawBehind {
layoutResult.value?.let { result ->
if (selectionRange.first < selectionRange.last && selectionRange.last <= text.length) {
drawPath(result.getPathForRange(selectionRange.first, selectionRange.last), SelectionHighlightColor)
}
}
}
} else Modifier
BasicText(
text = text,
modifier = modifier.then(highlight),
style = style,
maxLines = maxLines,
overflow = overflow,
onTextLayout = {
layoutResult.value = it
onTextLayoutResult?.invoke(it)
}
)
}
fun openBrowserAlert(uri: String, uriHandler: UriHandler) {
val (res, err) = sanitizeUri(uri)
if (res == null) {