Merge pull request #4307 from vitorpamplona/claude/cool-einstein-mkv9i8

Drop blank lines after block-level rich text segments
This commit is contained in:
Vitor Pamplona
2026-10-02 10:12:41 -04:00
committed by GitHub
3 changed files with 161 additions and 1 deletions
@@ -0,0 +1,72 @@
/*
* 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.richtext
/**
* True when [segment] renders as a full-width block (a link-preview card, an image, a video,
* a PDF) instead of an inline word. Media always draws as a block; a [LinkSegment] only does
* when previews are on, otherwise it is an inline clickable URL.
*/
fun rendersAsBlock(
segment: Segment,
canPreview: Boolean,
): Boolean =
when (segment) {
is ImageSegment, is VideoSegment, is PdfSegment, is Base64Segment -> true
is LinkSegment -> canPreview
else -> false
}
/** A paragraph that renders as an empty line: no words, or only blank text. */
fun ParagraphState.isBlankLine(): Boolean = words.all { it is RegularTextSegment && it.segmentText.isBlank() }
/**
* Drops the one blank line that directly follows a paragraph ending in a block (see
* [rendersAsBlock]). The block already ends its row, so the `\n\n` the author typed to separate
* the URL from the next sentence would otherwise render as an extra empty text line under the
* card, doubling the gap. Standalone image paragraphs get the same treatment in [GalleryParser].
*
* Returns [paragraphs] itself when nothing is dropped.
*/
fun dropBlankLineAfterBlocks(
paragraphs: List<ParagraphState>,
canPreview: Boolean,
): List<ParagraphState> {
var result: ArrayList<ParagraphState>? = null
for (i in paragraphs.indices) {
val paragraph = paragraphs[i]
val previous = paragraphs.getOrNull(i - 1)
val drop =
previous != null &&
paragraph.isBlankLine() &&
!previous.isBlankLine() &&
rendersAsBlock(previous.words.last(), canPreview)
if (drop) {
if (result == null) result = ArrayList<ParagraphState>(paragraphs.size).apply { addAll(paragraphs.subList(0, i)) }
} else {
result?.add(paragraph)
}
}
return result ?: paragraphs
}
@@ -0,0 +1,86 @@
/*
* 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.richtext
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
import kotlin.test.assertTrue
class BlockParagraphSpacingTest {
private fun parse(text: String) = RichTextParser().parseText(text, EmptyTagList, null).paragraphs
@Test
fun blankLineAfterTrailingLinkPreviewIsDropped() {
val paragraphs = parse("See if you can restore your follow list here: https://metadata.nostr.com/#\n\nSomething zeroed it out.")
// The parser keeps the author's blank line as its own paragraph...
assertEquals(3, paragraphs.size)
assertTrue(paragraphs[0].words.last() is LinkSegment)
assertTrue(paragraphs[1].isBlankLine())
// ...which the renderer drops under the card.
val rendered = dropBlankLineAfterBlocks(paragraphs, canPreview = true)
assertEquals(2, rendered.size)
assertSame(paragraphs[0], rendered[0])
assertSame(paragraphs[2], rendered[1])
}
@Test
fun blankLineAfterInlineLinkIsKeptWithoutPreviews() {
val paragraphs = parse("here: https://metadata.nostr.com/\n\nSomething zeroed it out.")
assertSame(paragraphs, dropBlankLineAfterBlocks(paragraphs, canPreview = false))
}
@Test
fun blankLineAfterTrailingImageIsDropped() {
val paragraphs = parse("look at this https://example.com/cat.jpg\n\nnice, right?")
assertEquals(3, paragraphs.size)
val rendered = dropBlankLineAfterBlocks(paragraphs, canPreview = false)
assertEquals(listOf(paragraphs[0], paragraphs[2]), rendered)
}
@Test
fun onlyOneBlankLineIsDropped() {
val paragraphs = parse("here: https://metadata.nostr.com/\n\n\nSomething zeroed it out.")
assertEquals(4, paragraphs.size)
val rendered = dropBlankLineAfterBlocks(paragraphs, canPreview = true)
assertEquals(listOf(paragraphs[0], paragraphs[2], paragraphs[3]), rendered)
}
@Test
fun linkInTheMiddleOfALineKeepsTheBlankLine() {
val paragraphs = parse("here: https://metadata.nostr.com/ is the site\n\nSomething zeroed it out.")
assertSame(paragraphs, dropBlankLineAfterBlocks(paragraphs, canPreview = true))
}
@Test
fun blankLineBeforeAPreviewIsKept() {
val paragraphs = parse("Something zeroed it out.\n\nhttps://metadata.nostr.com/")
assertSame(paragraphs, dropBlankLineAfterBlocks(paragraphs, canPreview = true))
}
}
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.commons.richtext.VideoSegment
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
import com.vitorpamplona.amethyst.commons.richtext.dropBlankLineAfterBlocks
/**
* Cross-platform rich-text renderer. Owns everything identical on every front
@@ -104,9 +105,10 @@ fun RichTextViewer(
val renderer = LocalRichTextSegmentRenderer.current
val baseStyle = LocalTextStyle.current
val paragraphStyle = remember(baseStyle) { baseStyle.copy(lineHeight = 1.3.em) }
val paragraphs = remember(state, canPreview) { dropBlankLineAfterBlocks(state.paragraphs, canPreview) }
Column(modifier) {
state.paragraphs.forEach { paragraph ->
paragraphs.forEach { paragraph ->
val align = if (paragraph.isRTL) Alignment.End else Alignment.Start
if (paragraph is ImageGalleryParagraph) {
renderer.Gallery(paragraph, state, Modifier.align(align))