Merge pull request #3757 from vitorpamplona/claude/nip84-highlight-marker

feat(highlights): render NIP-84 quotes with a highlighter-pen marker
This commit is contained in:
Vitor Pamplona
2026-07-27 22:48:01 -04:00
committed by GitHub
7 changed files with 540 additions and 29 deletions
@@ -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
@@ -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,
) {
@@ -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,
@@ -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<Int> {
val found = mutableListOf<Int>()
var from = context.indexOf(highlight)
while (from >= 0) {
found.add(from)
from = context.indexOf(highlight, from + 1)
}
return found
}
}
}
@@ -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<TextLayoutResult?>(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,
)
}
}
@@ -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)
@@ -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)
}
}