diff --git a/.maestro/enrichedMarkdownText/flows/advanced/block_elements/ordered_list_wide_markers_test.yaml b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/ordered_list_wide_markers_test.yaml new file mode 100644 index 000000000..730e26b0e --- /dev/null +++ b/.maestro/enrichedMarkdownText/flows/advanced/block_elements/ordered_list_wide_markers_test.yaml @@ -0,0 +1,163 @@ +appId: swmansion.enriched.markdown.example +tags: + - block + - advanced + - list + - text +--- +- launchApp: + permissions: {} + +- waitForAnimationToEnd + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- assertVisible: + id: playground-screen + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + 1. First short item + 2. Second short item + 3. Third short item + +- runFlow: + when: + platform: Android + commands: + - scrollUntilVisible: + element: + id: blur-button + direction: UP + visibilityPercentage: 100 + - tapOn: + id: blur-button + +- scrollUntilVisible: + element: + id: preview-container + direction: DOWN + visibilityPercentage: 100 + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'ordered_list_wide_markers_short_control_display' + +- scrollUntilVisible: + element: + id: set-markdown-button + direction: UP + visibilityPercentage: 100 + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + 98. Before the boundary + 99. Two-digit marker + 100. Three-digit marker + 101. After the boundary + +- runFlow: + when: + platform: Android + commands: + - scrollUntilVisible: + element: + id: blur-button + direction: UP + visibilityPercentage: 100 + - tapOn: + id: blur-button + +- scrollUntilVisible: + element: + id: preview-container + direction: DOWN + visibilityPercentage: 100 + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'ordered_list_wide_markers_hundreds_display' + +- scrollUntilVisible: + element: + id: set-markdown-button + direction: UP + visibilityPercentage: 100 + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + 998. Before the boundary + 999. Three-digit marker + 1000. Four-digit marker + 1001. After the boundary + +- runFlow: + when: + platform: Android + commands: + - scrollUntilVisible: + element: + id: blur-button + direction: UP + visibilityPercentage: 100 + - tapOn: + id: blur-button + +- scrollUntilVisible: + element: + id: preview-container + direction: DOWN + visibilityPercentage: 100 + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'ordered_list_wide_markers_thousands_display' + +- scrollUntilVisible: + element: + id: set-markdown-button + direction: UP + visibilityPercentage: 100 + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + 1000. Wide parent + 1. Nested first item + 1. Deeper nested item + 2. Nested second item + 1001. Next wide parent + +- runFlow: + when: + platform: Android + commands: + - scrollUntilVisible: + element: + id: blur-button + direction: UP + visibilityPercentage: 100 + - tapOn: + id: blur-button + +- scrollUntilVisible: + element: + id: preview-container + direction: DOWN + visibilityPercentage: 100 + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'ordered_list_wide_markers_nested_display' diff --git a/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_hundreds_display.png b/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_hundreds_display.png new file mode 100644 index 000000000..e2bf93137 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_hundreds_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_nested_display.png b/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_nested_display.png new file mode 100644 index 000000000..a269cf2a3 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_nested_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_short_control_display.png b/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_short_control_display.png new file mode 100644 index 000000000..8316c5b2c Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_short_control_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_thousands_display.png b/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_thousands_display.png new file mode 100644 index 000000000..d1f269bfd Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/android/ordered_list_wide_markers_thousands_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_hundreds_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_hundreds_display.png new file mode 100644 index 000000000..dca78bab8 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_hundreds_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_nested_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_nested_display.png new file mode 100644 index 000000000..a52b7111b Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_nested_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_short_control_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_short_control_display.png new file mode 100644 index 000000000..2b25b45bf Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_short_control_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_thousands_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_thousands_display.png new file mode 100644 index 000000000..ee22c8738 Binary files /dev/null and b/.maestro/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_thousands_display.png differ diff --git a/.maestro/iosExample/enrichedMarkdownText/flows/basic/block_elements/ordered_list_wide_markers_test.yaml b/.maestro/iosExample/enrichedMarkdownText/flows/basic/block_elements/ordered_list_wide_markers_test.yaml new file mode 100644 index 000000000..3e1a0dd65 --- /dev/null +++ b/.maestro/iosExample/enrichedMarkdownText/flows/basic/block_elements/ordered_list_wide_markers_test.yaml @@ -0,0 +1,33 @@ +appId: swmansion.enriched.markdown.ios.example +tags: + - block + - list + - text +--- +- launchApp + +- runFlow: + file: '../../../../subflows/move_to_playground.yaml' + +- runFlow: + file: '../../../subflows/set_enriched_text_value.yaml' + env: + VALUE: | + 1. One + 2. Two + 3. Three + 4. Four + 5. Five + 6. Six + 7. Seven + 8. Eight + 9. Nine + 10. Ten + 11. Eleven + 12. Twelve + - Nested under a two-digit item + +- runFlow: + file: '../../../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'ordered_list_wide_markers_display' diff --git a/.maestro/iosExample/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_display.png b/.maestro/iosExample/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_display.png new file mode 100644 index 000000000..db0334747 Binary files /dev/null and b/.maestro/iosExample/enrichedMarkdownText/screenshots/ios/ordered_list_wide_markers_display.png differ diff --git a/docs/docs/android/api-reference/style-properties.md b/docs/docs/android/api-reference/style-properties.md index f734dbaa2..7dfc02153 100644 --- a/docs/docs/android/api-reference/style-properties.md +++ b/docs/docs/android/api-reference/style-properties.md @@ -236,7 +236,7 @@ Requires [`MarkdownOptions(admonitions = true)`](/android/api-reference/enriched | `bulletSize` | `Dp` | `6.dp` | Unordered list bullet size | | `markerColor` | `Color` | `#6B7280` | Ordered list number color | | `markerFontWeight` | `FontWeight` | `FontWeight.Medium` (500) | Ordered list number weight | -| `markerMinWidth` | `Dp` | `0.dp` | Minimum width reserved for the marker column | +| `markerMinWidth` | `Dp` | `0.dp` | Minimum width reserved for the marker column. An ordered list's column also grows to fit its widest number | | `gapWidth` | `Dp` | `12.dp` | Gap between the marker and the text | | `marginLeft` | `Dp` | `24.dp` | Indent applied per nesting level | diff --git a/docs/docs/ios/api-reference/style-properties.md b/docs/docs/ios/api-reference/style-properties.md index d294ee7c3..239fd7c87 100644 --- a/docs/docs/ios/api-reference/style-properties.md +++ b/docs/docs/ios/api-reference/style-properties.md @@ -256,7 +256,7 @@ Ordered and unordered lists, and their markers. Takes the [shared modifiers](#sh | `.bulletColor(_:)` | `Color \| semantic` | `.secondary` | The dot drawn for an unordered item | | `.bulletSize(_:)` | `CGFloat` | `6` | Diameter of that dot, in points | | `.markerColor(_:)` | `Color \| semantic` | `.secondary` | The `1.` `2.` `3.` of an ordered item | -| `.markerMinWidth(_:)` | `CGFloat` | `0` | Minimum width reserved for the marker column, so numbers past `9.` do not shift the text. `0` sizes the column to each marker | +| `.markerMinWidth(_:)` | `CGFloat` | `0` | Minimum width reserved for the marker column. An ordered list's column also grows to fit its widest number, so its markers are never clipped | | `.gapWidth(_:)` | `CGFloat` | `12` | Space between the marker and the item text. Clamped to a minimum of `4` | | `.marginLeading(_:)` | `CGFloat` | `24` | Indent added per nesting level | diff --git a/docs/docs/react-native/api-reference/style-properties.md b/docs/docs/react-native/api-reference/style-properties.md index 55e477e5c..a83eae0d1 100644 --- a/docs/docs/react-native/api-reference/style-properties.md +++ b/docs/docs/react-native/api-reference/style-properties.md @@ -254,7 +254,7 @@ Requires [`flavor="github"`](/react-native/api-reference/enriched-markdown-text# | ------------------ | -------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `bulletColor` | `string` | `#6B7280` | Bullet point color | | `bulletSize` | `number` | `6` | Bullet point size | -| `markerMinWidth` | `number` | `0` | Minimum reserved marker column width (floors the natural width of every list type) | +| `markerMinWidth` | `number` | `0` | Minimum reserved marker column width (floors the natural width of every list type). An ordered list's column also grows to fit its widest number | | `markerColor` | `string` | `#6B7280` | Number marker color | | `markerFontWeight` | `string` | `'500'` | Number marker font weight | | `gapWidth` | `number` | `12` | Gap between marker and text | 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 89c37ece7..7cab2df14 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 @@ -41,6 +41,9 @@ class BlockStyleContext { var listDepth = 0 var listType: ListType? = null var listItemNumber = 0 + var listMarkerExtra = 0f + var ancestorMarkerOffset = 0f + var listItemMarkerExtra = 0f var taskItemCount = 0 private val orderedListItemNumbers = ArrayDeque() @@ -149,6 +152,9 @@ class BlockStyleContext { listDepth = 0 listType = null listItemNumber = 0 + listMarkerExtra = 0f + ancestorMarkerOffset = 0f + listItemMarkerExtra = 0f taskItemCount = 0 orderedListItemNumbers.clear() } diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt index 567cc79dc..f8b81399e 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt @@ -12,6 +12,8 @@ import com.swmansion.enriched.markdown.styles.ListStyle * allowing each level to maintain its own counter while preserving parent's position. * - **Parent context restoration**: Restores parent list's style when exiting nested lists * so subsequent parent items render correctly. + * - **Marker column**: An ordered list widens its `99.` column to fit its widest marker, and a + * nested list inherits the widening of the item it sits in. */ class ListContextManager( private val context: BlockStyleContext, @@ -24,6 +26,8 @@ class ListContextManager( val previousDepth: Int, val parentListType: BlockStyleContext.ListType?, val wasNestedInOrderedList: Boolean, + val previousMarkerExtra: Float, + val previousMarkerOffset: Float, ) /** @@ -32,10 +36,12 @@ class ListContextManager( * - Incrementing list depth * - Setting the appropriate list style * - Resetting item number for the new list + * - Widening the marker column by [markerExtraWidth] and shifting a nested list by its parent item's widening */ fun enterList( listType: BlockStyleContext.ListType, style: ListStyle, + markerExtraWidth: Float = 0f, ): ListEntryState { val previousDepth = context.listDepth val isNested = previousDepth > 0 @@ -60,10 +66,19 @@ class ListContextManager( } context.resetListItemNumber() + val previousMarkerExtra = context.listMarkerExtra + val previousMarkerOffset = context.ancestorMarkerOffset + if (isNested) { + context.ancestorMarkerOffset += context.listItemMarkerExtra + } + context.listMarkerExtra = markerExtraWidth + return ListEntryState( previousDepth = previousDepth, parentListType = parentListType, wasNestedInOrderedList = isNested && parentIsOrdered, + previousMarkerExtra = previousMarkerExtra, + previousMarkerOffset = previousMarkerOffset, ) } @@ -73,6 +88,7 @@ class ListContextManager( * - Decrementing list depth back to previousDepth * - Restoring parent list item numbers from stack (if applicable) * - Restoring parent list metadata (if nested) so subsequent parent items render correctly + * - Restoring the parent list's marker column and offset */ fun exitList(entryState: ListEntryState) { context.listDepth = entryState.previousDepth @@ -85,5 +101,8 @@ class ListContextManager( if (entryState.previousDepth > 0) { context.listType = entryState.parentListType } + + context.listMarkerExtra = entryState.previousMarkerExtra + context.ancestorMarkerOffset = entryState.previousMarkerOffset } } 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 541d66f51..5878fe6c0 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 @@ -37,18 +37,8 @@ class ListItemRenderer( // indices, matching the top-down order of `- [ ]` markers in the source. val taskIndex = if (isTask) styleContext.taskItemCount++ else -1 - factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) - - if (builder.length == start || builder.substring(start).isBlank()) return - - while (builder.length > start && builder.last() == '\n') { - builder.delete(builder.length - 1, builder.length) - } - builder.append("\n") - val depth = styleContext.listDepth - 1 val listStyle = config.style.listStyle - val itemEnd = builder.length val span = if (isTask) { TaskListSpan( @@ -59,21 +49,53 @@ class ListItemRenderer( styleCache = factory.styleCache, taskIndex = taskIndex, isChecked = isChecked, + ancestorMarkerOffset = styleContext.ancestorMarkerOffset, + markerExtraWidth = styleContext.listMarkerExtra, ) } else { when (listType) { BlockStyleContext.ListType.UNORDERED -> { - UnorderedListSpan(listStyle, depth, factory.context, factory.styleCache) + UnorderedListSpan( + listStyle, + depth, + factory.context, + factory.styleCache, + ancestorMarkerOffset = styleContext.ancestorMarkerOffset, + ) } BlockStyleContext.ListType.ORDERED -> { - OrderedListSpan(listStyle, depth, factory.context, factory.styleCache).apply { + OrderedListSpan( + listStyle, + depth, + factory.context, + factory.styleCache, + markerExtraWidth = styleContext.listMarkerExtra, + ancestorMarkerOffset = styleContext.ancestorMarkerOffset, + ).apply { setItemNumber(styleContext.listItemNumber) } } } } + // Nested lists inside this item shift by its extra marker width. + val previousMarkerExtra = styleContext.listItemMarkerExtra + styleContext.listItemMarkerExtra = span.markerExtraWidth + try { + factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) + } finally { + styleContext.listItemMarkerExtra = previousMarkerExtra + } + + if (builder.length == start || builder.substring(start).isBlank()) return + + while (builder.length > start && builder.last() == '\n') { + builder.delete(builder.length - 1, builder.length) + } + builder.append("\n") + + val itemEnd = builder.length builder.setSpan(span, start, itemEnd, SPAN_FLAGS_LIST_ITEM) if (isChecked) { diff --git a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt index e3e5f4f03..077824b16 100644 --- a/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt +++ b/packages/enriched-markdown-android/ui/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt @@ -3,6 +3,8 @@ package com.swmansion.enriched.markdown.renderer import android.text.SpannableStringBuilder import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.spans.MarginBottomSpan +import com.swmansion.enriched.markdown.spans.OrderedListSpan +import com.swmansion.enriched.markdown.styles.ListStyle import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE import com.swmansion.enriched.markdown.utils.text.span.applyLineHeightSkippingImages import com.swmansion.enriched.markdown.utils.text.span.applyMarginTop @@ -22,8 +24,9 @@ class ListRenderer( val listStyle = config.style.listStyle val listType = if (isOrdered) BlockStyleContext.ListType.ORDERED else BlockStyleContext.ListType.UNORDERED + val markerExtraWidth = if (isOrdered) markerExtraWidth(node, listStyle, factory) else 0f val contextManager = ListContextManager(factory.blockStyleContext) - val entryState = contextManager.enterList(listType, listStyle) + val entryState = contextManager.enterList(listType, listStyle, markerExtraWidth) // For top-level lists, insert marginTop spacer before rendering content if (entryState.previousDepth == 0) { @@ -49,6 +52,22 @@ class ListRenderer( } } + /** Lists under 100 keep the `99.` column, so only numbers from 100 on widen it. Task items draw a checkbox. */ + private fun markerExtraWidth( + node: MarkdownASTNode, + listStyle: ListStyle, + factory: RendererFactory, + ): Float { + val wideNumbers = mutableListOf() + var number = 1 + for (item in node.children) { + if (item.type != MarkdownASTNode.NodeType.ListItem) continue + if (number >= 100 && item.attributes["isTask"] != "true") wideNumbers += number + number++ + } + return OrderedListSpan.measureExtraWidth(listStyle, factory.context, wideNumbers) + } + private fun applyListSpacing( builder: SpannableStringBuilder, start: Int, 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 fea4aa03e..cffeb499a 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 ancestorMarkerOffset: Float = 0f, ) : MetricAffectingSpan(), LeadingMarginSpan { private var cachedText: CharSequence? = null @@ -37,6 +38,9 @@ abstract class BaseListSpan( protected abstract fun getMarkerWidth(): Float + /** How far an ordered list widened this item's column past the one short lists reserve. */ + internal open val markerExtraWidth: Float = 0f + override fun updateMeasureState(textPaint: TextPaint) = applyTextStyle(textPaint) override fun updateDrawState(textPaint: TextPaint) = applyTextStyle(textPaint) @@ -46,7 +50,7 @@ abstract class BaseListSpan( val effectiveGap = gapWidth.coerceAtLeast(DEFAULT_MIN_GAP) (getMarkerWidth() + effectiveGap).toInt() } else { - marginLeft.toInt() + (marginLeft + markerExtraWidth).toInt() } override fun drawLeadingMargin( 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 e134bb669..2d1130e5d 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 @@ -10,12 +10,15 @@ import com.swmansion.enriched.markdown.renderer.BlockStyle import com.swmansion.enriched.markdown.renderer.SpanStyleCache import com.swmansion.enriched.markdown.styles.ListStyle import com.swmansion.enriched.markdown.utils.text.TypefaceUtils +import kotlin.math.ceil class OrderedListSpan( private val listStyle: ListStyle, depth: Int, context: Context, styleCache: SpanStyleCache, + override val markerExtraWidth: Float = 0f, + ancestorMarkerOffset: Float = 0f, ) : BaseListSpan( depth = depth, context = context, @@ -29,30 +32,52 @@ class OrderedListSpan( ), marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, + ancestorMarkerOffset = ancestorMarkerOffset, ) { companion object { private val sharedMarkerPaint = TextPaint().apply { isAntiAlias = true } - } - - private val markerTypeface: Typeface = - TypefaceUtils.applyStyles( - context = context, - fontFamily = listStyle.fontFamily.takeIf { it.isNotEmpty() }, - fontWeight = listStyle.markerFontWeight, - ) - private fun configureMarkerPaint(): TextPaint = - sharedMarkerPaint.apply { - textSize = listStyle.fontSize - color = listStyle.markerColor - typeface = markerTypeface + /** + * How far the `99.` column every ordered list reserves must grow to fit each of [numbers]. + * Whole pixels, so the Int leading margins and the float marker positions agree at every depth. + */ + fun measureExtraWidth( + listStyle: ListStyle, + context: Context, + numbers: Iterable, + ): Float { + val paint = markerPaint(listStyle, markerTypeface(listStyle, context)) + val default = paint.measureText("99.") + val widest = numbers.fold(default) { width, number -> maxOf(width, paint.measureText("$number.")) } + return ceil(listStyle.effectiveMarkerWidth(widest) - listStyle.effectiveMarkerWidth(default)) } - override fun getMarkerWidth(): Float { - val paint = configureMarkerPaint() - return listStyle.effectiveMarkerWidth(paint.measureText("99.")) + private fun markerTypeface( + listStyle: ListStyle, + context: Context, + ): Typeface = + TypefaceUtils.applyStyles( + context = context, + fontFamily = listStyle.fontFamily.takeIf { it.isNotEmpty() }, + fontWeight = listStyle.markerFontWeight, + ) + + private fun markerPaint( + listStyle: ListStyle, + typeface: Typeface, + ): TextPaint = + sharedMarkerPaint.apply { + textSize = listStyle.fontSize + color = listStyle.markerColor + this.typeface = typeface + } } + private val markerTypeface = markerTypeface(listStyle, context) + private val defaultMarkerWidth = listStyle.effectiveMarkerWidth(markerPaint(listStyle, markerTypeface).measureText("99.")) + + override fun getMarkerWidth(): Float = defaultMarkerWidth + markerExtraWidth + var itemNumber: Int = 1 private set @@ -67,11 +92,11 @@ class OrderedListSpan( layout: Layout?, start: Int, ) { - val markerPaint = configureMarkerPaint() + val markerPaint = markerPaint(listStyle, markerTypeface) val text = if (dir < 0) ".$itemNumber" else "$itemNumber." val textWidth = markerPaint.measureText(text) - val markerRightEdge = x + (depth * marginLeft + getMarkerWidth()) * dir + val markerRightEdge = x + (depth * marginLeft + ancestorMarkerOffset + getMarkerWidth()) * dir val markerX = if (dir > 0) markerRightEdge - textWidth else markerRightEdge canvas.drawText(text, markerX, baseline.toFloat(), markerPaint) 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 2784850b3..0c269f96f 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,8 @@ class TaskListSpan( * moves it to the end of that order and shifts where its marker lands. */ var isChecked: Boolean, + ancestorMarkerOffset: Float = 0f, + override val markerExtraWidth: Float = 0f, ) : BaseListSpan( depth = depth, context = context, @@ -39,6 +41,7 @@ class TaskListSpan( ), marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, + ancestorMarkerOffset = ancestorMarkerOffset, ) { companion object { private val boxPaint = @@ -58,7 +61,7 @@ class TaskListSpan( } private val checkboxSize = taskStyle.checkboxSize - private val markerColumnWidth = listStyle.effectiveMarkerWidth(checkboxSize) + private val markerColumnWidth = listStyle.effectiveMarkerWidth(checkboxSize) + markerExtraWidth private val cornerRadius = taskStyle.checkboxBorderRadius private val rect = RectF() private val insetRect = RectF() @@ -79,7 +82,7 @@ class TaskListSpan( ) { val capHeight = -markerFontMetrics.ascent * CAP_HEIGHT_RATIO val centerY = baseline - capHeight / HALF_DIVISOR - val centerX = x + (depth * marginLeft + markerColumnWidth - checkboxSize / HALF_DIVISOR) * dir + val centerX = x + (depth * marginLeft + ancestorMarkerOffset + markerColumnWidth - checkboxSize / HALF_DIVISOR) * dir val half = checkboxSize / HALF_DIVISOR rect.set(centerX - half, centerY - half, centerX + half, centerY + half) 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 2b3465aa5..a5df5b519 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, + ancestorMarkerOffset: Float = 0f, ) : BaseListSpan( depth = depth, context = context, @@ -26,6 +27,7 @@ class UnorderedListSpan( ), marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, + ancestorMarkerOffset = ancestorMarkerOffset, ) { companion object { private val sharedBulletPaint = @@ -63,7 +65,7 @@ class UnorderedListSpan( // it hugs the gap before the text — matches iOS behavior and stays // visually flush-left when the column width equals the bullet radius // (the default). - val bulletX = x + (depth * marginLeft + markerColumnWidth) * dir + val bulletX = x + (depth * marginLeft + ancestorMarkerOffset + markerColumnWidth) * dir val fontMetrics = paint.fontMetrics val bulletY = baseline + (fontMetrics.ascent + fontMetrics.descent) / 2f diff --git a/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ListRenderingTest.kt b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ListRenderingTest.kt new file mode 100644 index 000000000..09a7eadb6 --- /dev/null +++ b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/ListRenderingTest.kt @@ -0,0 +1,230 @@ +package com.swmansion.enriched.markdown + +import android.graphics.Typeface +import android.text.SpannableString +import android.text.TextPaint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.spans.BaseListSpan +import com.swmansion.enriched.markdown.spans.OrderedListSpan +import com.swmansion.enriched.markdown.styles.StyleConfig +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.MarkerGeometry +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.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.math.ceil + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [28]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ListRenderingTest { + @Test + fun shortListsKeepTheirIndentAndLongListsShareTheirWidestGutter() { + val style = styleConfig() + val paint = + TextPaint().apply { + textSize = style.listStyle.fontSize + typeface = Typeface.create("monospace", Typeface.NORMAL) + } + val baseline = paint.measureText("99.") + val gap = style.listStyle.gapWidth + val short = orderedSpans(style, numbered(99)) + short.forEach { assertEquals((baseline + gap).toInt(), it.getLeadingMargin(false)) } + for (count in listOf(100, 101, 250, 1000)) { + val spans = orderedSpans(style, numbered(count)) + val widest = maxOf(baseline, spans.maxOf { paint.measureText("${it.itemNumber}.") }) + spans.forEach { assertEquals((baseline + ceil(widest - baseline) + gap).toInt(), it.getLeadingMargin(false)) } + assertTrue(spans.first().getLeadingMargin(false) > short.first().getLeadingMargin(false)) + } + } + + @Test + fun nestedAndFollowingListsRestoreTheirOwnGutters() { + val style = styleConfig() + val outer = withChild(numbered(101), 0, numbered(1000)) + val spans = orderedSpans(style, outer, numbered(1)) + val parent = spans.filter { it.depth == 0 }.dropLast(1) + assertEquals(101, parent.size) + parent.forEach { assertEquals(parent.first().getLeadingMargin(false), it.getLeadingMargin(false)) } + spans.filter { it.depth == 1 }.forEach { assertTrue(it.getLeadingMargin(false) > style.listStyle.marginLeft) } + val following = spans.last() + assertEquals(0, following.depth) + assertTrue(following.getLeadingMargin(false) < parent.first().getLeadingMargin(false)) + } + + @Test + fun markerMinimumWidthRemainsAFloor() { + val style = styleConfig(minWidth = 200f) + orderedSpans(style, numbered(101)).forEach { + assertEquals((style.listStyle.markerMinWidth + style.listStyle.gapWidth).toInt(), it.getLeadingMargin(false)) + } + } + + @Test + fun drawnMarkersStayInsideTheGutterAndAttachedToTheirParagraphs() { + val style = styleConfig() + for (count in listOf(4, 101, 1000)) { + val threeLevels = withChild(numbered(1), 0, numbered(1)) + drawMarkers(style, withChild(numbered(count), 2, threeLevels)).forEach { geometry -> + assertEquals( + "marker right at depth ${geometry.depth} in a list of $count", + geometry.paragraphLeft - style.listStyle.gapWidth, + geometry.marker.right, + 0.01f, + ) + } + } + } + + @Test + fun nestedBulletColumnsKeepTheirGapUnderWideParents() { + val style = styleConfig() + + fun tree(count: Int) = withChild(numbered(count), 2, unorderedList(item(), item())) + for (count in listOf(101, 1000)) { + assertNestedMarkerGapsUnchanged(style, tree(4), tree(count)) + } + } + + @Test + fun tasksMoveWithTheirWideNumberedSiblings() { + val style = styleConfig() + + fun tree(count: Int): MarkdownASTNode { + val items = numbered(count).children.toMutableList() + items[1] = taskListItem(checked = false, paragraph(text("task")), numbered(1)) + items[2] = listItem(paragraph(text("item")), numbered(1)) + return orderedList(*items.toTypedArray()) + } + assertEveryRowShiftsTogether(style, tree(4), tree(1000)) + } + + @Test + fun nestedTasksKeepTheirGapUnderWideNumericParents() { + val style = styleConfig() + + fun tree(count: Int) = withChild(numbered(count), 2, orderedList(taskListItem(checked = false, paragraph(text("task"))))) + for (count in listOf(101, 1000)) { + assertNestedMarkerGapsUnchanged(style, tree(4), tree(count)) + } + } + + @Test + fun inheritedMarkerOffsetsFollowTheRtlDirection() { + val style = styleConfig() + val text = SpannableString(render(document(withChild(numbered(1000), 0, numbered(1))), style)) + val layout = MarkerCanvas.layout(text, style.listStyle.fontSize, rtl = true) + val markers = MarkerCanvas.drawMarkers(layout) + assertEquals(1001, markers.size) + markers.forEach { marker -> + val line = layout.getLineForVertical(marker.baseline.toInt()) + assertEquals(-1, layout.getParagraphDirection(line)) + assertEquals(layout.getParagraphRight(line) + style.listStyle.gapWidth, marker.left, 0.01f) + assertTrue(marker.right <= layout.width) + } + } + + /** The rows [wide] shares with [short], tasks and their children included, all sit [wide]'s widening further right. */ + private fun assertEveryRowShiftsTogether( + style: StyleConfig, + short: MarkdownASTNode, + wide: MarkdownASTNode, + ) { + val shortRows = drawMarkers(style, short) + val wideRows = drawMarkers(style, wide).take(shortRows.size) + assertEquals(shortRows.size, wideRows.size) + val shift = wideRows.first().paragraphLeft - shortRows.first().paragraphLeft + assertTrue(shift > 0f) + wideRows.zip(shortRows).forEach { (actual, control) -> + assertEquals(control.depth, actual.depth) + assertEquals("text shift at depth ${actual.depth}", shift, actual.paragraphLeft - control.paragraphLeft, 0.01f) + assertEquals( + "marker gap at depth ${actual.depth}", + control.paragraphLeft - control.marker.right, + actual.paragraphLeft - actual.marker.right, + 0.01f, + ) + } + } + + private fun assertNestedMarkerGapsUnchanged( + style: StyleConfig, + short: MarkdownASTNode, + wide: MarkdownASTNode, + ) { + val shortMarkers = drawMarkers(style, short).filter { it.depth > 0 } + val wideMarkers = drawMarkers(style, wide).filter { it.depth > 0 } + assertTrue(shortMarkers.isNotEmpty()) + assertEquals(shortMarkers.size, wideMarkers.size) + shortMarkers.zip(wideMarkers).forEach { (control, actual) -> + assertEquals(control.depth, actual.depth) + assertEquals( + "nested marker gap at depth ${actual.depth}", + control.paragraphLeft - control.marker.right, + actual.paragraphLeft - actual.marker.right, + 0.01f, + ) + } + } + + private fun drawMarkers( + style: StyleConfig, + vararg lists: MarkdownASTNode, + ): List { + // SpannableString reports spans in insertion order, as the text view does. + val text = SpannableString(render(document(*lists), style)) + val geometry = MarkerCanvas.markerGeometry(text, MarkerCanvas.layout(text, style.listStyle.fontSize)) + assertEquals(text.getSpans(0, text.length, BaseListSpan::class.java).size, geometry.size) + geometry.forEach { assertTrue("marker left ${it.marker.left} at depth ${it.depth}", it.marker.left >= 0f) } + return geometry + } + + private fun orderedSpans( + style: StyleConfig, + vararg lists: MarkdownASTNode, + ): List { + val text = render(document(*lists), style) + return text + .getSpans(0, text.length, OrderedListSpan::class.java) + .sortedWith(compareBy({ text.getSpanStart(it) }, { it.depth })) + } + + private fun item(): MarkdownASTNode = listItem(paragraph(text("item"))) + + private fun numbered(count: Int): MarkdownASTNode = orderedList(*Array(count) { item() }) + + private fun withChild( + list: MarkdownASTNode, + index: Int, + child: MarkdownASTNode, + ): MarkdownASTNode { + val items = list.children.toMutableList() + items[index] = items[index].copy(children = items[index].children + child) + return list.copy(children = items) + } + + private fun styleConfig(minWidth: Float = 0f): StyleConfig { + val base = MarkdownRenderTestSupport.defaultStyle + return MarkdownRenderTestSupport.styleWithList( + base.listStyle.copy( + fontFamily = "monospace", + markerMinWidth = minWidth, + gapWidth = 8f, + marginLeft = 24f, + ), + ) + } +} 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 new file mode 100644 index 000000000..7383db048 --- /dev/null +++ b/packages/enriched-markdown-android/ui/src/test/java/com/swmansion/enriched/markdown/test/ListMarkerCanvas.kt @@ -0,0 +1,101 @@ +package com.swmansion.enriched.markdown.test + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.text.Layout +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextDirectionHeuristics +import android.text.TextPaint +import com.swmansion.enriched.markdown.spans.BaseListSpan + +/** A list marker as the layout drew it: a number, a bullet, or a checkbox. */ +data class DrawnMarker( + val left: Float, + val right: Float, + val baseline: Float, +) + +/** A drawn marker with the text edge and list depth of the paragraph it belongs to. */ +data class MarkerGeometry( + val marker: DrawnMarker, + val paragraphLeft: Float, + val depth: Int, +) + +/** Records the list markers a layout draws instead of painting them. */ +class MarkerCanvas : 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. + override fun getClipBounds(bounds: Rect): Boolean { + bounds.set(0, 0, LAYOUT_WIDTH, Int.MAX_VALUE / 2) + return true + } + + override fun drawCircle( + cx: Float, + cy: Float, + radius: Float, + paint: Paint, + ) { + // Bullets are centered on the reserved column edge, matching the iOS drawer. + val outerRadius = radius + paint.strokeWidth / 2f + markers += DrawnMarker(cx - outerRadius, cx, cy) + super.drawCircle(cx, cy, radius, paint) + } + + override fun drawRoundRect( + rect: RectF, + rx: Float, + ry: Float, + paint: Paint, + ) { + val halfStroke = if (paint.style == Paint.Style.STROKE) paint.strokeWidth / 2f else 0f + markers += DrawnMarker(rect.left - halfStroke, rect.right + halfStroke, rect.centerY()) + super.drawRoundRect(rect, rx, ry, paint) + } + + override fun drawText( + text: String, + x: Float, + y: Float, + paint: Paint, + ) { + if (text.matches(Regex("(?:[0-9]+\\.|\\.[0-9]+)"))) markers += DrawnMarker(x, x + paint.measureText(text), y) + super.drawText(text, x, y, paint) + } + + companion object { + const val LAYOUT_WIDTH = 2000 + + fun layout( + text: CharSequence, + textSize: Float, + rtl: Boolean = false, + ): StaticLayout = + StaticLayout.Builder + .obtain(text, 0, text.length, TextPaint().apply { this.textSize = textSize }, LAYOUT_WIDTH) + .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 })) + + /** Every marker [layout] draws, with the text edge and depth of its paragraph. */ + fun markerGeometry( + text: Spanned, + layout: Layout, + ): List = + drawMarkers(layout).map { marker -> + val line = layout.getLineForVertical(marker.baseline.toInt()) + val start = layout.getLineStart(line) + val depth = text.getSpans(start, start + 1, BaseListSpan::class.java).maxOf { it.depth } + MarkerGeometry(marker, layout.getParagraphLeft(line).toFloat(), depth) + } + } +} 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 9a03ee04b..06a22103f 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 @@ -16,6 +16,7 @@ import com.swmansion.enriched.markdown.styles.CodeStyle import com.swmansion.enriched.markdown.styles.HeadingStyle import com.swmansion.enriched.markdown.styles.HighlightStyle import com.swmansion.enriched.markdown.styles.LinkStyle +import com.swmansion.enriched.markdown.styles.ListStyle import com.swmansion.enriched.markdown.styles.ParagraphStyle import com.swmansion.enriched.markdown.styles.SpoilerStyle import com.swmansion.enriched.markdown.styles.StrikethroughStyle @@ -101,6 +102,9 @@ object MarkdownRenderTestSupport { ) } + /** [defaultStyle] with only its [ListStyle] replaced. */ + fun styleWithList(listStyle: ListStyle): StyleConfig = copyOfDefault(listStyle = listStyle) + /** [defaultStyle] with only its [TaskListStyle] replaced. */ fun styleWithTaskList(taskListStyle: TaskListStyle): StyleConfig = copyOfDefault(taskListStyle = taskListStyle) @@ -111,6 +115,7 @@ object MarkdownRenderTestSupport { strikethroughStyle: StrikethroughStyle? = null, underlineStyle: UnderlineStyle? = null, highlightStyle: HighlightStyle? = null, + listStyle: ListStyle? = null, taskListStyle: TaskListStyle? = null, blockquoteStyle: BlockquoteStyle? = null, codeStyle: CodeStyle? = null, @@ -137,7 +142,7 @@ object MarkdownRenderTestSupport { imageStyle = base.imageStyle, inlineImageStyle = base.inlineImageStyle, blockquoteStyle = blockquoteStyle ?: base.blockquoteStyle, - listStyle = base.listStyle, + listStyle = listStyle ?: base.listStyle, taskListStyle = taskListStyle ?: base.taskListStyle, codeBlockStyle = codeBlockStyle ?: base.codeBlockStyle, thematicBreakStyle = base.thematicBreakStyle, diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/RenderContext.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/RenderContext.swift index 52185914f..623bacd8f 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/RenderContext.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/RenderContext.swift @@ -43,6 +43,9 @@ enum MarkdownAttribute { /// Leading inset of a quote's bars and fill, set on quote paragraphs /// that sit inside a list item (the item's text column). static let blockquoteBarOffset = NSAttributedString.Key("EnrichedMarkdownBlockquoteBarOffset") + /// Marker widening already applied to a quote paragraph by the list items + /// it sits in, so an enclosing item's bar offset keeps it. + static let blockquoteMarkerExtra = NSAttributedString.Key("EnrichedMarkdownBlockquoteMarkerExtra") /// Present on blockquote paragraphs inside a GitHub admonition: the bar /// color of each nesting level, outermost first. static let blockquoteBarColors = NSAttributedString.Key("EnrichedMarkdownBlockquoteBarColors") @@ -76,6 +79,12 @@ package final class RenderContext { var listDepth = 0 var listType: ListType = .unordered var listItemNumber = 0 + /// Marker column of the current ordered list, sized to its widest number. + var listMarkerWidth: CGFloat = 0 + /// Extra marker width of the enclosing list items, carried into nested lists. + var ancestorMarkerOffset: CGFloat = 0 + /// Extra marker width of the list item whose children are rendering. + var listItemMarkerExtra: CGFloat = 0 var taskItemIndex = 0 var rendersBlockImage = false /// Set once a superscript or subscript run is rendered, so the post-pass diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/BlockquoteRenderer.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/BlockquoteRenderer.swift index 27966a2c9..ce9fbc986 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/BlockquoteRenderer.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/BlockquoteRenderer.swift @@ -27,11 +27,22 @@ final class BlockquoteRenderer: NodeRenderer { if let admonition { appendHeader(for: admonition, font: font, to: output) } + // An enclosing list item shifts the whole quote by its own indent, which + // already includes any widened marker column, so lists inside start fresh. + let prevMarkerOffset = context.ancestorMarkerOffset + let prevItemMarkerExtra = context.listItemMarkerExtra + context.ancestorMarkerOffset = 0 + context.listItemMarkerExtra = 0 factory.renderChildren(of: node, into: output, context: context) + context.ancestorMarkerOffset = prevMarkerOffset + context.listItemMarkerExtra = prevItemMarkerExtra context.clearBlockStyle() context.exitBlockquote() guard output.length > start else { return } + // Bars share one offset per paragraph, so widening from lists inside this + // quote must not move this quote's own bar when an outer item shifts it. + output.removeAttribute(MarkdownAttribute.blockquoteMarkerExtra, range: NSRange(location: start, length: output.length - start)) applyStylingAndSpacing(to: output, start: start, end: output.length, levels: levels) } diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ListItemRenderer.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ListItemRenderer.swift index c779225fc..cf1590cf5 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ListItemRenderer.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ListItemRenderer.swift @@ -22,20 +22,27 @@ final class ListItemRenderer: NodeRenderer { context.taskItemIndex += 1 } + let baseMarkerWidth = isTask + ? effectiveTaskMarkerWidth(in: context) + : effectiveMarkerWidth(in: context) + // How far a widened ordered list grew this item's own column; nested lists move by it. + let markerExtra = baseMarkerWidth - defaultColumnWidth(isTask: isTask, listType: context.listType) + let startLocation = output.length + let prevMarkerExtra = context.listItemMarkerExtra + context.listItemMarkerExtra = markerExtra factory.renderChildren(of: node, into: output, context: context) + context.listItemMarkerExtra = prevMarkerExtra ParagraphStyleHelpers.ensureTrailingNewline(in: output) let itemRange = NSRange(location: startLocation, length: output.length - startLocation) guard itemRange.length > 0 else { return } - let baseMarkerWidth = isTask - ? effectiveTaskMarkerWidth(for: context.listType) - : effectiveMarkerWidth(for: context.listType) let gapWidth = max(config.list.gapWidth ?? 12, 4) let marginLeading = config.list.marginLeading ?? 24 let blockquoteIndent = CGFloat(context.blockquoteDepth) * blockquoteLevelSpacing() let totalIndent = blockquoteIndent + baseMarkerWidth + gapWidth + (CGFloat(nestingLevel) * marginLeading) + + context.ancestorMarkerOffset let lineHeight = config.list.lineHeight ?? 0 let metadata: [NSAttributedString.Key: Any] = [ @@ -49,7 +56,11 @@ final class ListItemRenderer: NodeRenderer { itemRange: itemRange, nestingLevel: nestingLevel, metadata: metadata, - totalIndent: totalIndent, + indents: ItemIndents( + text: totalIndent, + quote: totalIndent - context.ancestorMarkerOffset, + markerExtra: markerExtra + ), lineHeight: lineHeight ) @@ -64,20 +75,23 @@ final class ListItemRenderer: NodeRenderer { } } - private func effectiveMarkerWidth(for listType: ListType) -> CGFloat { - let minWidth = config.list.markerMinWidth ?? 0 - switch listType { - case .ordered: - return max(minWidth, 20) - case .unordered: - return max(minWidth, config.list.bulletSize ?? 6) - } + /// An ordered list's column is sized to its widest number by `ListRenderer`. + private func effectiveMarkerWidth(in context: RenderContext) -> CGFloat { + context.listType == .ordered + ? context.listMarkerWidth + : config.list.defaultMarkerColumnWidth(for: .unordered) + } + + /// The item's column before an ordered list widens it for its numbers. + private func defaultColumnWidth(isTask: Bool, listType: ListType) -> CGFloat { + let markerWidth = config.list.defaultMarkerColumnWidth(for: listType) + return isTask ? max(markerWidth, config.taskList.checkboxSize ?? 14) : markerWidth } - /// The checkbox column is at least as wide as the list type's normal - /// marker column, so task items align with their non-task siblings. - private func effectiveTaskMarkerWidth(for listType: ListType) -> CGFloat { - max(effectiveMarkerWidth(for: listType), config.taskList.checkboxSize ?? 14) + /// The checkbox column is at least as wide as the list's marker column, + /// so task items align with their non-task siblings. + private func effectiveTaskMarkerWidth(in context: RenderContext) -> CGFloat { + max(effectiveMarkerWidth(in: context), config.taskList.checkboxSize ?? 14) } private func blockquoteLevelSpacing() -> CGFloat { @@ -139,7 +153,7 @@ final class ListItemRenderer: NodeRenderer { itemRange: NSRange, nestingLevel: Int, metadata: [NSAttributedString.Key: Any], - totalIndent: CGFloat, + indents: ItemIndents, lineHeight: CGFloat ) { let string = output.string as NSString @@ -153,7 +167,7 @@ final class ListItemRenderer: NodeRenderer { location = NSMaxRange(applyRange) if output.attribute(MarkdownAttribute.blockquoteDepth, at: applyRange.location, effectiveRange: nil) != nil { - shiftQuoteParagraph(in: output, range: applyRange, by: totalIndent) + shiftQuoteParagraph(in: output, range: applyRange, indents: indents) continue } if shouldSkipListStyling(in: output, range: applyRange, nestingLevel: nestingLevel) { @@ -161,8 +175,8 @@ final class ListItemRenderer: NodeRenderer { } let style = NSMutableParagraphStyle() - style.firstLineHeadIndent = totalIndent - style.headIndent = totalIndent + style.firstLineHeadIndent = indents.text + style.headIndent = indents.text if lineHeight > 0 { style.minimumLineHeight = lineHeight @@ -180,13 +194,20 @@ final class ListItemRenderer: NodeRenderer { } /// A quote inside the item keeps its own attributes and indent, moved - /// into the item's text column together with its bars. - private func shiftQuoteParagraph(in output: NSMutableAttributedString, range: NSRange, by offset: CGFloat) { + /// into the item's text column together with its bars. The bars also keep + /// the marker widening of any list item between this one and the quote. + private func shiftQuoteParagraph(in output: NSMutableAttributedString, range: NSRange, indents: ItemIndents) { + let innerExtra = output.attribute(MarkdownAttribute.blockquoteMarkerExtra, at: range.location, effectiveRange: nil) + as? CGFloat ?? 0 let style = ParagraphStyleHelpers.getOrCreateParagraphStyle(in: output, at: range.location) - style.firstLineHeadIndent += offset - style.headIndent += offset + style.firstLineHeadIndent += indents.quote + style.headIndent += indents.quote output.addAttributes( - [.paragraphStyle: style, MarkdownAttribute.blockquoteBarOffset: offset], + [ + .paragraphStyle: style, + MarkdownAttribute.blockquoteBarOffset: indents.quote + innerExtra, + MarkdownAttribute.blockquoteMarkerExtra: innerExtra + indents.markerExtra + ], range: range ) } @@ -216,3 +237,25 @@ final class ListItemRenderer: NodeRenderer { ).location == NSNotFound } } + +/// Where a list item moves its own paragraphs and the quotes inside it. +private struct ItemIndents { + let text: CGFloat + /// Each enclosing item shifts a quote too, adding its own widened column then. + let quote: CGFloat + /// How far a widened ordered list grew this item's marker column. + let markerExtra: CGFloat +} + +extension ListStyle { + /// The marker column before an ordered list widens it for its numbers. + func defaultMarkerColumnWidth(for listType: ListType) -> CGFloat { + let minWidth = markerMinWidth ?? 0 + switch listType { + case .ordered: + return max(minWidth, 20) + case .unordered: + return max(minWidth, bulletSize ?? 6) + } + } +} diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ListRenderer.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ListRenderer.swift index d5c1149be..1263cc193 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ListRenderer.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Rendering/Renderers/ListRenderer.swift @@ -15,6 +15,8 @@ final class ListRenderer: NodeRenderer { let prevDepth = context.listDepth let prevType = context.listType let prevNumber = context.listItemNumber + let prevMarkerWidth = context.listMarkerWidth + let prevMarkerOffset = context.ancestorMarkerOffset let startLocation = output.length if prevDepth == 0 { @@ -31,6 +33,12 @@ final class ListRenderer: NodeRenderer { context.listDepth = prevDepth + 1 context.listType = isOrdered ? .ordered : .unordered context.listItemNumber = 0 + if prevDepth > 0 { + context.ancestorMarkerOffset += context.listItemMarkerExtra + } + context.listMarkerWidth = isOrdered + ? widestMarkerWidth(of: node, atLeast: config.list.defaultMarkerColumnWidth(for: .ordered)) + : 0 let font = config.list.font ?? UIFont.preferredFont(forTextStyle: .body) let color = config.list.foregroundColor ?? UIColor.label @@ -45,6 +53,8 @@ final class ListRenderer: NodeRenderer { context.listDepth = prevDepth context.listType = prevType context.listItemNumber = prevNumber + context.listMarkerWidth = prevMarkerWidth + context.ancestorMarkerOffset = prevMarkerOffset if prevDepth == 0 { context.clearBlockStyle() @@ -53,4 +63,21 @@ final class ListRenderer: NodeRenderer { } } } + + /// Widens the marker column to the list's widest number, so markers are + /// never drawn past the column's leading edge. Task items draw a checkbox + /// instead of a number and do not count. + private func widestMarkerWidth(of node: MarkdownASTNode, atLeast minimumWidth: CGFloat) -> CGFloat { + let attributes: [NSAttributedString.Key: Any] = [ + .font: config.list.font ?? BlockDecorationConfig.defaultListMarkerFont + ] + var width = minimumWidth + var number = 0 + for item in node.children where item.type == .listItem { + number += 1 + guard item.attribute("isTask") != "true" else { continue } + width = max(width, ("\(number)." as NSString).size(withAttributes: attributes).width) + } + return width + } } diff --git a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Views/Layout/BlockDecorationConfig.swift b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Views/Layout/BlockDecorationConfig.swift index 7b587e8c8..34696c8e1 100644 --- a/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Views/Layout/BlockDecorationConfig.swift +++ b/packages/enriched-markdown-ios/Sources/EnrichedMarkdown/Views/Layout/BlockDecorationConfig.swift @@ -18,7 +18,9 @@ struct BlockDecorationConfig { var listBulletSize: CGFloat = 6 var listBulletColor: UIColor = UIColor(red: 0.42, green: 0.45, blue: 0.50, alpha: 1) var listMarkerColor: UIColor = UIColor(red: 0.42, green: 0.45, blue: 0.50, alpha: 1) - var listMarkerFont: UIFont = .systemFont(ofSize: 16, weight: .medium) + static let defaultListMarkerFont: UIFont = .systemFont(ofSize: 16, weight: .medium) + + var listMarkerFont: UIFont = defaultListMarkerFont var taskCheckboxSize: CGFloat = 14 var taskCheckboxBorderRadius: CGFloat = 3 diff --git a/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/ListRenderingTests.swift b/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/ListRenderingTests.swift new file mode 100644 index 000000000..fdc7b11c2 --- /dev/null +++ b/packages/enriched-markdown-ios/Tests/EnrichedMarkdownTests/ListRenderingTests.swift @@ -0,0 +1,198 @@ +import UIKit +import XCTest +@testable import EnrichedMarkdown + +final class ListRenderingTests: XCTestCase { + private var config: MarkdownStyleConfiguration! + + override func setUp() { + super.setUp() + config = MarkdownStyleConfiguration.baseline() + } + + func testShortListsKeepTheirMarkerColumn() { + let result = MarkdownRenderer.render(numbered(9), config: config) + + // Default theme: 20pt ordered marker column + 12pt gap. + XCTAssertEqual(headIndent(in: result, at: "i1x"), 32) + XCTAssertEqual(headIndent(in: result, at: "i9x"), 32) + } + + func testListsWidenTheirColumnToTheirWidestNumber() { + for count in [12, 99, 101, 1000] { + let result = MarkdownRenderer.render(numbered(count), config: config) + let widest = (1...count).map { markerWidth("\($0).") }.max() ?? 0 + XCTAssertGreaterThan(widest, 20, "list of \(count)") + + for item in ["i1x", "i\(count)x"] { + XCTAssertEqual(headIndent(in: result, at: item), widest + 12, accuracy: 0.001, "\(item) of \(count)") + } + } + } + + func testMarkerMinWidthRemainsAFloor() { + config.list.markerMinWidth = 200 + let result = MarkdownRenderer.render(numbered(1000), config: config) + + XCTAssertEqual(headIndent(in: result, at: "i1x"), 212) + XCTAssertEqual(headIndent(in: result, at: "i1000x"), 212) + } + + func testFollowingListsRestoreTheirOwnColumn() { + let result = MarkdownRenderer.render(numbered(101) + "\nbreak\n\n" + numbered(3, prefix: "f"), config: config) + + XCTAssertGreaterThan(headIndent(in: result, at: "i101x"), 32) + XCTAssertEqual(headIndent(in: result, at: "f1x"), 32) + } + + func testNestedListsKeepTheirPlaceUnderWideParents() { + for nested in ["- nestedx", "1. nestedx", "- [ ] nestedx"] { + let short = nestedOffset(in: numbered(4, nesting: nested, under: 3), parent: "i3x") + for count in [101, 1000] { + let wide = nestedOffset(in: numbered(count, nesting: nested, under: 3), parent: "i3x") + XCTAssertEqual(wide, short, accuracy: 0.001, "\(nested) under a list of \(count)") + } + } + } + + func testListsInsideAQuoteUnderAWideParentShiftOnce() { + func offset(_ count: Int) -> CGFloat { + let source = numbered(count, nesting: "> quotex\n>\n> - nestedx", under: 3) + let result = MarkdownRenderer.render(source, config: config) + return headIndent(in: result, at: "nestedx") - headIndent(in: result, at: "quotex") + } + + for count in [101, 1000] { + XCTAssertEqual(offset(count), offset(4), accuracy: 0.001, "list of \(count)") + } + } + + func testQuotesUnderSeveralListLevelsShiftOnce() { + func offsets(_ count: Int) -> (text: CGFloat, bar: CGFloat) { + let source = numbered(count, nesting: "- childx\n > quotex", under: 3) + let result = MarkdownRenderer.render(source, config: config) + let quote = (result.string as NSString).range(of: "quotex").location + let bar = result.attribute(MarkdownAttribute.blockquoteBarOffset, at: quote, effectiveRange: nil) + let barOffset = bar as? CGFloat ?? -1 + return (headIndent(in: result, at: "quotex") - headIndent(in: result, at: "childx"), + headIndent(in: result, at: "quotex") - barOffset) + } + + let short = offsets(4) + for count in [101, 1000] { + let wide = offsets(count) + XCTAssertEqual(wide.text, short.text, accuracy: 0.001, "quote under a list of \(count)") + XCTAssertEqual(wide.bar, short.bar, accuracy: 0.001, "quote bar under a list of \(count)") + } + } + + func testQuoteBarsKeepTheWideningOfAnInnerList() { + func offsets(_ count: Int) -> (text: CGFloat, bar: CGFloat) { + let inner = numbered(count, prefix: "n", nesting: "> quotex", under: 3) + let result = MarkdownRenderer.render(numbered(4, nesting: inner, under: 2), config: config) + let quote = (result.string as NSString).range(of: "quotex").location + let bar = result.attribute(MarkdownAttribute.blockquoteBarOffset, at: quote, effectiveRange: nil) + let barOffset = bar as? CGFloat ?? -1 + return (headIndent(in: result, at: "quotex") - headIndent(in: result, at: "n3x"), + headIndent(in: result, at: "quotex") - barOffset) + } + + let short = offsets(4) + for count in [101, 1000] { + let wide = offsets(count) + XCTAssertEqual(wide.text, short.text, accuracy: 0.001, "quote in a nested list of \(count)") + XCTAssertEqual(wide.bar, short.bar, accuracy: 0.001, "quote bar in a nested list of \(count)") + } + } + + func testWideListsInsideAQuoteKeepTheQuoteBarInPlace() { + func barOffset(_ result: NSAttributedString, at text: String) -> CGFloat { + let location = (result.string as NSString).range(of: text).location + return result.attribute(MarkdownAttribute.blockquoteBarOffset, at: location, effectiveRange: nil) as? CGFloat ?? -1 + } + + for count in [4, 101, 1000] { + let inner = numbered(count, prefix: "n", nesting: "> innerx", under: 3) + let quote = "> beforex\n>\n" + inner.split(separator: "\n").map { "> " + $0 }.joined(separator: "\n") + + "\n>\n> afterx" + let result = MarkdownRenderer.render(numbered(4, nesting: quote, under: 1), config: config) + XCTAssertEqual(barOffset(result, at: "innerx"), barOffset(result, at: "beforex"), "list of \(count)") + XCTAssertEqual(barOffset(result, at: "afterx"), barOffset(result, at: "beforex"), "list of \(count)") + } + } + + func testTaskChildrenMoveOnlyAsFarAsTheirCheckboxColumn() { + for checkboxSize: CGFloat in [22, 100] { + config.taskList.checkboxSize = checkboxSize + func offset(_ count: Int) -> CGFloat { + var lines = (1...count).map { "\($0). i\($0)x" } + lines[1] = "2. [ ] taskx\n - childx" + let result = MarkdownRenderer.render(lines.joined(separator: "\n"), config: config) + return headIndent(in: result, at: "childx") - headIndent(in: result, at: "taskx") + } + + for count in [101, 1000] { + XCTAssertEqual(offset(count), offset(4), accuracy: 0.001, "checkbox \(checkboxSize), list of \(count)") + } + } + } + + func testNestedWideListsWidenOnlyThemselves() { + let source = numbered(3, nesting: numbered(101, prefix: "n"), under: 2) + let result = MarkdownRenderer.render(source, config: config) + let nestedWidest = (1...101).map { markerWidth("\($0).") }.max() ?? 0 + + XCTAssertEqual(headIndent(in: result, at: "i1x"), 32) + XCTAssertEqual(headIndent(in: result, at: "n101x"), 32 + 24 - 20 + nestedWidest, accuracy: 0.001) + } + + func testTaskItemsAlignWithWideNumberedSiblings() { + let source = (1...101).map { $0 == 2 ? "2. [ ] taskx" : "\($0). i\($0)x" }.joined(separator: "\n") + let result = MarkdownRenderer.render(source, config: config) + + XCTAssertEqual(headIndent(in: result, at: "taskx"), headIndent(in: result, at: "i1x")) + } + + func testTaskItemChildrenFollowTheirWideNumberedSiblings() { + var lines = (1...101).map { "\($0). i\($0)x" } + lines[1] = "2. [ ] taskx\n - undertaskx" + lines[2] = "3. i3x\n - undernumberx" + let result = MarkdownRenderer.render(lines.joined(separator: "\n"), config: config) + + XCTAssertEqual( + headIndent(in: result, at: "undertaskx") - headIndent(in: result, at: "taskx"), + headIndent(in: result, at: "undernumberx") - headIndent(in: result, at: "i3x"), + accuracy: 0.001 + ) + } + + private func nestedOffset(in source: String, parent: String) -> CGFloat { + let result = MarkdownRenderer.render(source, config: config) + return headIndent(in: result, at: "nestedx") - headIndent(in: result, at: parent) + } + + /// An ordered list of `count` items `x`, with `nesting` under item `under`. + private func numbered(_ count: Int, prefix: String = "i", nesting: String? = nil, under: Int = 0) -> String { + (1...count).map { number in + var item = "\(number). \(prefix)\(number)x" + if let nesting, number == under { + let indent = String(repeating: " ", count: "\(number). ".count) + item += "\n" + nesting.split(separator: "\n").map { indent + $0 }.joined(separator: "\n") + } + return item + }.joined(separator: "\n") + "\n" + } + + private func markerWidth(_ marker: String) -> CGFloat { + let font = config.list.font ?? BlockDecorationConfig.defaultListMarkerFont + return (marker as NSString).size(withAttributes: [.font: font]).width + } + + private func headIndent(in result: NSAttributedString, at substring: String) -> CGFloat { + let range = (result.string as NSString).range(of: substring) + XCTAssertNotEqual(range.location, NSNotFound, substring) + guard range.location != NSNotFound else { return -1 } + let style = result.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as? NSParagraphStyle + return style?.headIndent ?? -1 + } +} diff --git a/packages/react-native-enriched-markdown/__tests__/ios/ListRenderingTests.mm b/packages/react-native-enriched-markdown/__tests__/ios/ListRenderingTests.mm new file mode 100644 index 000000000..4209fdc80 --- /dev/null +++ b/packages/react-native-enriched-markdown/__tests__/ios/ListRenderingTests.mm @@ -0,0 +1,177 @@ +#import "../../ios/parser/ENRMMarkdownParser.h" +#import "../../ios/parser/MarkdownASTNode.h" +#import "../../ios/renderer/AttributedRenderer.h" +#import "../../ios/renderer/ListItemRenderer.h" +#import "../../ios/renderer/RenderContext.h" +#import "../../ios/styles/StyleConfig.h" +#import + +static MarkdownASTNode *ENRMOrderedList(NSInteger start, NSUInteger count) +{ + MarkdownASTNode *list = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeOrderedList]; + list.attributes[@"start"] = [@(start) stringValue]; + for (NSUInteger i = 0; i < count; i++) { + MarkdownASTNode *item = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeListItem]; + MarkdownASTNode *paragraph = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeParagraph]; + MarkdownASTNode *text = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeText]; + text.content = @"item"; + [paragraph addChild:text]; + [item addChild:paragraph]; + [list addChild:item]; + } + return list; +} + +@interface ENRMListRenderingTests : XCTestCase +@end + +@implementation ENRMListRenderingTests + +- (NSArray *)markersForLists:(NSArray *)lists + config:(StyleConfig *)config +{ + AttributedRenderer *renderer = [[AttributedRenderer alloc] initWithConfig:config]; + NSAttributedString *text = [renderer renderNodes:lists context:[[RenderContext alloc] init] block:nil]; + NSMutableArray *markers = [NSMutableArray array]; + [text enumerateAttribute:ListItemMarkerStartAttribute + inRange:NSMakeRange(0, text.length) + options:0 + usingBlock:^(NSArray *value, NSRange range, BOOL *stop) { + if (value) + [markers addObjectsFromArray:value]; + }]; + return markers; +} + +- (void)testShortListKeepsItsIndentAndLongListsShareTheWidestGutter +{ + StyleConfig *config = [[StyleConfig alloc] init]; + [config setListStyleFontSize:16]; + [config setListStyleMarginLeft:24]; + [config setListStyleGapWidth:11]; + CGFloat baseline = [@"99." sizeWithAttributes:@{NSFontAttributeName : config.listMarkerFont}].width; + CGFloat shortIndent = MAX(config.listStyleMarkerMinWidth, baseline) + config.effectiveListGapWidth; + NSArray *shortMarkers = [self markersForLists:@[ ENRMOrderedList(1, 99) ] config:config]; + for (ENRMListMarkerDescriptor *marker in shortMarkers) { + XCTAssertEqualWithAccuracy(marker.indent, shortIndent, 0.01); + } + for (NSNumber *start in @[ @98, @998, @12345 ]) { + NSArray *markers = [self markersForLists:@[ ENRMOrderedList(start.integerValue, 4) ] config:config]; + CGFloat width = baseline; + for (ENRMListMarkerDescriptor *marker in markers) { + NSString *label = [NSString stringWithFormat:@"%ld.", (long)marker.number]; + width = MAX(width, [label sizeWithAttributes:@{NSFontAttributeName : config.listMarkerFont}].width); + } + for (ENRMListMarkerDescriptor *marker in markers) { + XCTAssertEqualWithAccuracy(marker.indent, width + config.effectiveListGapWidth, 0.01); + } + } +} + +- (void)testNestedAndFollowingListsRestoreTheirOwnGutters +{ + StyleConfig *config = [[StyleConfig alloc] init]; + [config setListStyleFontSize:16]; + [config setListStyleMarginLeft:24]; + [config setListStyleGapWidth:11]; + MarkdownASTNode *outer = ENRMOrderedList(98, 4); + [outer.children[0] addChild:ENRMOrderedList(9998, 4)]; + NSArray *markers = [self markersForLists:@[ outer, ENRMOrderedList(1, 1) ] config:config]; + CGFloat outerIndent = 0; + CGFloat nestedIndent = 0; + for (ENRMListMarkerDescriptor *marker in markers) { + if (marker.depth == 1) { + nestedIndent = marker.indent; + } else if (marker.number != 1) { + if (outerIndent == 0) + outerIndent = marker.indent; + XCTAssertEqualWithAccuracy(marker.indent, outerIndent, 0.01); + } + } + XCTAssertGreaterThan(nestedIndent, outerIndent + config.listStyleMarginLeft); + ENRMListMarkerDescriptor *last = markers.lastObject; + XCTAssertLessThan(last.indent, outerIndent); +} + +- (void)testNestedListsKeepTheSpaceReservedForWideParentMarkers +{ + StyleConfig *config = [[StyleConfig alloc] init]; + [config setListStyleFontSize:16]; + [config setListStyleMarginLeft:24]; + [config setListStyleGapWidth:11]; + MarkdownASTNode *outer = ENRMOrderedList(123456789, 1); + MarkdownASTNode *middle = ENRMOrderedList(1, 1); + [middle.children[0] addChild:ENRMOrderedList(1, 1)]; + [outer.children[0] addChild:middle]; + NSArray *markers = [self markersForLists:@[ outer ] config:config]; + ENRMListMarkerDescriptor *parent = markers[0]; + ENRMListMarkerDescriptor *child = markers[1]; + ENRMListMarkerDescriptor *leaf = markers[2]; + XCTAssertEqual(parent.depth, 0); + XCTAssertEqual(child.depth, 1); + XCTAssertEqual(leaf.depth, 2); + XCTAssertEqualWithAccuracy(child.indent, parent.indent + config.listStyleMarginLeft, 0.01); + XCTAssertEqualWithAccuracy(leaf.indent, parent.indent + 2 * config.listStyleMarginLeft, 0.01); +} + +- (void)testTaskItemsMoveWithTheirWideNumberedSiblings +{ + StyleConfig *config = [[StyleConfig alloc] init]; + [config setListStyleFontSize:16]; + [config setListStyleMarginLeft:24]; + [config setListStyleGapWidth:11]; + NSMutableArray *> *results = [NSMutableArray array]; + for (NSNumber *start in @[ @1, @12345 ]) { + MarkdownASTNode *outer = ENRMOrderedList(start.integerValue, 3); + MarkdownASTNode *task = outer.children[1]; + task.attributes[@"isTask"] = @"true"; + MarkdownASTNode *child = ENRMOrderedList(1, 1); + child.type = MarkdownNodeTypeUnorderedList; + [task addChild:child]; + [outer.children[2] addChild:ENRMOrderedList(1, 1)]; + [results addObject:[self markersForLists:@[ outer ] config:config]]; + } + NSArray *shortMarkers = results[0]; + NSArray *wideMarkers = results[1]; + XCTAssertEqual(shortMarkers.count, 5u); + XCTAssertEqual(wideMarkers.count, 5u); + ENRMListMarkerDescriptor *task = wideMarkers[1]; + ENRMListMarkerDescriptor *child = wideMarkers[2]; + XCTAssertTrue(task.isTask); + XCTAssertEqual(child.depth, 1); + XCTAssertEqual(child.listType, ListTypeUnordered); + CGFloat shift = [wideMarkers[0] indent] - [shortMarkers[0] indent]; + XCTAssertGreaterThan(shift, 0); + XCTAssertEqualWithAccuracy(task.indent, [shortMarkers[1] indent] + shift, 0.01); + XCTAssertEqualWithAccuracy(child.indent, [shortMarkers[2] indent] + shift, 0.01); + ENRMListMarkerDescriptor *numericChild = wideMarkers[4]; + ENRMListMarkerDescriptor *numericParent = wideMarkers[3]; + XCTAssertEqualWithAccuracy(numericChild.indent, numericParent.indent + config.listStyleMarginLeft, 0.01); +} + +- (void)testSourceNumbersAfterTheStartDoNotOverrideRenderedNumbers +{ + ENRMMarkdownParser *parser = [[ENRMMarkdownParser alloc] init]; + MarkdownASTNode *document = [parser parseMarkdown:@"98. a\n400. b\n1. c\n2. d"]; + StyleConfig *config = [[StyleConfig alloc] init]; + [config setListStyleFontSize:16]; + [config setListStyleMarginLeft:24]; + [config setListStyleGapWidth:11]; + NSArray *markers = [self markersForLists:document.children config:config]; + XCTAssertEqual(markers.count, 4u); + for (NSUInteger i = 0; i < markers.count; i++) { + ENRMListMarkerDescriptor *marker = markers[i]; + XCTAssertEqual(marker.number, 98 + (NSInteger)i); + } +} + +- (void)testMarkerMinimumWidthRemainsAFloor +{ + StyleConfig *config = [[StyleConfig alloc] init]; + [config setListStyleMarkerMinWidth:200]; + NSArray *markers = [self markersForLists:@[ ENRMOrderedList(998, 4) ] config:config]; + for (ENRMListMarkerDescriptor *marker in markers) { + XCTAssertEqualWithAccuracy(marker.indent, 200 + config.effectiveListGapWidth, 0.01); + } +} +@end diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt index 0286a8558..1ca6f3dd4 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt @@ -41,6 +41,9 @@ class BlockStyleContext { var listDepth = 0 var listType: ListType? = null var listItemNumber = 0 + var listMarkerExtra = 0f + var ancestorMarkerOffset = 0f + var listItemMarkerExtra = 0f var taskItemCount = 0 var accumulatedIndent = 0 @@ -151,6 +154,9 @@ class BlockStyleContext { listDepth = 0 listType = null listItemNumber = 0 + listMarkerExtra = 0f + ancestorMarkerOffset = 0f + listItemMarkerExtra = 0f taskItemCount = 0 accumulatedIndent = 0 listItemStarts.clear() diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt index cb3d2aa54..548dbda85 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt @@ -12,6 +12,8 @@ import com.swmansion.enriched.markdown.styles.ListStyle * allowing each level to maintain its own counter while preserving parent's position. * - **Parent context restoration**: Restores parent list's style when exiting nested lists * so subsequent parent items render correctly. + * - **Marker column**: An ordered list widens its `99.` column to fit its widest marker, and a + * nested list inherits the widening of the item it sits in. */ class ListContextManager( private val context: BlockStyleContext, @@ -24,6 +26,8 @@ class ListContextManager( val previousDepth: Int, val parentListType: BlockStyleContext.ListType?, val wasNestedInOrderedList: Boolean, + val previousMarkerExtra: Float, + val previousMarkerOffset: Float, ) /** @@ -32,11 +36,13 @@ class ListContextManager( * - Incrementing list depth * - Setting the appropriate list style * - Resetting item number for the new list + * - Widening the marker column by [markerExtraWidth] and shifting a nested list by its parent item's widening */ fun enterList( listType: BlockStyleContext.ListType, style: ListStyle, startNumber: Int = 1, + markerExtraWidth: Float = 0f, ): ListEntryState { val previousDepth = context.listDepth val isNested = previousDepth > 0 @@ -61,10 +67,19 @@ class ListContextManager( } context.resetListItemNumber(startNumber) + val previousMarkerExtra = context.listMarkerExtra + val previousMarkerOffset = context.ancestorMarkerOffset + if (isNested) { + context.ancestorMarkerOffset += context.listItemMarkerExtra + } + context.listMarkerExtra = markerExtraWidth + return ListEntryState( previousDepth = previousDepth, parentListType = parentListType, wasNestedInOrderedList = isNested && parentIsOrdered, + previousMarkerExtra = previousMarkerExtra, + previousMarkerOffset = previousMarkerOffset, ) } @@ -74,6 +89,7 @@ class ListContextManager( * - Decrementing list depth back to previousDepth * - Restoring parent list item numbers from stack (if applicable) * - Restoring parent list metadata (if nested) so subsequent parent items render correctly + * - Restoring the parent list's marker column and offset */ fun exitList(entryState: ListEntryState) { context.listDepth = entryState.previousDepth @@ -86,5 +102,8 @@ class ListContextManager( if (entryState.previousDepth > 0) { context.listType = entryState.parentListType } + + context.listMarkerExtra = entryState.previousMarkerExtra + context.ancestorMarkerOffset = entryState.previousMarkerOffset } } diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt index b849f7863..f79309fad 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt @@ -50,15 +50,32 @@ class ListItemRenderer( taskIndex = taskIndex, isChecked = isChecked, drawsMarker = drawsMarker, + ancestorMarkerOffset = styleContext.ancestorMarkerOffset, + markerExtraWidth = styleContext.listMarkerExtra, ) } else { when (listType) { BlockStyleContext.ListType.UNORDERED -> { - UnorderedListSpan(listStyle, depth, factory.context, factory.styleCache, drawsMarker) + UnorderedListSpan( + listStyle, + depth, + factory.context, + factory.styleCache, + drawsMarker, + ancestorMarkerOffset = styleContext.ancestorMarkerOffset, + ) } BlockStyleContext.ListType.ORDERED -> { - OrderedListSpan(listStyle, depth, factory.context, factory.styleCache, drawsMarker).apply { + OrderedListSpan( + listStyle, + depth, + factory.context, + factory.styleCache, + drawsMarker, + markerExtraWidth = styleContext.listMarkerExtra, + ancestorMarkerOffset = styleContext.ancestorMarkerOffset, + ).apply { setItemNumber(styleContext.listItemNumber) } } @@ -67,12 +84,15 @@ class ListItemRenderer( val markerSpan = makeSpan(drawsMarker = true) + val previousMarkerExtra = styleContext.listItemMarkerExtra + styleContext.listItemMarkerExtra = markerSpan.markerExtraWidth val prevIndent = styleContext.accumulatedIndent styleContext.accumulatedIndent = prevIndent + markerSpan.getLeadingMargin(false) try { factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) } finally { styleContext.accumulatedIndent = prevIndent + styleContext.listItemMarkerExtra = previousMarkerExtra } if (builder.length == start || builder.substring(start).isBlank()) return diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt index 2bac5b11d..d88422262 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt @@ -4,6 +4,7 @@ import android.text.SpannableStringBuilder import com.swmansion.enriched.markdown.parser.MarkdownASTNode import com.swmansion.enriched.markdown.spans.ListItemSpacingSpan import com.swmansion.enriched.markdown.spans.MarginBottomSpan +import com.swmansion.enriched.markdown.spans.OrderedListSpan import com.swmansion.enriched.markdown.styles.ListStyle import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE import com.swmansion.enriched.markdown.utils.text.span.applyLineHeightSkippingImages @@ -31,8 +32,9 @@ class ListRenderer( 1 } + val markerExtraWidth = if (isOrdered) markerExtraWidth(node, listStyle, startNumber, factory) else 0f val contextManager = ListContextManager(factory.blockStyleContext) - val entryState = contextManager.enterList(listType, listStyle, startNumber) + val entryState = contextManager.enterList(listType, listStyle, startNumber, markerExtraWidth) // For top-level lists, insert marginTop spacer before rendering content if (entryState.previousDepth == 0) { @@ -58,6 +60,23 @@ class ListRenderer( } } + /** Lists under 100 keep the `99.` column, so only numbers from 100 on widen it. Task items draw a checkbox. */ + private fun markerExtraWidth( + node: MarkdownASTNode, + listStyle: ListStyle, + startNumber: Int, + factory: RendererFactory, + ): Float { + val wideNumbers = mutableListOf() + var number = startNumber + for (item in node.children) { + if (item.type != MarkdownASTNode.NodeType.ListItem) continue + if (number >= 100 && item.attributes["isTask"] != "true") wideNumbers += number + number++ + } + return OrderedListSpan.measureExtraWidth(listStyle, factory.context, wideNumbers) + } + private fun applyListSpacing( builder: SpannableStringBuilder, start: Int, diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt index 2ab00430c..054931d8d 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt @@ -23,6 +23,7 @@ abstract class BaseListSpan( protected val marginLeft: Float, protected val gapWidth: Float, internal val drawsMarker: Boolean = true, + internal val ancestorMarkerOffset: Float = 0f, ) : MetricAffectingSpan(), LeadingMarginSpan { private var cachedText: CharSequence? = null @@ -38,6 +39,9 @@ abstract class BaseListSpan( protected abstract fun getMarkerWidth(): Float + /** How far an ordered list widened this item's column past the one short lists reserve. */ + internal open val markerExtraWidth: Float = 0f + override fun updateMeasureState(textPaint: TextPaint) = applyTextStyle(textPaint) override fun updateDrawState(textPaint: TextPaint) = applyTextStyle(textPaint) @@ -47,7 +51,7 @@ abstract class BaseListSpan( val effectiveGap = gapWidth.coerceAtLeast(DEFAULT_MIN_GAP) (getMarkerWidth() + effectiveGap).toInt() } else { - marginLeft.toInt() + (marginLeft + markerExtraWidth).toInt() } override fun drawLeadingMargin( diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt index 566f52800..9a2b19930 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt @@ -12,6 +12,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.swmansion.enriched.markdown.renderer.BlockStyle import com.swmansion.enriched.markdown.renderer.SpanStyleCache import com.swmansion.enriched.markdown.styles.ListStyle +import kotlin.math.ceil class OrderedListSpan( private val listStyle: ListStyle, @@ -19,6 +20,8 @@ class OrderedListSpan( context: Context, styleCache: SpanStyleCache, drawsMarker: Boolean = true, + override val markerExtraWidth: Float = 0f, + ancestorMarkerOffset: Float = 0f, ) : BaseListSpan( depth = depth, context = context, @@ -33,30 +36,54 @@ class OrderedListSpan( marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, drawsMarker = drawsMarker, + ancestorMarkerOffset = ancestorMarkerOffset, ) { companion object { private val sharedMarkerPaint = TextPaint().apply { isAntiAlias = true } - } - private val markerTypeface: Typeface = - run { - val fontFamily = listStyle.fontFamily.takeIf { it.isNotEmpty() } - val fontWeight = parseFontWeight(listStyle.markerFontWeight) - applyStyles(null, ReactConstants.UNSET, fontWeight, fontFamily, context.assets) + /** + * How far the `99.` column every ordered list reserves must grow to fit each of [numbers]. + * Whole pixels, so the Int leading margins and the float marker positions agree at every depth. + */ + fun measureExtraWidth( + listStyle: ListStyle, + context: Context, + numbers: Iterable, + ): Float { + val paint = markerPaint(listStyle, markerTypeface(listStyle, context)) + val default = paint.measureText("99.") + val widest = numbers.fold(default) { width, number -> maxOf(width, paint.measureText("$number.")) } + return ceil(listStyle.effectiveMarkerWidth(widest) - listStyle.effectiveMarkerWidth(default)) } - private fun configureMarkerPaint(): TextPaint = - sharedMarkerPaint.apply { - textSize = listStyle.fontSize - color = listStyle.markerColor - typeface = markerTypeface - } + private fun markerTypeface( + listStyle: ListStyle, + context: Context, + ): Typeface = + applyStyles( + null, + ReactConstants.UNSET, + parseFontWeight(listStyle.markerFontWeight), + listStyle.fontFamily.takeIf { it.isNotEmpty() }, + context.assets, + ) - override fun getMarkerWidth(): Float { - val paint = configureMarkerPaint() - return listStyle.effectiveMarkerWidth(paint.measureText("99.")) + private fun markerPaint( + listStyle: ListStyle, + typeface: Typeface, + ): TextPaint = + sharedMarkerPaint.apply { + textSize = listStyle.fontSize + color = listStyle.markerColor + this.typeface = typeface + } } + private val markerTypeface = markerTypeface(listStyle, context) + private val defaultMarkerWidth = listStyle.effectiveMarkerWidth(markerPaint(listStyle, markerTypeface).measureText("99.")) + + override fun getMarkerWidth(): Float = defaultMarkerWidth + markerExtraWidth + var itemNumber: Int = 1 private set @@ -71,11 +98,11 @@ class OrderedListSpan( layout: Layout?, start: Int, ) { - val markerPaint = configureMarkerPaint() + val markerPaint = markerPaint(listStyle, markerTypeface) val text = if (dir < 0) ".$itemNumber" else "$itemNumber." val textWidth = markerPaint.measureText(text) - val markerRightEdge = x + (depth * marginLeft + getMarkerWidth()) * dir + val markerRightEdge = x + (depth * marginLeft + ancestorMarkerOffset + getMarkerWidth()) * dir val markerX = if (dir > 0) markerRightEdge - textWidth else markerRightEdge canvas.drawText(text, markerX, baseline.toFloat(), markerPaint) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt index 3b5a5c60b..fb0088718 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt @@ -20,6 +20,8 @@ class TaskListSpan( val taskIndex: Int, val isChecked: Boolean, drawsMarker: Boolean = true, + ancestorMarkerOffset: Float = 0f, + override val markerExtraWidth: Float = 0f, ) : BaseListSpan( depth = depth, context = context, @@ -34,9 +36,10 @@ class TaskListSpan( marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, drawsMarker = drawsMarker, + ancestorMarkerOffset = ancestorMarkerOffset, ) { private val checkboxSize = taskStyle.checkboxSize - private val markerColumnWidth = listStyle.effectiveMarkerWidth(checkboxSize) + private val markerColumnWidth = listStyle.effectiveMarkerWidth(checkboxSize) + markerExtraWidth private val cornerRadius = taskStyle.checkboxBorderRadius private val rect = RectF() private val checkPath = Path() @@ -76,7 +79,7 @@ class TaskListSpan( // Right-align the checkbox inside the reserved marker column so it hugs // the gap before the text. At the default (markerColumnWidth == // checkboxSize) this is identical to the previous flush-left layout. - val centerX = x + (depth * marginLeft + markerColumnWidth - checkboxSize / HALF_DIVISOR) * dir + val centerX = x + (depth * marginLeft + ancestorMarkerOffset + markerColumnWidth - checkboxSize / HALF_DIVISOR) * dir val half = checkboxSize / HALF_DIVISOR rect.set(centerX - half, centerY - half, centerX + half, centerY + half) diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt index cc3e70401..2f50f46a0 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt @@ -14,6 +14,7 @@ class UnorderedListSpan( context: Context, styleCache: SpanStyleCache, drawsMarker: Boolean = true, + ancestorMarkerOffset: Float = 0f, ) : BaseListSpan( depth = depth, context = context, @@ -28,6 +29,7 @@ class UnorderedListSpan( marginLeft = listStyle.marginLeft, gapWidth = listStyle.gapWidth, drawsMarker = drawsMarker, + ancestorMarkerOffset = ancestorMarkerOffset, ) { companion object { private val sharedBulletPaint = @@ -65,7 +67,7 @@ class UnorderedListSpan( // it hugs the gap before the text — matches iOS behavior and stays // visually flush-left when the column width equals the bullet radius // (the default). - val bulletX = x + (depth * marginLeft + markerColumnWidth) * dir + val bulletX = x + (depth * marginLeft + ancestorMarkerOffset + markerColumnWidth) * dir val bulletY = baseline + (markerFontMetrics.ascent + markerFontMetrics.descent) / 2f when (depth) { diff --git a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt index 1bb4b5313..a9e6e00d0 100644 --- a/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt +++ b/packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt @@ -131,16 +131,30 @@ object TaskListTapUtils { taskIndex = targetIndex, isChecked = newChecked, drawsMarker = old.drawsMarker, + ancestorMarkerOffset = old.ancestorMarkerOffset, + markerExtraWidth = old.markerExtraWidth, ) - // Replace every segment of the item — spans are split around code blocks - for (old in targetSpans) { - val start = spannable.getSpanStart(old) - val end = spannable.getSpanEnd(old) - // Keep the original flags so the list item priority survives the toggle - val flags = spannable.getSpanFlags(old) - spannable.removeSpan(old) - spannable.setSpan(toggledCopy(old), start, end, flags) + // Layout draws leading margins in insertion order, and a re-added span moves to the end of it. + // Re-add the task's spans and everything attached after them (its ancestors) in their original + // order; the task's own nested items were attached first and stay where they are. + data class ListSpanState( + val span: BaseListSpan, + val start: Int, + val end: Int, + val flags: Int, + ) + + val coveringSpans = spannable.getSpans(spanStart, spanEnd, BaseListSpan::class.java) + val firstTarget = coveringSpans.indexOfFirst { it is TaskListSpan && it.taskIndex == targetIndex } + val listSpans = + coveringSpans.drop(firstTarget).map { + ListSpanState(it, spannable.getSpanStart(it), spannable.getSpanEnd(it), spannable.getSpanFlags(it)) + } + listSpans.forEach { spannable.removeSpan(it.span) } + for ((old, start, end, flags) in listSpans) { + val replacement = if (old is TaskListSpan && old.taskIndex == targetIndex) toggledCopy(old) else old + spannable.setSpan(replacement, start, end, flags) } // The marker may be drawn by an anchor span wrapping an unattached copy diff --git a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/ListRenderingTest.kt b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/ListRenderingTest.kt new file mode 100644 index 000000000..c10a5a307 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/ListRenderingTest.kt @@ -0,0 +1,270 @@ +package com.swmansion.enriched.markdown + +import android.graphics.Typeface +import android.text.SpannableString +import android.text.TextPaint +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.spans.BaseListSpan +import com.swmansion.enriched.markdown.spans.ListMarkerAnchorSpan +import com.swmansion.enriched.markdown.spans.OrderedListSpan +import com.swmansion.enriched.markdown.styles.StyleConfig +import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport.render +import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport.styleConfig +import com.swmansion.enriched.markdown.test.MarkerCanvas +import com.swmansion.enriched.markdown.test.MarkerGeometry +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.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.math.ceil + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ListRenderingTest { + @Test + fun shortListsKeepTheirIndentAndLongListsShareTheirWidestGutter() { + val style = listStyle() + val paint = + TextPaint().apply { + textSize = style.listStyle.fontSize + typeface = Typeface.create("monospace", Typeface.NORMAL) + } + val baseline = paint.measureText("99.") + val gap = style.listStyle.gapWidth + val short = orderedSpans(style, numbered(1, 99)) + short.forEach { assertEquals((baseline + gap).toInt(), it.getLeadingMargin(false)) } + for ((start, count) in listOf(1 to 250, 98 to 4, 998 to 4, 12345 to 4, 0 to 101)) { + val spans = orderedSpans(style, numbered(start, count)) + val widest = maxOf(baseline, spans.maxOf { paint.measureText("${it.itemNumber}.") }) + spans.forEach { assertEquals((baseline + ceil(widest - baseline) + gap).toInt(), it.getLeadingMargin(false)) } + assertTrue(spans.first().getLeadingMargin(false) > short.first().getLeadingMargin(false)) + } + } + + @Test + fun nestedAndFollowingListsRestoreTheirOwnGutters() { + val style = listStyle() + val spans = orderedSpans(style, withChild(numbered(98, 4), 0, numbered(9998, 4)), numbered(1, 1)) + val parent = spans.filter { it.depth == 0 && it.itemNumber != 1 } + assertEquals(4, parent.size) + parent.forEach { assertEquals(parent.first().getLeadingMargin(false), it.getLeadingMargin(false)) } + spans.filter { it.depth == 1 }.forEach { assertTrue(it.getLeadingMargin(false) > style.listStyle.marginLeft) } + val following = spans.single { it.depth == 0 && it.itemNumber == 1 } + assertTrue(following.getLeadingMargin(false) < parent.first().getLeadingMargin(false)) + } + + @Test + fun markerMinimumWidthRemainsAFloor() { + val style = listStyle(minWidth = 200.0) + orderedSpans(style, numbered(998, 4)).forEach { + assertEquals((style.listStyle.markerMinWidth + style.listStyle.gapWidth).toInt(), it.getLeadingMargin(false)) + } + } + + @Test + fun drawnMarkersStayInsideTheGutterAndAttachedToTheirParagraphs() { + val style = listStyle() + for (start in listOf(1, 98, 9998)) { + val threeLevels = withChild(numbered(1, 1), 0, numbered(1, 1)) + assertDrawnMarkerPositions(style, withChild(numbered(start, 4), 2, threeLevels)) + } + } + + @Test + fun nestedBulletColumnsKeepTheirGapUnderWideParents() { + val style = listStyle() + + fun tree(start: Int) = withChild(numbered(start, 4), 2, unorderedList(item(), item())) + for (start in listOf(98, 9998)) { + assertNestedMarkerGapsUnchanged(style, tree(1), tree(start)) + } + } + + @Test + fun tasksMoveWithTheirWideNumberedSiblings() { + val style = listStyle() + + fun tree(start: Int): MarkdownASTNode { + val items = numbered(start, 3).children.toMutableList() + items[1] = taskListItem(checked = false, paragraph(text("item")), numbered(1, 1)) + items[2] = listItem(paragraph(text("item")), numbered(1, 1)) + return orderedList(*items.toTypedArray(), start = start) + } + assertEveryRowShiftsTogether(style, tree(1), tree(12345)) + } + + @Test + fun nestedTasksKeepTheirGapUnderWideNumericParents() { + val style = listStyle() + + fun tree(start: Int) = withChild(numbered(start, 4), 2, orderedList(taskListItem(checked = false, paragraph(text("item"))))) + for (start in listOf(98, 9998)) { + assertNestedMarkerGapsUnchanged(style, tree(1), tree(start)) + } + } + + @Test + fun sameTypeBulletAndTaskNestsKeepTheirMarkerGap() { + val style = listStyle() + val bullet = unorderedList(item()) + val task = orderedList(taskListItem(checked = false, paragraph(text("item")))) + for (parent in listOf(bullet, task)) { + assertDrawnMarkerPositions(style, withChild(parent, 0, parent)) + } + } + + @Test + fun shortMixedNestsKeepTheirOriginalTextIndents() { + val style = styleConfig(list = mapOf("gapWidth" to 6.0), taskList = mapOf("checkboxSize" to 14.0)) + val ordered = numbered(1, 1) + val bullet = unorderedList(item()) + val task = orderedList(taskListItem(checked = false, paragraph(text("item")))) + val shapes = listOf(ordered to bullet, bullet to ordered, bullet to task, ordered to task, task to bullet) + val baseIndents = listOf(52f, 33f, 33f, 52f, 44f) + for ((index, shape) in shapes.withIndex()) { + val (parent, child) = shape + val nested = drawMarkers(style, withChild(parent, 0, child)).single { it.depth == 1 } + assertEquals("base mixed indent for shape $index", baseIndents[index], nested.paragraphLeft, 0.01f) + assertTrue(nested.marker.left >= 0f) + } + } + + @Test + fun markersAnchoredOnANestedListKeepTheirAncestorsOffset() { + val style = listStyle() + // The middle item opens with its sublist, so its marker is drawn by an anchor span. + val middle = orderedList(listItem(numbered(1, 1)), start = 1) + val text = SpannableString(render(style, withChild(numbered(12345, 1), 0, middle))) + assertEquals(1, text.getSpans(0, text.length, ListMarkerAnchorSpan::class.java).size) + val markers = MarkerCanvas.drawMarkers(MarkerCanvas.layout(text, style.listStyle.fontSize)) + assertEquals(3, markers.size) + val (root, anchored, leaf) = markers.sortedBy { it.right } + assertEquals(root.right + style.listStyle.marginLeft, anchored.right, 0.01f) + assertEquals(anchored.right + style.listStyle.marginLeft, leaf.right, 0.01f) + assertTrue(markers.all { it.left >= 0f }) + } + + @Test + fun inheritedMarkerOffsetsFollowTheRtlDirection() { + val style = listStyle() + val text = SpannableString(render(style, withChild(numbered(12345, 1), 0, numbered(1, 1)))) + val layout = MarkerCanvas.layout(text, style.listStyle.fontSize, rtl = true) + val markers = MarkerCanvas.drawMarkers(layout) + assertEquals(2, markers.size) + markers.forEach { marker -> + val line = layout.getLineForVertical(marker.baseline.toInt()) + assertEquals(-1, layout.getParagraphDirection(line)) + assertEquals(layout.getParagraphRight(line) + style.listStyle.gapWidth, marker.left, 0.01f) + assertTrue(marker.right <= layout.width) + } + } + + private fun assertDrawnMarkerPositions( + style: StyleConfig, + vararg lists: MarkdownASTNode, + ) { + drawMarkers(style, *lists).forEach { geometry -> + assertEquals( + "marker right at depth ${geometry.depth}", + geometry.paragraphLeft - style.listStyle.gapWidth, + geometry.marker.right, + 0.01f, + ) + } + } + + /** The rows [wide] shares with [short], tasks and their children included, all sit [wide]'s widening further right. */ + private fun assertEveryRowShiftsTogether( + style: StyleConfig, + short: MarkdownASTNode, + wide: MarkdownASTNode, + ) { + val shortRows = drawMarkers(style, short) + val wideRows = drawMarkers(style, wide).take(shortRows.size) + assertEquals(shortRows.size, wideRows.size) + val shift = wideRows.first().paragraphLeft - shortRows.first().paragraphLeft + assertTrue(shift > 0f) + wideRows.zip(shortRows).forEach { (actual, control) -> + assertEquals(control.depth, actual.depth) + assertEquals("text shift at depth ${actual.depth}", shift, actual.paragraphLeft - control.paragraphLeft, 0.01f) + assertEquals( + "marker gap at depth ${actual.depth}", + control.paragraphLeft - control.marker.right, + actual.paragraphLeft - actual.marker.right, + 0.01f, + ) + } + } + + private fun assertNestedMarkerGapsUnchanged( + style: StyleConfig, + short: MarkdownASTNode, + wide: MarkdownASTNode, + ) { + val shortMarkers = drawMarkers(style, short).filter { it.depth > 0 } + val wideMarkers = drawMarkers(style, wide).filter { it.depth > 0 } + assertTrue(shortMarkers.isNotEmpty()) + assertEquals(shortMarkers.size, wideMarkers.size) + shortMarkers.zip(wideMarkers).forEach { (control, actual) -> + assertEquals(control.depth, actual.depth) + assertEquals( + "nested marker gap at depth ${actual.depth}", + control.paragraphLeft - control.marker.right, + actual.paragraphLeft - actual.marker.right, + 0.01f, + ) + } + } + + private fun drawMarkers( + style: StyleConfig, + vararg lists: MarkdownASTNode, + ): List { + // SpannableString reports spans in insertion order, as the text view does. + val text = SpannableString(render(style, *lists)) + val geometry = MarkerCanvas.markerGeometry(text, MarkerCanvas.layout(text, style.listStyle.fontSize)) + assertEquals(text.getSpans(0, text.length, BaseListSpan::class.java).count { it.drawsMarker }, geometry.size) + geometry.forEach { assertTrue("marker left ${it.marker.left} at depth ${it.depth}", it.marker.left >= 0f) } + return geometry + } + + private fun orderedSpans( + style: StyleConfig, + vararg lists: MarkdownASTNode, + ): List { + val text = render(style, *lists) + return text + .getSpans(0, text.length, OrderedListSpan::class.java) + .sortedWith(compareBy({ text.getSpanStart(it) }, { it.depth })) + } + + private fun item(): MarkdownASTNode = listItem(paragraph(text("item"))) + + private fun numbered( + start: Int, + count: Int, + ): MarkdownASTNode = orderedList(*Array(count) { item() }, start = start) + + private fun withChild( + list: MarkdownASTNode, + index: Int, + child: MarkdownASTNode, + ): MarkdownASTNode { + val items = list.children.toMutableList() + items[index] = items[index].copy(children = items[index].children + child) + return list.copy(children = items) + } + + private fun listStyle(minWidth: Double = 0.0): StyleConfig = + styleConfig(list = mapOf("fontFamily" to "monospace", "markerMinWidth" to minWidth)) +} diff --git a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/TaskListInteractionTest.kt b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/TaskListInteractionTest.kt new file mode 100644 index 000000000..64a70db33 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/TaskListInteractionTest.kt @@ -0,0 +1,102 @@ +package com.swmansion.enriched.markdown + +import android.text.Spanned +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import com.swmansion.enriched.markdown.accessibility.AccessibleMarkdownTextView +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.spans.BaseListSpan +import com.swmansion.enriched.markdown.spans.ListMarkerAnchorSpan +import com.swmansion.enriched.markdown.spans.TaskListSpan +import com.swmansion.enriched.markdown.test.DrawnMarker +import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport.render +import com.swmansion.enriched.markdown.test.MarkdownRenderTestSupport.styleConfig +import com.swmansion.enriched.markdown.test.MarkerCanvas +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 com.swmansion.enriched.markdown.utils.text.interaction.TaskListTapUtils +import com.swmansion.enriched.markdown.utils.text.view.setupAsMarkdownTextView +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [30]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TaskListInteractionTest { + private val context = RuntimeEnvironment.getApplication() + + @Test + fun togglingATaskUnderAWideParentKeepsEveryMarginAndMarkerPosition() { + assertTaskToggleKeepsGeometry(opensWithSublist = false) + } + + @Test + fun togglingAnAnchoredTaskUnderAWideParentKeepsEveryMarginAndMarkerPosition() { + assertTaskToggleKeepsGeometry(opensWithSublist = true) + } + + // Layout draws leading margins in span insertion order, so a toggle that re-adds only the + // task's span would move its checkbox by its ancestors' margins. + private fun assertTaskToggleKeepsGeometry(opensWithSublist: Boolean) { + val style = styleConfig(list = mapOf("fontFamily" to "monospace")) + val taskChildren = if (opensWithSublist) arrayOf(numbered(1)) else arrayOf(paragraph(text("item")), numbered(1)) + val task = taskListItem(checked = false, *taskChildren) + val tree = orderedList(listItem(paragraph(text("item")), unorderedList(task)), item(), start = 100) + val textView = + object : AccessibleMarkdownTextView(context) {}.apply { + layoutParams = ViewGroup.LayoutParams(MarkerCanvas.LAYOUT_WIDTH, ViewGroup.LayoutParams.WRAP_CONTENT) + setupAsMarkdownTextView() + } + textView.setText(render(style, tree), TextView.BufferType.SPANNABLE) + val initialText = textView.text as Spanned + val taskIndex = initialText.getSpans(0, initialText.length, TaskListSpan::class.java).single().taskIndex + assertEquals(if (opensWithSublist) 1 else 0, initialText.getSpans(0, initialText.length, ListMarkerAnchorSpan::class.java).size) + val initialMargins = listMargins(initialText) + val initialMarkers = drawTextViewMarkers(textView) + assertEquals(4, initialMarkers.size) + + for (checked in listOf(true, false)) { + assertTrue(TaskListTapUtils.updateTaskListItemCheckedState(textView, taskIndex, checked, style)) + val text = textView.text as Spanned + val tasks = text.getSpans(0, text.length, TaskListSpan::class.java) + assertTrue(tasks.isNotEmpty()) + tasks.forEach { assertEquals(checked, it.isChecked) } + text.getSpans(0, text.length, ListMarkerAnchorSpan::class.java).forEach { + assertEquals(checked, (it.marker as TaskListSpan).isChecked) + } + assertEquals("list margins after checked=$checked", initialMargins, listMargins(text)) + assertEquals("drawn markers after checked=$checked", initialMarkers, drawTextViewMarkers(textView)) + } + } + + private fun listMargins(text: Spanned): List> = + text + .getSpans(0, text.length, BaseListSpan::class.java) + .sortedWith(compareBy({ text.getSpanStart(it) }, { it.depth }, { text.getSpanEnd(it) })) + .map { listOf(text.getSpanStart(it), text.getSpanEnd(it), it.depth, it.getLeadingMargin(true), it.getLeadingMargin(false)) } + + private fun drawTextViewMarkers(textView: TextView): List { + textView.forceLayout() + textView.measure( + View.MeasureSpec.makeMeasureSpec(MarkerCanvas.LAYOUT_WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + textView.layout(0, 0, textView.measuredWidth, textView.measuredHeight) + return MarkerCanvas.drawMarkers(textView.layout) + } + + private fun item(): MarkdownASTNode = listItem(paragraph(text("item"))) + + private fun numbered(count: Int): MarkdownASTNode = orderedList(*Array(count) { item() }, start = 1) +} diff --git a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/test/ListMarkerCanvas.kt b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/test/ListMarkerCanvas.kt new file mode 100644 index 000000000..7383db048 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/test/ListMarkerCanvas.kt @@ -0,0 +1,101 @@ +package com.swmansion.enriched.markdown.test + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.text.Layout +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextDirectionHeuristics +import android.text.TextPaint +import com.swmansion.enriched.markdown.spans.BaseListSpan + +/** A list marker as the layout drew it: a number, a bullet, or a checkbox. */ +data class DrawnMarker( + val left: Float, + val right: Float, + val baseline: Float, +) + +/** A drawn marker with the text edge and list depth of the paragraph it belongs to. */ +data class MarkerGeometry( + val marker: DrawnMarker, + val paragraphLeft: Float, + val depth: Int, +) + +/** Records the list markers a layout draws instead of painting them. */ +class MarkerCanvas : 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. + override fun getClipBounds(bounds: Rect): Boolean { + bounds.set(0, 0, LAYOUT_WIDTH, Int.MAX_VALUE / 2) + return true + } + + override fun drawCircle( + cx: Float, + cy: Float, + radius: Float, + paint: Paint, + ) { + // Bullets are centered on the reserved column edge, matching the iOS drawer. + val outerRadius = radius + paint.strokeWidth / 2f + markers += DrawnMarker(cx - outerRadius, cx, cy) + super.drawCircle(cx, cy, radius, paint) + } + + override fun drawRoundRect( + rect: RectF, + rx: Float, + ry: Float, + paint: Paint, + ) { + val halfStroke = if (paint.style == Paint.Style.STROKE) paint.strokeWidth / 2f else 0f + markers += DrawnMarker(rect.left - halfStroke, rect.right + halfStroke, rect.centerY()) + super.drawRoundRect(rect, rx, ry, paint) + } + + override fun drawText( + text: String, + x: Float, + y: Float, + paint: Paint, + ) { + if (text.matches(Regex("(?:[0-9]+\\.|\\.[0-9]+)"))) markers += DrawnMarker(x, x + paint.measureText(text), y) + super.drawText(text, x, y, paint) + } + + companion object { + const val LAYOUT_WIDTH = 2000 + + fun layout( + text: CharSequence, + textSize: Float, + rtl: Boolean = false, + ): StaticLayout = + StaticLayout.Builder + .obtain(text, 0, text.length, TextPaint().apply { this.textSize = textSize }, LAYOUT_WIDTH) + .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 })) + + /** Every marker [layout] draws, with the text edge and depth of its paragraph. */ + fun markerGeometry( + text: Spanned, + layout: Layout, + ): List = + drawMarkers(layout).map { marker -> + val line = layout.getLineForVertical(marker.baseline.toInt()) + val start = layout.getLineStart(line) + val depth = text.getSpans(start, start + 1, BaseListSpan::class.java).maxOf { it.depth } + MarkerGeometry(marker, layout.getParagraphLeft(line).toFloat(), depth) + } + } +} diff --git a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt new file mode 100644 index 000000000..645ce339d --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/test/MarkdownRenderTestSupport.kt @@ -0,0 +1,126 @@ +package com.swmansion.enriched.markdown.test + +import android.content.Context +import android.graphics.Color +import android.text.Spannable +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.renderer.Renderer +import com.swmansion.enriched.markdown.styles.StyleConfig +import org.robolectric.RuntimeEnvironment + +object MarkdownRenderTestSupport { + // Resolved per call, not cached: a @Config qualifier applies to the current test's context. + private val context: Context + get() = RuntimeEnvironment.getApplication() + + fun render( + style: StyleConfig, + vararg blocks: MarkdownASTNode, + ): Spannable { + val renderer = Renderer() + renderer.configure(style, context) + return renderer.renderContent(blocks.toList()) + } + + /** + * The style map the JS side sends, with [list] and [taskList] entries replacing the defaults. + * Sizes are Double, colors Int, as in a ReadableMap. + */ + fun styleConfig( + list: Map = emptyMap(), + taskList: Map = emptyMap(), + ): StyleConfig { + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(context) + val style = JavaOnlyMap() + listOf("paragraph", "strong", "em", "strikethrough", "code", "superscript", "subscript", "highlight") + .forEach { style.putMap(it, textElement()) } + style.putMap("link", textElement().apply { putString("fontFamily", "serif") }) + style.putArray( + "linkVariants", + JavaOnlyArray.of( + textElement().apply { + putString("pattern", "^ref:") + putString("fontFamily", "monospace") + putInt("color", Color.BLUE) + }, + ), + ) + style.putMap( + "spoiler", + textElement().apply { + putMap("particles", JavaOnlyMap.of("density", 0.5, "speed", 1.0)) + putMap("solid", JavaOnlyMap.of("borderRadius", 0.0)) + }, + ) + style.putMap("taskList", JavaOnlyMap().apply { putAll(defaultTaskList + taskList) }) + style.putMap("list", textElement().apply { putAll(defaultList + list) }) + return StyleConfig(style, context, false, 0f) + } + + private val defaultList: Map = + mapOf( + "fontFamily" to "", + "bulletColor" to Color.BLACK, + "markerColor" to Color.BLACK, + "bulletSize" to 6.0, + "markerMinWidth" to 0.0, + "gapWidth" to 8.0, + "marginLeft" to 24.0, + "itemSpacing" to 0.0, + ) + + private val defaultTaskList: Map = + mapOf( + "checkedColor" to Color.BLACK, + "borderColor" to Color.BLACK, + "checkmarkColor" to Color.BLACK, + "checkedTextColor" to Color.BLACK, + "checkboxSize" to 16.0, + "checkboxBorderRadius" to 0.0, + "checkedStrikethrough" to false, + ) + + private fun textElement(): JavaOnlyMap = + JavaOnlyMap.of( + "fontFamily", + "", + "fontWeight", + "normal", + "fontSize", + 16.0, + "lineHeight", + 0.0, + "marginTop", + 0.0, + "marginBottom", + 0.0, + "color", + Color.BLACK, + "backgroundColor", + Color.TRANSPARENT, + "borderColor", + Color.TRANSPARENT, + "underline", + false, + "fontScale", + 0.8, + "baselineOffsetScale", + 0.5, + ) + + private fun JavaOnlyMap.putAll(values: Map) { + for ((key, value) in values) { + when (value) { + is String -> putString(key, value) + is Boolean -> putBoolean(key, value) + is Int -> putInt(key, value) + is Double -> putDouble(key, value) + is Float -> putDouble(key, value.toDouble()) + else -> error("Unsupported style value for $key: $value") + } + } + } +} diff --git a/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/test/TestAstFactory.kt b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/test/TestAstFactory.kt new file mode 100644 index 000000000..1808c3414 --- /dev/null +++ b/packages/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/test/TestAstFactory.kt @@ -0,0 +1,36 @@ +package com.swmansion.enriched.markdown.test + +import com.swmansion.enriched.markdown.parser.MarkdownASTNode +import com.swmansion.enriched.markdown.parser.MarkdownASTNode.NodeType + +object TestAstFactory { + fun paragraph(vararg children: MarkdownASTNode): MarkdownASTNode = MarkdownASTNode(NodeType.Paragraph, children = children.toList()) + + fun text(content: String): MarkdownASTNode = MarkdownASTNode(NodeType.Text, content = content) + + /** An ordered list; [start] is the first number, as md4c reports it. */ + fun orderedList( + vararg items: MarkdownASTNode, + start: Int? = null, + ): MarkdownASTNode = + MarkdownASTNode( + type = NodeType.OrderedList, + attributes = start?.let { mapOf("start" to it.toString()) }.orEmpty(), + children = items.toList(), + ) + + fun unorderedList(vararg items: MarkdownASTNode): MarkdownASTNode = MarkdownASTNode(NodeType.UnorderedList, children = items.toList()) + + fun listItem(vararg children: MarkdownASTNode): MarkdownASTNode = MarkdownASTNode(NodeType.ListItem, children = children.toList()) + + /** A GFM task-list item, what md4c produces for `- [ ]` / `- [x]`. */ + fun taskListItem( + checked: Boolean, + vararg children: MarkdownASTNode, + ): MarkdownASTNode = + MarkdownASTNode( + type = NodeType.ListItem, + attributes = mapOf("isTask" to "true", "taskChecked" to checked.toString()), + children = children.toList(), + ) +} diff --git a/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.m index ed80dfad7..dd6aa2478 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ListItemRenderer.m @@ -83,25 +83,29 @@ - (void)renderNodeContent:(MarkdownASTNode *)node // currentDepth - 1 handles the horizontal offset for nested lists const NSInteger nestingLevel = currentDepth - 1; - const CGFloat baseMarkerWidth = isTask ? [_config effectiveListMarginLeftForTask] - : (context.listType == ListTypeOrdered) ? [_config effectiveListMarginLeftForNumber] + // A task in a widened ordered list moves with its numbered siblings. + const CGFloat baseMarkerWidth = isTask ? [_config effectiveListMarginLeftForTask] + context.listMarkerExtra + : (context.listType == ListTypeOrdered) ? context.listMarkerWidth : [_config effectiveListMarginLeftForBullet]; const CGFloat blockquoteIndent = blockquoteIndentForDepth(context.blockquoteDepth, [_config blockquoteBorderWidth] + [_config blockquoteGapWidth]); const CGFloat totalIndent = blockquoteIndent + baseMarkerWidth + [_config effectiveListGapWidth] + - (nestingLevel * [_config listStyleMarginLeft]); + (nestingLevel * [_config listStyleMarginLeft]) + context.ancestorMarkerOffset; const NSUInteger startLocation = output.length; // Manual save/restore — a scope snapshot would also roll back the listItemNumber increment + const CGFloat prevMarkerExtra = context.listItemMarkerExtra; + context.listItemMarkerExtra = context.listMarkerExtra; const CGFloat prevIndent = context.accumulatedIndent; context.accumulatedIndent = totalIndent; @try { [_rendererFactory renderChildrenOfNode:node into:output context:context]; } @finally { context.accumulatedIndent = prevIndent; + context.listItemMarkerExtra = prevMarkerExtra; } // Ensure every list item ends with a newline to prevent paragraph merging diff --git a/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.m index 47283dc99..61812a7c0 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/ListRenderer.m @@ -90,6 +90,24 @@ - (void)renderNodeContent:(MarkdownASTNode *)node // ListItemRenderer pre-increments, so the counter starts one below the // first rendered number. context.listItemNumber = startNumber - 1; + const CGFloat defaultMarkerWidth = [_config effectiveListMarginLeftForNumber:99]; + if (prevDepth > 0) { + context.ancestorMarkerOffset += context.listItemMarkerExtra; + } + context.listMarkerWidth = defaultMarkerWidth; + if (_isOrdered) { + NSInteger number = startNumber; + for (MarkdownASTNode *item in node.children) { + if (item.type != MarkdownNodeTypeListItem) + continue; + // Keep the existing gutter for one- and two-digit markers. + if (number >= 100 && ![item.attributes[@"isTask"] isEqualToString:@"true"]) { + context.listMarkerWidth = MAX(context.listMarkerWidth, [_config effectiveListMarginLeftForNumber:number]); + } + number++; + } + } + context.listMarkerExtra = context.listMarkerWidth - defaultMarkerWidth; [context setBlockStyle:_isOrdered ? BlockTypeOrderedList : BlockTypeUnorderedList font:_config.listStyleFont diff --git a/packages/react-native-enriched-markdown/ios/renderer/RenderContext.h b/packages/react-native-enriched-markdown/ios/renderer/RenderContext.h index ad4a6431a..376b5e99e 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/RenderContext.h +++ b/packages/react-native-enriched-markdown/ios/renderer/RenderContext.h @@ -52,6 +52,10 @@ typedef NS_ENUM(NSInteger, ListType) { ListTypeUnordered, ListTypeOrdered }; @property (nonatomic, assign) NSInteger listDepth; @property (nonatomic, assign) ListType listType; @property (nonatomic, assign) NSInteger listItemNumber; +@property (nonatomic, assign) CGFloat listMarkerWidth; +@property (nonatomic, assign) CGFloat listMarkerExtra; +@property (nonatomic, assign) CGFloat ancestorMarkerOffset; +@property (nonatomic, assign) CGFloat listItemMarkerExtra; @property (nonatomic, assign) CGFloat accumulatedIndent; @property (nonatomic, assign) BOOL allowFontScaling; @property (nonatomic, assign) CGFloat maxFontSizeMultiplier; diff --git a/packages/react-native-enriched-markdown/ios/renderer/RenderContext.m b/packages/react-native-enriched-markdown/ios/renderer/RenderContext.m index b22686708..fc5e6312c 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/RenderContext.m +++ b/packages/react-native-enriched-markdown/ios/renderer/RenderContext.m @@ -24,6 +24,10 @@ @implementation BlockStyle @"listDepth", @"listType", @"listItemNumber", + @"listMarkerWidth", + @"listMarkerExtra", + @"ancestorMarkerOffset", + @"listItemMarkerExtra", @"accumulatedIndent", ]; }); @@ -313,6 +317,10 @@ - (void)reset _listDepth = 0; _listType = ListTypeUnordered; _listItemNumber = 0; + _listMarkerWidth = 0; + _listMarkerExtra = 0; + _ancestorMarkerOffset = 0; + _listItemMarkerExtra = 0; _accumulatedIndent = 0; _taskItemCount = 0; diff --git a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h index 628d069ed..815a86a57 100644 --- a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h +++ b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.h @@ -336,7 +336,7 @@ NS_ASSUME_NONNULL_BEGIN - (UIFont *)listStyleFont; - (CGFloat)effectiveListGapWidth; - (CGFloat)effectiveListMarginLeftForBullet; -- (CGFloat)effectiveListMarginLeftForNumber; +- (CGFloat)effectiveListMarginLeftForNumber:(NSInteger)number; - (CGFloat)effectiveListMarginLeftForTask; // Code block properties - (CGFloat)codeBlockFontSize; diff --git a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm index 33876bd21..82ae4be95 100644 --- a/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm +++ b/packages/react-native-enriched-markdown/ios/styles/StyleConfig.mm @@ -2079,11 +2079,13 @@ - (CGFloat)effectiveListMarginLeftForBullet return MAX(_listStyleMarkerMinWidth, _listStyleBulletSize / 2.0); } -- (CGFloat)effectiveListMarginLeftForNumber +- (CGFloat)effectiveListMarginLeftForNumber:(NSInteger)number { UIFont *font = [self listMarkerFont]; CGFloat natural = - [@"99." sizeWithAttributes:@{NSFontAttributeName : font ?: [UIFont systemFontOfSize:_listStyleFontSize]}].width; + [[NSString stringWithFormat:@"%ld.", (long)number] + sizeWithAttributes:@{NSFontAttributeName : font ?: [UIFont systemFontOfSize:_listStyleFontSize]}] + .width; return MAX(_listStyleMarkerMinWidth, natural); }