desktop: fix copying selected text in reports (#6863)

* desktop: fix copying selected text in reports

Text selection in report items rendered a red reason prefix (e.g. "Spam: ")
before the user's comment but dropped the prefix when copying, because
selection offsets are in rendered-text space while copy extraction was
operating on ci.text / ci.formattedText directly.

Introduce itemPrefixText(ci) as the single source of truth for the rendered
prefix, and drive both selection copy and snap-to-segment from a unified
itemDisplaySegments list that prepends the prefix as a leading segment.
This also fixes mention/link snapping on the selection boundary inside
report comments.

* desktop: scope report-selection fix to report items

Inline a prefix preamble + offset shift in selectedItemCopiedText and
snapOffset instead of routing every item through itemDisplaySegments.
Non-report items now run the original pre-PR loop unchanged; reports
emit the selected slice of the rendered prefix and shift body offsets
by prefix.length.

* desktop: simplify report selection arithmetic

Selection offsets are in display-text space, which already includes the
leading itemPrefixText for reports. Treat the prefix as the leading
display segment and seed displayOffset with prefix.length, instead of
shifting body offsets by prefix.length in the inner loop and gating
snapOffset on offset <= prefix.length.

The inner loop body of selectedItemCopiedText becomes identical to
pre-PR for non-reports (prefix is "" so displayOffset starts at 0),
and snapOffset reduces to a one-line seed change. Same fix, smaller
diff, fewer intermediate variables.

* desktop: drop helper, inline report-prefix in selection only

Revert itemPrefixText helper extraction (TextItemView.kt and
FramedItemView.kt back to pre-PR). Inline the report-prefix expression
at its two use sites in TextSelection.kt directly.

PR diff is now confined to TextSelection.kt: +13/-6.

* desktop: extract itemPrefixText, drop dead defensive code

Re-introduce itemPrefixText(ci) helper in TextItemView.kt and migrate all
four sites that compute the report prefix (FramedItemView,
ChatPreviewView, TextSelection x2). The prefix expression now has one
definition and one place to change.

Also drop the unreachable sel.first.coerceAtLeast(0) on the prefix-slice
append — selectedRange guarantees sel.first >= 0.

* plans: justify desktop fix for copying selected text in reports

Add 2026-05-08-fix-select-in-reports.md covering the problem, the offset
flow, the minimal structural change (seed displayOffset with prefix.length),
the itemPrefixText single-source-of-truth migration across the four
prefix sites, the verified edge-case table, and the rollback path.
This commit is contained in:
Narasimha-sc
2026-05-08 11:15:52 +00:00
committed by GitHub
parent 8c9c6471a7
commit 4d43f2d41c
5 changed files with 208 additions and 11 deletions
@@ -36,6 +36,7 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.item.itemPrefixText
import chat.simplex.common.views.chat.item.itemSegmentDisplayText
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
@@ -240,15 +241,22 @@ fun selectedRange(range: SelectionRange?, index: Int): IntRange? {
}
// Extracts source text for the selected range within one item.
// Selection offsets are in display-text space. For transformed segments (mentions, links with showText),
// the full source is emitted if any part is selected. For untransformed segments, partial substring works.
// Selection offsets are in display-text space (which includes any leading itemPrefixText).
// For transformed segments (mentions, links with showText), the full source is emitted if any part
// is selected. For untransformed segments, partial substring works.
private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: SimplexLinkMode): String {
val formattedText = ci.formattedText ?: return ci.text.substring(
sel.first.coerceAtMost(ci.text.length),
(sel.last + 1).coerceAtMost(ci.text.length)
)
val prefix = itemPrefixText(ci)
val sb = StringBuilder()
var displayOffset = 0
if (sel.first < prefix.length) {
sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1))
}
val formattedText = ci.formattedText ?: run {
val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length)
val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length)
if (start < end) sb.append(ci.text, start, end)
return sb.toString()
}
var displayOffset = prefix.length
for (ft in formattedText) {
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
val displayEnd = displayOffset + segDisplay.length
@@ -269,7 +277,7 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple
// Snaps a boundary offset to include full transformed segments.
private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int {
val formattedText = ci.formattedText ?: return offset
var displayOffset = 0
var displayOffset = itemPrefixText(ci).length
for (ft in formattedText) {
val segDisplay = itemSegmentDisplayText(ft, ci, linkMode)
val displayEnd = displayOffset + segDisplay.length
@@ -365,7 +365,7 @@ fun FramedItemView(
is MsgContent.MCReport -> {
val prefix = buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
append(itemPrefixText(ci))
}
}
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix)
@@ -85,6 +85,13 @@ fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String {
return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) }
}
// Display-only prefix rendered before ci.text (e.g. "Spam: " for reports).
// Renderers and selection code MUST share this string — otherwise selection offsets drift from screen.
fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) {
is MsgContent.MCReport -> if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: "
else -> ""
}
// Text transformations in MarkdownText must match itemSegmentDisplayText above
@Composable
fun MarkdownText (
@@ -255,11 +255,11 @@ fun ChatPreviewView(
ci.content.msgContent is MsgContent.MCChat -> null
else -> ci.formattedText
}
val prefix = when (val mc = ci.content.msgContent) {
val prefix = when (ci.content.msgContent) {
is MsgContent.MCReport ->
buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
append(itemPrefixText(ci))
}
}