From 057623e3ab40e6e6475a3369d4519031d8a632dc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Jul 2026 22:03:28 -0400 Subject: [PATCH 1/2] feat(highlights): render NIP-84 quotes with a highlighter-pen marker The renderer never drew a highlight. It synthesised a markdown string -- blockquote each line with "> ", wrap the quoted span in "**" -- and handed it to the rich-text viewer, so a highlight arrived as bold text. That also meant the quoted article prose was parsed as markdown, so any *, _, # or [ in it was interpreted as formatting rather than shown. Drop the markdown round-trip and paint the marker behind the glyphs. The stroke is drawn per visual line from the TextLayoutResult, so it follows soft wraps and stops at real glyph edges. Per-line rounded rects rather than SpanStyle(background), which can only ever be a hard full-line-height rectangle -- that is what buys the rounded pen ends. Size the stroke from the baseline and font size, not the line box, so leading and stroke weight stay independent knobs. Along the way: - Locate the quote as an index range instead of context.replace(), which marked every occurrence when a quote repeated. Use the W3C TextQuoteSelector prefix -- already on the event, previously ignored -- to disambiguate. - Restore 1.35em leading. The markdown path forced 1.5em via MarkdownTextStyle; the ambient bodyLarge sets no lineHeight at all, so rendering plain text inherited the font's intrinsic ~1.2em. - Indent the source attribution by the quote's own 15.dp so it lines up with the text rather than the bar, and space the comment, quote and attribution 8.dp apart -- they were flush at 0.dp. - Clamp the stroke to the column so it cannot be clipped on full-width lines. Light keeps a near-opaque yellow with dark glyphs reading through it. Dark cannot do that, so it gets a translucent amber that glows rather than covers. Not derived from the user's accent: a highlighter reads as yellow. The quoted passage no longer routes through TranslatableRichTextViewer, so it loses its auto-translate affordance; drawing the marker requires owning the text layout. The author's own comment above the quote keeps it. Verified on device in both themes. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/ui/note/types/Highlight.kt | 46 ++-- .../model/highlights/HighlightQuote.kt | 92 +++++++ .../commons/ui/note/HighlightedQuote.kt | 234 ++++++++++++++++++ .../commons/ui/theme/ThemeExtensions.kt | 30 +++ .../model/highlights/HighlightQuoteTest.kt | 92 +++++++ 5 files changed, 465 insertions(+), 29 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuote.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HighlightedQuote.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuoteTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index 795199f211..a54ab75d8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -25,7 +25,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,7 +42,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.highlights.HighlightQuote import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary +import com.vitorpamplona.amethyst.commons.ui.note.HighlightQuoteIndent +import com.vitorpamplona.amethyst.commons.ui.note.HighlightQuoteSpacing +import com.vitorpamplona.amethyst.commons.ui.note.HighlightedQuote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote @@ -65,7 +72,6 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.net.URL -import java.util.UUID @Composable fun RenderHighlight( @@ -180,45 +186,27 @@ fun DisplayHighlight( accountViewModel = accountViewModel, nav = nav, ) + Spacer(Modifier.height(HighlightQuoteSpacing)) } val quote = - remember(highlight) { - val uuid = UUID.randomUUID().toString() - if (context != null) { - if (context.contains(highlight)) { - val cleanContext = context.replace(highlight, uuid) - - val quotedContext = cleanContext.split("\n").joinToString("\n") { "> ${it.removeSuffix(" ")}" } - - val quotedSplit = highlight.split("\n") - val quotedHighlight = quotedSplit.joinToString("\n >") { "**${it.removeSuffix(" ")}**" } - - quotedContext.replace(uuid, quotedHighlight) - } else { - highlight.split("\n").joinToString("\n") { "> ${it.removeSuffix(" ")}" } - } - } else { - highlight.split("\n").joinToString("\n") { "> ${it.removeSuffix(" ")}" } - } + remember(highlight, context, textFragmentPrefix) { + HighlightQuote.of(highlight, context, textFragmentPrefix) } - TranslatableRichTextViewer( - content = quote, - canPreview = canPreview && !makeItShort, - quotesLeft = quotesLeft, + HighlightedQuote( + text = quote.text, + highlight = quote.marked, modifier = Modifier.fillMaxWidth(), - tags = EmptyTagList, - backgroundColor = backgroundColor, - id = quote, - callbackUri = null, - accountViewModel = accountViewModel, - nav = nav, ) + Spacer(Modifier.height(HighlightQuoteSpacing)) + val spaceWidth = measureSpaceWidth(textStyle = LocalTextStyle.current) + // Indented to sit under the quote text, not under the quote bar. FlowRow( + modifier = Modifier.padding(start = HighlightQuoteIndent), horizontalArrangement = Arrangement.spacedBy(spaceWidth), verticalArrangement = Arrangement.Center, ) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuote.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuote.kt new file mode 100644 index 0000000000..2b7a394cc8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuote.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.highlights + +/** + * What to render for a NIP-84 highlight: the passage to show, and the range inside it that + * the user actually marked. + * + * When the event carries a `context` tag the whole surrounding sentence is shown with the + * quote marked inside it. When it doesn't — or the quote can't be located in the context — + * [text] is the quote alone and [marked] is null, meaning "mark all of it". + */ +data class HighlightQuote( + val text: String, + val marked: IntRange?, +) { + companion object { + /** + * @param highlight the `content` of the highlight event — the marked passage. + * @param context the optional surrounding text the highlight was taken from. + * @param prefix the W3C TextQuoteSelector prefix, used to pick the right occurrence + * when the quote appears more than once in the context. + */ + fun of( + highlight: String, + context: String?, + prefix: String? = null, + ): HighlightQuote { + if (highlight.isEmpty()) return HighlightQuote(context.orEmpty(), null) + if (context.isNullOrBlank()) return HighlightQuote(highlight, null) + + val at = locate(context, highlight, prefix) + return if (at != null) { + HighlightQuote(context, at until (at + highlight.length)) + } else { + // Context that doesn't actually contain the quote is worse than no context: + // it would mark nothing and silently show text the user never highlighted. + HighlightQuote(highlight, null) + } + } + + /** + * Finds [highlight] inside [context], preferring the occurrence whose preceding text + * ends with [prefix]. Highlighters emit that prefix precisely so a repeated quote can + * be pinned to the right spot. + */ + private fun locate( + context: String, + highlight: String, + prefix: String?, + ): Int? { + val occurrences = occurrencesOf(context, highlight) + if (occurrences.isEmpty()) return null + if (occurrences.size == 1 || prefix.isNullOrBlank()) return occurrences.first() + + val tail = prefix.trimEnd() + return occurrences.firstOrNull { context.substring(0, it).trimEnd().endsWith(tail) } + ?: occurrences.first() + } + + private fun occurrencesOf( + context: String, + highlight: String, + ): List { + val found = mutableListOf() + var from = context.indexOf(highlight) + while (from >= 0) { + found.add(from) + from = context.indexOf(highlight, from + 1) + } + return found + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HighlightedQuote.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HighlightedQuote.kt new file mode 100644 index 0000000000..c2e6c2482f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HighlightedQuote.kt @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.note + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.ui.theme.highlightContextText +import com.vitorpamplona.amethyst.commons.ui.theme.highlightMarker +import com.vitorpamplona.amethyst.commons.ui.theme.highlightMarkerText +import com.vitorpamplona.amethyst.commons.ui.theme.highlightQuoteBar + +/** + * Leading for the quoted passage. The ambient `bodyLarge` sets no lineHeight at all, so + * inheriting it drops to the font's intrinsic ~1.2em and reads as cramped; the markdown + * blockquote this component replaced used 1.5em, which reads as too airy for a quote sitting + * inside a feed card. This splits the difference. + */ +private val QuoteLineHeight = 1.35.em + +/** + * Stroke height above and below the baseline, as a fraction of font size. Deliberately keyed + * to the glyphs rather than to the line box: leading is a text-spacing decision, and letting + * it drive stroke thickness would turn a generous [QuoteLineHeight] into a solid slab. + */ +private val MarkerAscentRatio = 0.82f +private val MarkerDescentRatio = 0.22f + +/** How far the marker bleeds past the first and last glyph, like a real pen stroke. */ +private val MarkerHorizontalBleed = 2.5.dp + +/** Grows the stroke on every side, so the wash clears the glyphs instead of hugging them. */ +private val MarkerGrowth = 2.dp + +private val MarkerCornerRadius = 3.dp + +private val QuoteBarWidth = 3.dp + +private val QuoteBarGap = 12.dp + +/** + * Distance from the quote block's left edge to the quote text itself. Anything rendered as + * part of the quote — the source attribution below it, most importantly — must be indented by + * this much to line up with the text rather than with the bar. + */ +val HighlightQuoteIndent = QuoteBarWidth + QuoteBarGap + +/** Breathing room between the quote block and whatever sits above or below it. */ +val HighlightQuoteSpacing = 8.dp + +/** + * A NIP-84 highlight: the quoted passage painted under a highlighter-pen wash, optionally + * embedded in the surrounding context it was taken from. + * + * The marker is drawn per visual line from the laid-out text, so it follows soft wraps and + * stops at the real glyph edges instead of stretching to the full paragraph width. Drawing it + * behind the glyphs (rather than as a `SpanStyle` background) is what buys the rounded pen + * ends and the vertical inset — a span background can only ever be a hard, full-line-height + * rectangle. + * + * @param text the full passage to render, context included. + * @param highlight the range within [text] that was highlighted, or null to mark all of it. + */ +@Composable +fun HighlightedQuote( + text: String, + highlight: IntRange?, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current.copy(lineHeight = QuoteLineHeight), +) { + val barColor = MaterialTheme.colorScheme.highlightQuoteBar + + // IntrinsicSize.Min lets the bar match the height of the text beside it. + Row(modifier = modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + Spacer( + Modifier + .width(QuoteBarWidth) + .fillMaxHeight() + .clip(RoundedCornerShape(QuoteBarWidth / 2)) + .background(barColor), + ) + Spacer(Modifier.width(QuoteBarGap)) + HighlightedQuoteText( + text = text, + highlight = highlight, + modifier = Modifier.fillMaxWidth(), + style = style, + ) + } +} + +@Composable +fun HighlightedQuoteText( + text: String, + highlight: IntRange?, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current.copy(lineHeight = QuoteLineHeight), +) { + val markerColor = MaterialTheme.colorScheme.highlightMarker + val markedText = MaterialTheme.colorScheme.highlightMarkerText + val contextText = MaterialTheme.colorScheme.highlightContextText + + val start = highlight?.first?.coerceIn(0, text.length) ?: 0 + val end = highlight?.let { (it.last + 1).coerceIn(start, text.length) } ?: text.length + + val annotated = + remember(text, start, end, markedText, contextText) { + buildAnnotatedString { + append(text) + if (start > 0 || end < text.length) { + addStyle(SpanStyle(color = contextText), 0, text.length) + } + addStyle(SpanStyle(color = markedText, fontWeight = FontWeight.Medium), start, end) + } + } + + var layout by remember { mutableStateOf(null) } + + Text( + text = annotated, + style = style, + modifier = + modifier.drawBehind { + layout?.let { drawMarker(it, start, end, markerColor) } + }, + onTextLayout = { layout = it }, + ) +} + +/** + * Paints the pen stroke one visual line at a time. [TextLayoutResult.getPathForRange] would + * give the same coverage in one call, but returns a single un-roundable path — per-line round + * rects are what make it read as a marker rather than a selection. + */ +private fun DrawScope.drawMarker( + layout: TextLayoutResult, + start: Int, + end: Int, + color: Color, +) { + if (start >= end || end > layout.layoutInput.text.length) return + + val growth = MarkerGrowth.toPx() + val bleed = MarkerHorizontalBleed.toPx() + growth + val radius = CornerRadius(MarkerCornerRadius.toPx()) + + // The resolved style, so an inherited (Unspecified) font size still gives a real number. + val fontSize = layout.layoutInput.style.fontSize + val fontPx = if (fontSize.isSp) fontSize.toPx() else layout.layoutInput.density.run { 16.sp.toPx() } + + val firstLine = layout.getLineForOffset(start) + val lastLine = layout.getLineForOffset(end - 1) + + for (line in firstLine..lastLine) { + val lineStart = maxOf(start, layout.getLineStart(line)) + val lineEnd = minOf(end, layout.getLineEnd(line, visibleEnd = true)) + if (lineEnd <= lineStart) continue + + val left = layout.getHorizontalPosition(lineStart, usePrimaryDirection = true) + val right = layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true) + if (right <= left) continue + + // Anchored to the baseline so the stroke keeps the same weight whatever the leading is. + val baseline = layout.getLineBaseline(line) + val top = + (baseline - fontPx * MarkerAscentRatio - growth) + .coerceAtLeast(layout.getLineTop(line)) + val bottom = + (baseline + fontPx * MarkerDescentRatio + growth) + .coerceAtMost(layout.getLineBottom(line)) + if (bottom <= top) continue + + // A line that fills the column would otherwise bleed past its right edge and get + // clipped, leaving the stroke visibly squared off on exactly the longest lines. + val markLeft = (left - bleed).coerceAtLeast(0f) + val markRight = (right + bleed).coerceAtMost(size.width) + if (markRight <= markLeft) continue + + drawRoundRect( + color = color, + topLeft = Offset(markLeft, top), + size = Size(markRight - markLeft, bottom - top), + cornerRadius = radius, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt index c3709e12c4..225adc8b37 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt @@ -65,3 +65,33 @@ val ColorScheme.allGoodColor: Color /** Amber "warning / degraded" status color. Mirrors the Android app's warningColor. */ val ColorScheme.warningColor: Color get() = if (isLight) Color(0xFFFFCC00) else Color(0xFFF8DE22) + +/** + * Highlighter-pen wash painted behind NIP-84 highlighted text. + * + * Light themes get the classic near-opaque yellow marker: dark glyphs read straight + * through it, exactly like a pen on paper. Dark themes cannot do that — a bright + * yellow slab behind light text is unreadable, and inverting to dark glyphs makes the + * quote fight the card it sits on — so they get a translucent amber that glows behind + * the text instead of covering it, paired with [highlightMarkerText]. + * + * Deliberately NOT derived from the user's accent color: a highlighter reads as yellow + * regardless of theme, and re-tinting it to (say) a teal accent loses the metaphor. + */ +val ColorScheme.highlightMarker: Color + get() = if (isLight) Color(0xFFFFE066).copy(alpha = 0.88f) else Color(0xFFFFD24A).copy(alpha = 0.33f) + +/** Glyph color for text sitting on the [highlightMarker] wash. */ +val ColorScheme.highlightMarkerText: Color + get() = if (isLight) Color(0xFF1A1400) else Color(0xFFFFEFC2) + +/** + * The un-highlighted text surrounding a NIP-84 highlight. Dimmed so the marked span + * carries the eye without needing bold, which is what the quote used to lean on. + */ +val ColorScheme.highlightContextText: Color + get() = onSurface.copy(alpha = if (isLight) 0.55f else 0.50f) + +/** Left rule marking the block as quoted from somewhere else. */ +val ColorScheme.highlightQuoteBar: Color + get() = onSurface.copy(alpha = if (isLight) 0.16f else 0.22f) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuoteTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuoteTest.kt new file mode 100644 index 0000000000..c99a165190 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/highlights/HighlightQuoteTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.highlights + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class HighlightQuoteTest { + @Test + fun marksTheQuoteInsideItsContext() { + val quote = HighlightQuote.of("the merge happens slowly", "We think the merge happens slowly. It does.") + + assertEquals("We think the merge happens slowly. It does.", quote.text) + assertEquals(9 until 33, quote.marked) + assertEquals("the merge happens slowly", quote.text.substring(quote.marked!!)) + } + + @Test + fun marksEverythingWhenThereIsNoContext() { + val quote = HighlightQuote.of("a bare quote", null) + + assertEquals("a bare quote", quote.text) + assertNull(quote.marked) + } + + @Test + fun dropsContextThatDoesNotContainTheQuote() { + val quote = HighlightQuote.of("not in here", "some entirely different sentence") + + assertEquals("not in here", quote.text) + assertNull(quote.marked) + } + + /** The old renderer used String.replace, which marked every occurrence at once. */ + @Test + fun marksOnlyOneOccurrenceOfARepeatedQuote() { + val quote = HighlightQuote.of("freedom", "freedom begets freedom") + + assertEquals(0 until 7, quote.marked) + } + + @Test + fun usesTheSelectorPrefixToPickTheRightOccurrence() { + val quote = HighlightQuote.of("freedom", "freedom begets freedom", prefix = "freedom begets ") + + assertEquals(15 until 22, quote.marked) + assertEquals("freedom", quote.text.substring(quote.marked!!)) + } + + @Test + fun fallsBackToTheFirstOccurrenceWhenThePrefixMatchesNothing() { + val quote = HighlightQuote.of("freedom", "freedom begets freedom", prefix = "nowhere in the text") + + assertEquals(0 until 7, quote.marked) + } + + @Test + fun handlesAQuoteSpanningNewlines() { + val context = "First line here.\nSecond line here." + val quote = HighlightQuote.of("here.\nSecond", context) + + assertEquals(context, quote.text) + assertEquals("here.\nSecond", quote.text.substring(quote.marked!!)) + } + + @Test + fun handlesAnEmptyHighlight() { + val quote = HighlightQuote.of("", "some context") + + assertEquals("some context", quote.text) + assertNull(quote.marked) + } +} From 7d13f373ac91e35062c216c608eaab8e9acb393a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Jul 2026 22:39:57 -0400 Subject: [PATCH 2/2] feat(highlights): keep auto-translation on the marked quote Replacing the markdown pipeline dropped TranslatableRichTextViewer, and with it the ML Kit auto-translation of the quoted passage. Restore it without giving up the marker. A translation rewrites the passage, so the character offsets that locate the quote inside its context stop pointing at anything. Translate the quote as well and re-find it in the translated context: ML Kit works sentence by sentence, so a quote that is one or more whole sentences -- the common case, since people highlight sentences -- comes back identical whether it is translated alone or inside its paragraph. untranslated -> context, original span marked translated + quote found -> translated context, translated span marked translated + not found -> translated quote alone, fully marked The fallback is free: HighlightQuote.of already returns the quote alone when the needle is not in the haystack, so the not-found case needs no special casing. Marking a guessed span, or claiming the whole paragraph was highlighted, would both be worse than showing less. That second translation must not draw its own status bar, so add rememberTranslation() to both flavors: play reuses the existing translateAndCache and its cache; fdroid, which ships no translation service, returns the content unchanged. Also indent the "Auto-translated from X to Y" line to the quote's own 15dp so it lines up with the text rather than the bar. Verified on device: an English highlight renders as a fully translated Portuguese paragraph with the quoted sentence marked inside it. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/TranslatableRichTextViewer.kt | 7 ++++ .../amethyst/ui/note/types/Highlight.kt | 36 +++++++++++++--- .../components/TranslatableRichTextViewer.kt | 42 +++++++++++++++++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index b6c79e0a67..2f1690ddf1 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -66,3 +66,10 @@ fun TranslatableRichTextViewer( accountViewModel: AccountViewModel, displayText: @Composable (String) -> Unit, ) = displayText(content) + +/** No translation service in this flavor, so the content is always its own "translation". */ +@Composable +fun rememberTranslation( + content: String, + accountViewModel: AccountViewModel, +): String = content diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt index a54ab75d8f..f1614872b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Highlight.kt @@ -41,6 +41,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.highlights.HighlightQuote import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary @@ -57,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.components.DisplayEvent import com.vitorpamplona.amethyst.ui.components.RenderUserAsClickableText import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.measureSpaceWidth +import com.vitorpamplona.amethyst.ui.components.rememberTranslation import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -194,11 +196,35 @@ fun DisplayHighlight( HighlightQuote.of(highlight, context, textFragmentPrefix) } - HighlightedQuote( - text = quote.text, - highlight = quote.marked, - modifier = Modifier.fillMaxWidth(), - ) + TranslatableRichTextViewer( + content = quote.text, + id = quote.text, + // Indented like the attribution, so the "Auto-translated from …" line sits under the + // quote text rather than under the bar. + translationMessageModifier = + Modifier + .fillMaxWidth() + .padding(top = 5.dp, start = HighlightQuoteIndent), + accountViewModel = accountViewModel, + ) { shown -> + // A translation rewrites the passage, so the original offsets stop locating anything. + // Translate the quote too and re-find it inside the translated context — ML Kit works + // sentence by sentence, so a quote that is one or more whole sentences comes back the + // same either way. HighlightQuote.of degrades to the quote alone when the two + // translations don't line up, which beats marking a span the reader never highlighted. + val translatedQuote = rememberTranslation(highlight, accountViewModel) + + val display = + remember(shown, translatedQuote, quote) { + if (shown == quote.text) quote else HighlightQuote.of(translatedQuote, shown) + } + + HighlightedQuote( + text = display.text, + highlight = display.marked, + modifier = Modifier.fillMaxWidth(), + ) + } Spacer(Modifier.height(HighlightQuoteSpacing)) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index ac57a8c731..0dad690756 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -129,6 +129,48 @@ fun TranslatableRichTextViewer( ) } +/** + * The translation of [content] under the current language settings, or [content] unchanged when + * no translation applies. Same machinery and cache as [TranslatableRichTextViewer] but without + * the status bar, for callers that already render one and need a second string translated in + * step with it — e.g. a NIP-84 highlight, which must translate the quoted passage alongside the + * context in order to keep locating the passage inside it. + */ +@Composable +fun rememberTranslation( + content: String, + accountViewModel: AccountViewModel, +): String { + val languages = accountViewModel.account.settings.syncedSettings.languages + val translateTo by languages.translateTo.collectAsStateWithLifecycle() + val dontTranslateFrom by languages.dontTranslateFrom.collectAsStateWithLifecycle() + + val state = + remember(content, translateTo, dontTranslateFrom) { + mutableStateOf( + TranslationsCache.get(content, translateTo, dontTranslateFrom) + ?: TranslationConfig(content, null, null), + ) + } + + LaunchedEffect(content, translateTo, dontTranslateFrom) { + try { + state.value = + withContext(Dispatchers.IO) { + translateAndCache(content, translateTo, dontTranslateFrom) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Transient ML Kit / network failure — keep the original, same as the viewer does. + } + } + + val config = state.value + val translated = config.sourceLang != null && config.targetLang != null && config.sourceLang != config.targetLang + return if (translated) config.result else content +} + @Composable private fun RenderTextWithTranslateOptions( translatedTextState: TranslationConfig,