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 795199f211..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 @@ -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 @@ -38,8 +41,13 @@ 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 +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 @@ -50,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 @@ -65,7 +74,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 +188,51 @@ 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, - modifier = Modifier.fillMaxWidth(), - tags = EmptyTagList, - backgroundColor = backgroundColor, - id = quote, - callbackUri = null, + 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, - nav = nav, - ) + ) { 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)) 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/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, 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) + } +}