mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+7
@@ -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,
|
||||
) {
|
||||
|
||||
+42
@@ -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,
|
||||
|
||||
+92
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+234
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+30
@@ -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)
|
||||
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user