diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt index 3d48caca3..e5e6238d3 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt @@ -571,6 +571,9 @@ class MarkdownAccessibilityHelper( .maxByOrNull { it.depth } ?: return null if (requireStart) { + // An item split around a code block starts only where its marker is drawn: the segments after + // the code block continue it. + if (!deepest.drawsMarker) return null val spanStart = spanned.getSpanStart(deepest) val firstChar = (spanStart until minOf(spanStart + 10, spanned.length)) diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt index 401383bef..609568ea9 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt @@ -46,6 +46,7 @@ class BlockStyleContext { var listItemMarkerExtra = 0f var listItemMarkerWidth: Float? = null var taskItemCount = 0 + var accumulatedIndent = 0 private val orderedListItemNumbers = ArrayDeque() @@ -158,6 +159,7 @@ class BlockStyleContext { listItemMarkerExtra = 0f listItemMarkerWidth = null taskItemCount = 0 + accumulatedIndent = 0 orderedListItemNumbers.clear() } } diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt index edb6d84a5..55c1246ee 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt @@ -28,6 +28,10 @@ class BlockquoteRenderer( onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, ) { + if (builder.isNotEmpty() && builder.last() != '\n') { + builder.append("\n") + } + val start = builder.length val style = config.style.blockquoteStyle val context = factory.blockStyleContext diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt index 8a7da8b99..20b9de820 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt @@ -25,6 +25,10 @@ class CodeBlockRenderer( onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, ) { + if (builder.isNotEmpty() && builder.last() != '\n') { + builder.append("\n") + } + val start = builder.length val style = config.style.codeBlockStyle val context = factory.blockStyleContext @@ -49,7 +53,7 @@ class CodeBlockRenderer( // Apply background, borders, and horizontal padding to content only builder.setSpan( - CodeBlockSpan(style, factory.context, factory.styleCache), + CodeBlockSpan(style, factory.context, factory.styleCache, context.accumulatedIndent), contentStart, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE, diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt index b5bd8def4..b489abed6 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt @@ -21,6 +21,10 @@ class HeadingRenderer( onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, ) { + if (builder.isNotEmpty() && builder.last() != '\n') { + builder.append("\n") + } + val level = node.getAttribute("level")?.toIntOrNull() ?: 1 val start = builder.length diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt index f0d854472..1c1188006 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt @@ -39,7 +39,8 @@ class ListItemRenderer( val depth = styleContext.listDepth - 1 val listStyle = config.style.listStyle - val span = + + fun makeSpan(drawsMarker: Boolean): BaseListSpan = if (isTask) { TaskListSpan( taskStyle = config.style.taskListStyle, @@ -49,6 +50,7 @@ class ListItemRenderer( styleCache = factory.styleCache, taskIndex = taskIndex, isChecked = isChecked, + drawsMarker = drawsMarker, ancestorMarkerOffset = styleContext.ancestorMarkerOffset, markerExtraWidth = styleContext.listMarkerExtra, parentMarkerWidth = styleContext.listItemMarkerWidth, @@ -61,6 +63,7 @@ class ListItemRenderer( depth, factory.context, factory.styleCache, + drawsMarker, ancestorMarkerOffset = styleContext.ancestorMarkerOffset, parentMarkerWidth = styleContext.listItemMarkerWidth, ) @@ -72,6 +75,7 @@ class ListItemRenderer( depth, factory.context, factory.styleCache, + drawsMarker, markerExtraWidth = styleContext.listMarkerExtra, ancestorMarkerOffset = styleContext.ancestorMarkerOffset, parentMarkerWidth = styleContext.listItemMarkerWidth, @@ -82,27 +86,66 @@ class ListItemRenderer( } } + val markerSpan = makeSpan(drawsMarker = true) + // Nested lists inside this item shift by its extra marker width and measure their indent from its column. + // Code blocks inside it start at its text column, since the item's spans leave their lines (see below). val previousMarkerExtra = styleContext.listItemMarkerExtra - styleContext.listItemMarkerExtra = span.markerExtraWidth + styleContext.listItemMarkerExtra = markerSpan.markerExtraWidth val previousMarkerWidth = styleContext.listItemMarkerWidth - styleContext.listItemMarkerWidth = span.baselineMarkerWidth + styleContext.listItemMarkerWidth = markerSpan.baselineMarkerWidth + val previousIndent = styleContext.accumulatedIndent + styleContext.accumulatedIndent = previousIndent + markerSpan.getLeadingMargin(false) try { factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) } finally { + styleContext.accumulatedIndent = previousIndent styleContext.listItemMarkerExtra = previousMarkerExtra styleContext.listItemMarkerWidth = previousMarkerWidth } if (builder.length == start || builder.substring(start).isBlank()) return - while (builder.length > start && builder.last() == '\n') { - builder.delete(builder.length - 1, builder.length) + if (builder.last() != '\n') { + builder.append("\n") } - builder.append("\n") val itemEnd = builder.length - builder.setSpan(span, start, itemEnd, SPAN_FLAGS_LIST_ITEM) + + val codeBlocks = builder.getSpans(start, itemEnd, CodeBlockSpan::class.java) + // Nested items render first, so a code block keeps the innermost item that holds it. + for (block in codeBlocks) { + if (block.listItem == null) block.listItem = markerSpan + } + + val codeBlockRanges = + codeBlocks + .map { builder.getSpanStart(it) to builder.getSpanEnd(it) } + .filter { it.first < it.second } + .sortedBy { it.first } + + var segmentStart = start + var isFirstSegment = true + for ((codeStart, codeEnd) in codeBlockRanges) { + if (segmentStart < codeStart) { + builder.setSpan( + if (isFirstSegment) markerSpan else makeSpan(drawsMarker = false), + segmentStart, + codeStart, + SPAN_FLAGS_LIST_ITEM, + ) + isFirstSegment = false + } + segmentStart = maxOf(segmentStart, codeEnd) + } + if (segmentStart < itemEnd) { + builder.setSpan( + if (isFirstSegment) markerSpan else makeSpan(drawsMarker = false), + segmentStart, + itemEnd, + SPAN_FLAGS_LIST_ITEM, + ) + } if (isChecked) { applyCheckedDecorations(builder, start, itemEnd, depth) diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt index 7e8754941..9d91ea20b 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt @@ -22,6 +22,7 @@ abstract class BaseListSpan( protected val blockStyle: BlockStyle, protected val marginLeft: Float, protected val gapWidth: Float, + internal val drawsMarker: Boolean = true, internal val ancestorMarkerOffset: Float = 0f, internal val parentMarkerWidth: Float? = null, ) : MetricAffectingSpan(), @@ -76,6 +77,7 @@ abstract class BaseListSpan( first: Boolean, layout: Layout?, ) { + if (!drawsMarker) return if (!first || shouldSkipDrawing(text, start) || !hasContent(text, start, end)) return // Only draw the marker on the true first line of this span. diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt index a9e022715..f68ce145a 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt @@ -22,9 +22,13 @@ class CodeBlockSpan( private val codeBlockStyle: CodeBlockStyle, private val context: Context, private val styleCache: SpanStyleCache, + private val leadingIndent: Int = 0, ) : MetricAffectingSpan(), LineBackgroundSpan, LeadingMarginSpan { + /** The innermost list item holding this block, whose marker span this is; copy-as-markdown nests the block under it. */ + internal var listItem: BaseListSpan? = null + private val blockStyle = BlockStyle( fontSize = codeBlockStyle.fontSize, @@ -59,7 +63,7 @@ class CodeBlockSpan( color = codeBlockStyle.borderColor } - override fun getLeadingMargin(first: Boolean): Int = codeBlockStyle.padding.toInt() + override fun getLeadingMargin(first: Boolean): Int = codeBlockStyle.padding.toInt() + leadingIndent override fun drawLeadingMargin( c: Canvas?, @@ -105,11 +109,12 @@ class CodeBlockSpan( val inset = codeBlockStyle.borderWidth / 2f rect.set( - left.toFloat() + inset, + left.toFloat() + leadingIndent + inset, top.toFloat() + (if (isFirstLine) inset else 0f), right.toFloat() - inset, bottom.toFloat() - (if (isLastLine) inset else 0f), ) + if (rect.left >= rect.right) return val radius = codeBlockStyle.borderRadius val adjRadius = if (radius > inset) radius - inset else radius diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt index 6183204d6..22b530271 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt @@ -17,6 +17,7 @@ class OrderedListSpan( depth: Int, context: Context, styleCache: SpanStyleCache, + drawsMarker: Boolean = true, override val markerExtraWidth: Float = 0f, ancestorMarkerOffset: Float = 0f, parentMarkerWidth: Float? = null, @@ -33,6 +34,7 @@ class OrderedListSpan( ), marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, + drawsMarker = drawsMarker, ancestorMarkerOffset = ancestorMarkerOffset, parentMarkerWidth = parentMarkerWidth, ) { diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt index edcc1d526..d4fcf4a28 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt @@ -26,6 +26,7 @@ class TaskListSpan( * moves it to the end of that order and shifts where its marker lands. */ var isChecked: Boolean, + drawsMarker: Boolean = true, ancestorMarkerOffset: Float = 0f, override val markerExtraWidth: Float = 0f, parentMarkerWidth: Float? = null, @@ -42,6 +43,7 @@ class TaskListSpan( ), marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, + drawsMarker = drawsMarker, ancestorMarkerOffset = ancestorMarkerOffset, parentMarkerWidth = parentMarkerWidth, ) { diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt index 381e7dbc7..616b2bdb2 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt @@ -13,6 +13,7 @@ class UnorderedListSpan( depth: Int, context: Context, styleCache: SpanStyleCache, + drawsMarker: Boolean = true, ancestorMarkerOffset: Float = 0f, parentMarkerWidth: Float? = null, ) : BaseListSpan( @@ -28,6 +29,7 @@ class UnorderedListSpan( ), marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, + drawsMarker = drawsMarker, ancestorMarkerOffset = ancestorMarkerOffset, parentMarkerWidth = parentMarkerWidth, ) { diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/HTMLGenerator.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/HTMLGenerator.kt index 443cc6977..945c74ea5 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/HTMLGenerator.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/HTMLGenerator.kt @@ -21,6 +21,7 @@ import com.swmansion.enriched.markdown.spans.HeadingSpan import com.swmansion.enriched.markdown.spans.HighlightSpan import com.swmansion.enriched.markdown.spans.ImageSpan import com.swmansion.enriched.markdown.spans.LinkSpan +import com.swmansion.enriched.markdown.spans.MarginBottomSpan import com.swmansion.enriched.markdown.spans.OrderedListSpan import com.swmansion.enriched.markdown.spans.StrongSpan import com.swmansion.enriched.markdown.spans.TaskListSpan @@ -334,6 +335,9 @@ object HTMLGenerator { return } + // A block's bottom-margin line inside a list item or a quote holds no content of its own. + if (paraText.isEmpty() && text.getSpans(para.start, para.start + 1, MarginBottomSpan::class.java).isNotEmpty()) return + // Get content range (trim trailing newline) val contentEnd = if (para.end > para.start && text[para.end - 1] == '\n') para.end - 1 else para.end val isCodeBlock = para.type == TYPE_CODE_BLOCK diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt index 2cfc7fcdd..27b5a0bae 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt @@ -171,7 +171,8 @@ object MarkdownExtractor { val codeBlockSpans = spannable.getSpans(segmentStart, segmentEnd, CodeBlockSpan::class.java) if (codeBlockSpans.isNotEmpty()) { - appendCodeBlock(segmentText, result, state) + val opensBlock = codeBlockSpans.any { spannable.getSpanStart(it) == segmentStart } + appendCodeBlock(spannable, codeBlockSpans[0], segmentText, opensBlock, result, state) return true } @@ -291,7 +292,10 @@ object MarkdownExtractor { } private fun appendCodeBlock( + spannable: Spannable, + block: CodeBlockSpan, text: String, + opensBlock: Boolean, result: StringBuilder, state: ExtractionState, ) { @@ -300,14 +304,42 @@ object MarkdownExtractor { state.needsBlankLine = false } - val needsFence = result.isEmpty() || result.endsWith("\n\n") - if (needsFence) result.append("```\n") + // A code block inside a list item lines up under the item's text. + val item = block.listItem + + // When the block opens its item, the item's marker span is not attached at all or sits after the block. + val itemStart = item?.let { spannable.getSpanStart(it) } ?: -1 + val markerSpanDetached = itemStart < 0 + val markerSpanAfterBlock = itemStart >= spannable.getSpanEnd(block) + val blockOpensItem = item != null && (markerSpanDetached || markerSpanAfterBlock) + + // The fence takes the item's marker only when the selection includes the block's first line. + val opensItem = opensBlock && blockOpensItem + val nestedItem = item?.takeIf { opensItem || result.isNotEmpty() } + val indent = nestedItem?.let { contentIndent(it) } ?: "" + if (nestedItem != null) state.listDepth = nestedItem.depth + + val needsFence = result.isEmpty() || result.endsWith("\n\n") || opensBlock + if (needsFence) { + if (!result.isAtLineStart()) result.append("\n") + if (opensItem) { + result.append(listMarker(item)) + state.itemOpenedByCode = item + } else { + result.append(indent) + } + result.append("```\n") + } - result.append(text) + text.split('\n').forEachIndexed { index, line -> + if (index > 0) result.append('\n') + if (line.isNotEmpty() && result.isAtLineStart()) result.append(indent) + result.append(line) + } if (text.endsWith("\n")) { - result.append("```\n") - state.needsBlankLine = true + result.append(indent).append("```\n") + state.needsBlankLine = nestedItem == null } } @@ -346,6 +378,13 @@ object MarkdownExtractor { } state.listDepth = listSpan.depth + + // Text after a code block continues its item rather than starting a new one. + val continuesItem = !listSpan.drawsMarker || listSpan === state.itemOpenedByCode + return if (continuesItem) contentIndent(listSpan) else listMarker(listSpan) + } + + private fun listMarker(listSpan: BaseListSpan): String { val indent = " ".repeat(listSpan.depth) return when (listSpan) { @@ -355,6 +394,12 @@ object MarkdownExtractor { } } + /** The column an item's text starts at; a task item's checkbox is part of its text. */ + private fun contentIndent(listSpan: BaseListSpan): String { + val markerWidth = if (listSpan is OrderedListSpan) "${listSpan.itemNumber}. ".length else 2 + return " ".repeat(2 * listSpan.depth + markerWidth) + } + private fun applyInlineFormatting( spannable: Spannable, text: String, @@ -424,6 +469,7 @@ object MarkdownExtractor { var blockquoteDepth: Int = -1, var listDepth: Int = -1, var needsBlankLine: Boolean = false, + var itemOpenedByCode: BaseListSpan? = null, ) private class HeadingAccumulator { diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt index eecbfea7e..713702897 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt @@ -94,15 +94,15 @@ object TaskListTapUtils { if (x >= indentWidth) return null } - val spanStart = spannable.getSpanStart(taskSpan) - val spanEnd = spannable.getSpanEnd(taskSpan) + val (itemStart, itemEnd) = spannable.itemRange(taskSpan.taskIndex) ?: return null val itemText = spannable - .subSequence(spanStart, spanEnd) - .toString() - .substringBefore('\n') - .trim() + .subSequence(itemStart, itemEnd) + .lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotEmpty() } + .orEmpty() return TaskListHitTestResult( span = taskSpan, @@ -112,8 +112,8 @@ object TaskListTapUtils { } /** - * Flips [span]'s checkbox and checked-text decoration directly on [textView]'s - * spans — no re-parse, so a tap redraws immediately. + * Flips the checkbox and checked-text decoration of [span]'s item directly on + * [textView]'s spans — no re-parse, so a tap redraws immediately. * * Returns `false` when the view holds no spannable text or [span] is no longer * attached to it, which a render landing mid-gesture can do; the caller then @@ -127,13 +127,13 @@ object TaskListTapUtils { ): Boolean { val spannable = textView.text as? Spannable ?: return false - val spanStart = spannable.getSpanStart(span) - val spanEnd = spannable.getSpanEnd(span) - if (spanStart < 0) return false + // An item split around its code blocks has a span per segment. + val segments = spannable.getSpans(0, spannable.length, TaskListSpan::class.java).filter { it.taskIndex == span.taskIndex } + if (span !in segments) return false - if (span.isChecked == newChecked) return true + if (segments.all { it.isChecked == newChecked }) return true - // Flip the span in place, then re-set it over the range it already holds. + // Flip each span in place, then re-set it over the range it already holds. // Re-setting an attached span keeps its slot in the buffer's span array — // Layout paints LeadingMarginSpans in that order, advancing x by each one's // margin, so a remove + add would send this span to the end and a nested @@ -141,22 +141,26 @@ object TaskListTapUtils { // The setSpan still reports a span change, and that is what makes TextView // drop the render node its Editor caches the drawn text in; a bare // invalidate() re-runs onDraw off that cache and repaints the old checkbox. - span.isChecked = newChecked - spannable.setSpan(span, spanStart, spanEnd, spannable.getSpanFlags(span)) + for (segment in segments) { + segment.isChecked = newChecked + spannable.resetSpan(segment) + } + + val (itemStart, itemEnd) = spannable.itemRange(span.taskIndex) ?: return true // Nested items and code blocks keep their own styling, exactly as on the // initial render in ListItemRenderer. val excludedRanges = ( - spannable.getSpans(spanStart, spanEnd, BaseListSpan::class.java).filter { it.depth > span.depth } + - spannable.getSpans(spanStart, spanEnd, CodeBlockSpan::class.java).toList() + spannable.getSpans(itemStart, itemEnd, BaseListSpan::class.java).filter { it.depth > span.depth } + + spannable.getSpans(itemStart, itemEnd, CodeBlockSpan::class.java).toList() ).map { spannable.getSpanStart(it) to spannable.getSpanEnd(it) } .sortedBy { it.first } applyDecorationsToRanges( spannable = spannable, - spanStart = spanStart, - spanEnd = spanEnd, + spanStart = itemStart, + spanEnd = itemEnd, excludedRanges = excludedRanges, isChecked = newChecked, styleConfig = styleConfig, @@ -167,6 +171,15 @@ object TaskListTapUtils { return true } + private fun Spannable.resetSpan(span: Any) = setSpan(span, getSpanStart(span), getSpanEnd(span), getSpanFlags(span)) + + /** The range a task item covers: from its first segment's start to its last one's end, code blocks between included. */ + private fun Spanned.itemRange(taskIndex: Int): Pair? { + val spans = getSpans(0, length, TaskListSpan::class.java).filter { it.taskIndex == taskIndex } + if (spans.isEmpty()) return null + return spans.minOf { getSpanStart(it) } to spans.maxOf { getSpanEnd(it) } + } + private fun applyDecorationsToRanges( spannable: Spannable, spanStart: Int, diff --git a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ListChildrenExportTest.kt b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ListChildrenExportTest.kt new file mode 100644 index 000000000..f9bb5a2d7 --- /dev/null +++ b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ListChildrenExportTest.kt @@ -0,0 +1,147 @@ +package com.swmansion.enriched.markdown + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.test.HTMLAssertions.assertContainsHtmlInOrder +import com.swmansion.enriched.markdown.test.HTMLGeneratorTestSupport.generateHTML +import com.swmansion.enriched.markdown.test.MarkdownExtractorTestSupport.extractFromFullSelection +import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport.render +import com.swmansion.enriched.markdown.test.TestAstFactory.blockquote +import com.swmansion.enriched.markdown.test.TestAstFactory.codeBlock +import com.swmansion.enriched.markdown.test.TestAstFactory.document +import com.swmansion.enriched.markdown.test.TestAstFactory.listItem +import com.swmansion.enriched.markdown.test.TestAstFactory.orderedList +import com.swmansion.enriched.markdown.test.TestAstFactory.paragraph +import com.swmansion.enriched.markdown.test.TestAstFactory.taskListItem +import com.swmansion.enriched.markdown.test.TestAstFactory.text +import com.swmansion.enriched.markdown.test.TestAstFactory.unorderedList +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Copy-as-markdown, HTML and accessibility output for list items whose spans are split around code blocks. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [28]) +class ListChildrenExportTest { + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun nestsACodeBlockThatEndsAnItemUnderTheItem() { + val markdown = + extractFromFullSelection( + document( + unorderedList( + listItem(paragraph(text("Item")), codeBlock("first line\nlast line\n")), + listItem(paragraph(text("Next"))), + ), + ), + ) + + assertEquals("- Item\n ```\n first line\n last line\n ```\n- Next", markdown) + } + + @Test + fun putsTheMarkerOfAnItemThatOpensWithACodeBlockOnItsFence() { + val markdown = + extractFromFullSelection( + document( + unorderedList( + listItem(codeBlock("val x = 1\n"), paragraph(text("After code"))), + listItem(paragraph(text("Next"))), + ), + ), + ) + + assertEquals("- ```\n val x = 1\n ```\n After code\n- Next", markdown) + } + + @Test + fun continuesAnItemAfterItsCodeBlock() { + val markdown = extractFromFullSelection(codeMidItem()) + + assertEquals("1. Item\n ```\n val x = 1\n ```\n After code\n2. Next", markdown) + } + + @Test + fun nestsACodeBlockUnderTheInnermostItem() { + val markdown = + extractFromFullSelection( + document( + unorderedList( + listItem(paragraph(text("Outer")), unorderedList(listItem(paragraph(text("Inner")), codeBlock("val x = 1\n")))), + listItem(paragraph(text("Next"))), + ), + ), + ) + + assertEquals("- Outer\n - Inner\n ```\n val x = 1\n ```\n- Next", markdown) + } + + @Test + fun leavesNoEmptyListItemsAroundCodeBlocksInHtml() { + for (document in listOf(codeMidItem(), codeEndingQuotedItem())) { + val html = generateHTML(render(document)) + + assertFalse("Empty list item in $html", Regex("]*>").containsMatchIn(html)) + assertFalse("Empty paragraph in $html", Regex("]*>

").containsMatchIn(html)) + } + val html = generateHTML(render(codeMidItem())) + html.assertContainsHtmlInOrder("Item", "", "After code", "Next") + } + + @Test + fun announcesOnlyTheLineThatDrawsAnItemsMarkerAsAListItem() { + val roles = accessibilityRoles(codeMidItem()) + + assertEquals("list item 1", roles["Item"]) + assertEquals("The code block is not a list item of its own", null, roles["val x = 1"]) + assertEquals("Text after the code block continues the item", null, roles["After code"]) + assertEquals("list item 2", roles["Next"]) + } + + private fun codeMidItem(): MarkdownASTNode = + document( + orderedList( + listItem(paragraph(text("Item")), codeBlock("val x = 1\n"), paragraph(text("After code"))), + listItem(paragraph(text("Next"))), + ), + ) + + private fun codeEndingQuotedItem(): MarkdownASTNode = + document( + unorderedList( + listItem(paragraph(text("Bullet")), blockquote(codeBlock("first line\nlast line\n"))), + listItem(paragraph(text("Another bullet"))), + ), + ) + + /** Each accessibility item's text mapped to the role it announces, `null` for plain text. */ + private fun accessibilityRoles(document: MarkdownASTNode): Map { + val view = EnrichedMarkdownInternalText(context).apply { setText(render(document), TextView.BufferType.SPANNABLE) } + view.layoutParams = ViewGroup.LayoutParams(VIEW_WIDTH, ViewGroup.LayoutParams.WRAP_CONTENT) + view.measure( + View.MeasureSpec.makeMeasureSpec(VIEW_WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + view.layout(0, 0, view.measuredWidth, view.measuredHeight) + + val provider = view.accessibilityHelper.getAccessibilityNodeProvider(view)!! + val host = provider.createAccessibilityNodeInfo(View.NO_ID)!! + return (0 until host.childCount).associate { id -> + val node: AccessibilityNodeInfoCompat = provider.createAccessibilityNodeInfo(id)!! + node.text.toString() to node.roleDescription?.toString() + } + } + + private companion object { + const val VIEW_WIDTH = 600 + } +} diff --git a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ListChildrenRenderingTest.kt b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ListChildrenRenderingTest.kt new file mode 100644 index 000000000..421bade74 --- /dev/null +++ b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ListChildrenRenderingTest.kt @@ -0,0 +1,269 @@ +package com.swmansion.enriched.markdown + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StrikethroughSpan +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.spans.CodeBlockSpan +import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.styles.TaskListStyle +import com.swmansion.enriched.markdown.test.DrawnMarker +import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport +import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport.render +import com.swmansion.enriched.markdown.test.MarkerCanvas +import com.swmansion.enriched.markdown.test.TestAstFactory.blockquote +import com.swmansion.enriched.markdown.test.TestAstFactory.codeBlock +import com.swmansion.enriched.markdown.test.TestAstFactory.document +import com.swmansion.enriched.markdown.test.TestAstFactory.listItem +import com.swmansion.enriched.markdown.test.TestAstFactory.orderedList +import com.swmansion.enriched.markdown.test.TestAstFactory.paragraph +import com.swmansion.enriched.markdown.test.TestAstFactory.taskListItem +import com.swmansion.enriched.markdown.test.TestAstFactory.text +import com.swmansion.enriched.markdown.test.TestAstFactory.unorderedList +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Block children of list items: code blocks, sublists and quotes inside an item. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [28]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ListChildrenRenderingTest { + @Test + fun codeBlockEndingAnItemKeepsItsBottomPaddingAndCorners() { + val drawn = draw(itemsWithCode(codeBlock(CODE))) + + // The first line carries the top padding and the last the bottom one, so they match. + assertEquals("bottom padding of the last code line", drawn.height("first line"), drawn.height("last line")) + assertFalse("the last code line rounds its bottom corners", drawn.codeBoxOn("last line").isRect) + } + + @Test + fun codeBlockInsideAnItemStartsAtTheItemsTextColumn() { + val drawn = draw(itemsWithCode(codeBlock(CODE))) + val itemColumn = drawn.textLeft("Item") + + assertTrue(itemColumn > 0f) + for (line in listOf("first line", "last line")) { + assertEquals("code box left on \"$line\"", itemColumn, drawn.codeBoxOn(line).bounds.left, 0.01f) + assertEquals("code text left on \"$line\"", itemColumn + CODE_PADDING, drawn.textLeft(line), 0.01f) + } + } + + @Test + fun blockquoteInsideAnItemKeepsTheMarkersAndIndent() { + val drawn = + draw( + unorderedList( + listItem(paragraph(text("Item")), blockquote(paragraph(text("Quoted")))), + listItem(paragraph(text("Next"))), + ), + ) + + assertEquals(listOf(drawn.line("Item"), drawn.line("Next")), drawn.markers.map { drawn.lineOf(it) }) + assertTrue(drawn.textLeft("Quoted") > drawn.textLeft("Item")) + assertEquals(drawn.quoteBarsOn("Quoted"), drawn.quoteBarsOn("Quoted").distinct()) + } + + @Test + fun codeBlockInAQuoteUnderANestedBulletKeepsTheQuoteAndMarkers() { + // 1. Ordered + // - Bullet + // > ``` + // > first line + // > last line + // > ``` + // - Another bullet + val drawn = + draw( + orderedList( + listItem( + paragraph(text("Ordered")), + unorderedList( + listItem(paragraph(text("Bullet")), blockquote(codeBlock(CODE))), + listItem(paragraph(text("Another bullet"))), + ), + ), + ), + ) + + assertEquals( + listOf(drawn.line("Ordered"), drawn.line("Bullet"), drawn.line("Another bullet")), + drawn.markers.map { drawn.lineOf(it) }, + ) + val bar = drawn.quoteBarsOn("first line") + assertTrue("the quote draws its bar beside the code", bar.isNotEmpty()) + assertEquals("the quote bar stays put across the code lines", bar, drawn.quoteBarsOn("last line")) + assertEquals(drawn.textLeft("first line"), drawn.textLeft("last line"), 0.01f) + assertTrue(drawn.textLeft("first line") > drawn.textLeft("Bullet")) + assertEquals("bottom padding of the last code line", drawn.height("first line"), drawn.height("last line")) + assertFalse(drawn.codeBoxOn("last line").isRect) + assertEquals("the next bullet keeps its column", drawn.textLeft("Bullet"), drawn.textLeft("Another bullet"), 0.01f) + } + + @Test + fun checkedTaskWithACodeBlockDrawsOneCheckboxAndLeavesTheCodeUndecorated() { + val style = + style( + taskListStyle = + MarkdownRenderTestSupport.defaultTaskListStyle().copy( + checkedTextColor = CHECKED_TEXT_COLOR, + checkedStrikethrough = true, + ), + ) + val drawn = + draw( + unorderedList( + taskListItem(checked = true, paragraph(text("Done")), codeBlock(CODE), paragraph(text("After code"))), + taskListItem(checked = false, paragraph(text("Next"))), + ), + style, + ) + + assertEquals(listOf(drawn.line("Done"), drawn.line("Next")), drawn.markers.map { drawn.lineOf(it) }) + assertEquals("bottom padding of the last code line", drawn.height("first line"), drawn.height("last line")) + assertEquals(drawn.textLeft("Done"), drawn.codeBoxOn("first line").bounds.left, 0.01f) + + val text = drawn.text + val code = text.getSpans(0, text.length, CodeBlockSpan::class.java).single() + val codeStart = text.getSpanStart(code) + val codeEnd = text.getSpanEnd(code) + assertTrue(text.getSpans(codeStart, codeEnd, StrikethroughSpan::class.java).isEmpty()) + assertTrue(text.getSpans(codeStart, codeEnd, ForegroundColorSpan::class.java).none { it.foregroundColor == CHECKED_TEXT_COLOR }) + for (decorated in listOf("Done", "After code")) { + val start = text.indexOf(decorated) + assertTrue("\"$decorated\" is struck through", text.getSpans(start, start + 1, StrikethroughSpan::class.java).isNotEmpty()) + } + } + + private fun itemsWithCode(code: MarkdownASTNode): MarkdownASTNode = + unorderedList( + listItem(paragraph(text("Item")), code), + listItem(paragraph(text("Next"))), + listItem(paragraph(text("Last"))), + ) + + private fun draw( + block: MarkdownASTNode, + style: StyleConfig = style(), + ): Drawn { + // SpannableString reports spans in priority and insertion order, as the text view does. + val text = SpannableString(render(document(block), style)) + val layout = MarkerCanvas.layout(text, style.listStyle.fontSize) + val blocks = BlockCanvas().also(layout::draw) + val markers = MarkerCanvas.drawMarkers(layout, ignoredColors = setOf(QUOTE_BORDER, CHECKED_TEXT_COLOR)) + return Drawn(text, layout, markers, blocks.codeBoxes, blocks.quoteBars) + } + + private fun style(taskListStyle: TaskListStyle? = null): StyleConfig { + val base = MarkdownRenderTestSupport.defaultStyle + return MarkdownRenderTestSupport.styleWithListAndCodeBlock( + listStyle = base.listStyle.copy(lineHeight = 0f, gapWidth = 8f, marginLeft = 24f), + codeBlockStyle = + base.codeBlockStyle.copy( + backgroundColor = CODE_BACKGROUND, + borderWidth = 0f, + borderRadius = 6f, + padding = CODE_PADDING, + marginTop = 8f, + marginBottom = 8f, + ), + taskListStyle = taskListStyle, + blockquoteStyle = base.blockquoteStyle.copy(borderColor = QUOTE_BORDER, borderWidth = 3f, gapWidth = 10f, backgroundColor = null), + ) + } + + private class CodeBox( + val bounds: RectF, + val isRect: Boolean, + ) + + private class Drawn( + val text: Spanned, + val layout: Layout, + val markers: List, + val codeBoxes: List, + val quoteBars: List, + ) { + fun line(content: String): Int { + val start = text.indexOf(content) + require(start >= 0) { "Rendered text does not contain \"$content\": \"$text\"" } + return layout.getLineForOffset(start) + } + + fun lineOf(marker: DrawnMarker): Int = layout.getLineForVertical(marker.baseline.toInt()) + + fun height(content: String): Int = line(content).let { layout.getLineBottom(it) - layout.getLineTop(it) } + + fun textLeft(content: String): Float = layout.getParagraphLeft(line(content)).toFloat() + + fun codeBoxOn(content: String): CodeBox { + val line = line(content) + return codeBoxes.single { it.bounds.centerY() > layout.getLineTop(line) && it.bounds.centerY() < layout.getLineBottom(line) } + } + + /** The left edges of the quote bars drawn on [content]'s line. */ + fun quoteBarsOn(content: String): List { + val line = line(content) + return quoteBars + .filter { it.centerY() > layout.getLineTop(line) && it.centerY() < layout.getLineBottom(line) } + .map { it.left } + .sorted() + } + } + + /** Records the code block boxes and blockquote bars a layout draws. */ + private class BlockCanvas : Canvas(Bitmap.createBitmap(MarkerCanvas.LAYOUT_WIDTH, 1, Bitmap.Config.ARGB_8888)) { + val codeBoxes = mutableListOf() + val quoteBars = mutableListOf() + + override fun getClipBounds(bounds: Rect): Boolean { + bounds.set(0, 0, MarkerCanvas.LAYOUT_WIDTH, Int.MAX_VALUE / 2) + return true + } + + override fun drawPath( + path: Path, + paint: Paint, + ) { + if (paint.color == CODE_BACKGROUND && paint.style == Paint.Style.FILL) { + val bounds = RectF() + path.computeBounds(bounds, true) + codeBoxes += CodeBox(bounds, path.isRect(RectF())) + } + super.drawPath(path, paint) + } + + override fun drawRect( + left: Float, + top: Float, + right: Float, + bottom: Float, + paint: Paint, + ) { + if (paint.color == QUOTE_BORDER) quoteBars += RectF(left, top, right, bottom) + super.drawRect(left, top, right, bottom, paint) + } + } + + private companion object { + const val CODE = "first line\nlast line\n" + const val CODE_PADDING = 12f + const val CODE_BACKGROUND = 0xFF123456.toInt() + const val QUOTE_BORDER = 0xFF654321.toInt() + const val CHECKED_TEXT_COLOR = 0xFF9E9E9E.toInt() + } +} diff --git a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/TaskListInteractionTest.kt b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/TaskListInteractionTest.kt index c810a932b..029439877 100644 --- a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/TaskListInteractionTest.kt +++ b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/TaskListInteractionTest.kt @@ -22,6 +22,8 @@ import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport.defaultTas import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport.render import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport.styleWithTaskList import com.swmansion.enriched.markdown.test.MarkdownTextViewTestSupport.createContainerWithStoredMarkdown +import com.swmansion.enriched.markdown.test.MarkerCanvas +import com.swmansion.enriched.markdown.test.TestAstFactory.codeBlock import com.swmansion.enriched.markdown.test.TestAstFactory.document import com.swmansion.enriched.markdown.test.TestAstFactory.listItem import com.swmansion.enriched.markdown.test.TestAstFactory.paragraph @@ -226,6 +228,35 @@ class TaskListInteractionTest { assertTrue((textView.text as Spannable).taskSpanCovering("Second nested").isChecked) } + @Test + fun togglesTheWholeTaskAroundItsCodeBlock() { + val style = decoratedStyle() + val task = + document( + unorderedList( + taskListItem(checked = false, paragraph(text("Open task")), codeBlock("val x = 1\n"), paragraph(text("After code"))), + taskListItem(checked = false, paragraph(text("Next task"))), + ), + ) + val textView = laidOutTextView(render(task, style)) + val text = textView.text as Spannable + val before = textView.leadingMarginSpans() + val hit = TaskListTapUtils.hitTest(textView, rawX = 1f, rawY = lineCenterY(textView, line = 0)) + + assertEquals(0, hit!!.taskIndex) + assertEquals("Open task", hit.itemText) + assertTrue(TaskListTapUtils.updateTaskListItemCheckedState(textView, hit.span, newChecked = true, style)) + + assertEquals("Toggling must not reorder the list markers", before, textView.leadingMarginSpans()) + val afterLine = textView.layout.getLineForOffset(text.indexOf("After code")) + assertEquals(true, TaskListTapUtils.hitTest(textView, rawX = 1f, rawY = lineCenterY(textView, afterLine))?.checked) + assertTrue(text.hasDecoration("Open task")) + assertTrue("The text after the code block is part of the item", text.hasDecoration("After code")) + assertFalse("Code blocks keep their own styling", text.hasDecoration("val x = 1")) + assertFalse(text.hasDecoration("Next task")) + assertFalse(text.taskSpanCovering("Next task").isChecked) + } + @Test fun reportsTheSpanChangeWhenTogglingSoTheViewRepaints() { val style = decoratedStyle() @@ -529,6 +560,10 @@ class TaskListInteractionTest { y: Float, ): MotionEvent = MotionEvent.obtain(0L, 0L, action, x, y, 0) + /** The markers drawn in a line's leading margin; a checked item's strikethrough is drawn past it. */ + private fun TextView.checkboxCount(): Int = + MarkerCanvas.drawMarkers(layout).count { it.right <= layout.getParagraphLeft(layout.getLineForVertical(it.baseline.toInt())) } + private fun TextView.leadingMarginSpans(): List { val spanned = text as Spanned return spanned.getSpans(0, spanned.length, LeadingMarginSpan::class.java).toList() diff --git a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/ListMarkerCanvas.kt b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/ListMarkerCanvas.kt index 7d925cc12..7ff7d2467 100644 --- a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/ListMarkerCanvas.kt +++ b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/ListMarkerCanvas.kt @@ -28,8 +28,13 @@ data class MarkerGeometry( val direction: Int, ) -/** Records the list markers a layout draws instead of painting them. */ -class MarkerCanvas : Canvas(Bitmap.createBitmap(LAYOUT_WIDTH, 1, Bitmap.Config.ARGB_8888)) { +/** + * Records the list markers a layout draws instead of painting them. Rectangles in [ignoredColors] + * are not markers: a quote bar or a strikethrough drawn in that color. + */ +class MarkerCanvas( + private val ignoredColors: Set = emptySet(), +) : Canvas(Bitmap.createBitmap(LAYOUT_WIDTH, 1, Bitmap.Config.ARGB_8888)) { val markers = mutableListOf() // Layout draws only the lines inside the clip; report one tall enough for every list. @@ -58,7 +63,7 @@ class MarkerCanvas : Canvas(Bitmap.createBitmap(LAYOUT_WIDTH, 1, Bitmap.Config.A paint: Paint, ) { // Bullets from depth 2 on are squares, centered like the round ones. - markers += DrawnMarker(left, (left + right) / 2f, (top + bottom) / 2f) + if (paint.color !in ignoredColors) markers += DrawnMarker(left, (left + right) / 2f, (top + bottom) / 2f) super.drawRect(left, top, right, bottom, paint) } @@ -96,9 +101,11 @@ class MarkerCanvas : Canvas(Bitmap.createBitmap(LAYOUT_WIDTH, 1, Bitmap.Config.A .apply { if (rtl) setTextDirection(TextDirectionHeuristics.RTL) } .build() - /** The markers [layout] draws, top to bottom. */ - fun drawMarkers(layout: Layout): List = - MarkerCanvas().also(layout::draw).markers.sortedWith(compareBy({ it.baseline }, { it.left })) + /** The markers [layout] draws, top to bottom, leaving out rectangles in [ignoredColors]. */ + fun drawMarkers( + layout: Layout, + ignoredColors: Set = emptySet(), + ): List = MarkerCanvas(ignoredColors).also(layout::draw).markers.sortedWith(compareBy({ it.baseline }, { it.left })) /** Every marker [layout] draws, with the text edges, direction and depth of its paragraph. */ fun markerGeometry( diff --git a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt index c68628907..d306fc37d 100644 --- a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt +++ b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt @@ -108,6 +108,20 @@ object MarkdownRenderTestSupport { taskListStyle: TaskListStyle? = null, ): StyleConfig = copyOfDefault(listStyle = listStyle, taskListStyle = taskListStyle) + /** [defaultStyle] with its [ListStyle] and [CodeBlockStyle] and, when given, its [TaskListStyle] and [BlockquoteStyle] replaced. */ + fun styleWithListAndCodeBlock( + listStyle: ListStyle, + codeBlockStyle: CodeBlockStyle, + taskListStyle: TaskListStyle? = null, + blockquoteStyle: BlockquoteStyle? = null, + ): StyleConfig = + copyOfDefault( + listStyle = listStyle, + codeBlockStyle = codeBlockStyle, + taskListStyle = taskListStyle, + blockquoteStyle = blockquoteStyle, + ) + /** [defaultStyle] with only its [TaskListStyle] replaced. */ fun styleWithTaskList(taskListStyle: TaskListStyle): StyleConfig = copyOfDefault(taskListStyle = taskListStyle)