|
| 1 | +// SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | +// SPDX-FileCopyrightText: Copyright (c) 2026 Shaan Narendran <shaannaren06@gmail.com> |
| 3 | +package com.ichi2.anki.widgets |
| 4 | + |
| 5 | +import android.content.Context |
| 6 | +import android.graphics.Canvas |
| 7 | +import android.graphics.Paint |
| 8 | +import android.graphics.Path |
| 9 | +import android.graphics.RectF |
| 10 | +import android.util.TypedValue |
| 11 | +import androidx.recyclerview.widget.RecyclerView |
| 12 | +import com.ichi2.anki.R |
| 13 | +import com.ichi2.utils.dp |
| 14 | + |
| 15 | +class DeckHierarchyLinesDecoration( |
| 16 | + context: Context, |
| 17 | +) : RecyclerView.ItemDecoration() { |
| 18 | + private val paint = |
| 19 | + Paint(Paint.ANTI_ALIAS_FLAG).apply { |
| 20 | + style = Paint.Style.STROKE |
| 21 | + strokeCap = Paint.Cap.BUTT |
| 22 | + strokeWidth = 2.dp.toPx(context).toFloat() |
| 23 | + |
| 24 | + val typedValue = TypedValue() |
| 25 | + context.theme.resolveAttribute(com.google.android.material.R.attr.colorOnSurface, typedValue, true) |
| 26 | + color = typedValue.data |
| 27 | + alpha = 30 |
| 28 | + } |
| 29 | + |
| 30 | + private val nestedIndent = context.resources.getDimension(R.dimen.keyline_1) |
| 31 | + private val expanderCenterOffset = 24.dp.toPx(context).toFloat() |
| 32 | + private val cornerRadius = nestedIndent - 12.dp.toPx(context) |
| 33 | + private val reusablePath = Path() |
| 34 | + private val reusableRect = RectF() |
| 35 | + |
| 36 | + /** |
| 37 | + * Cache used to store the bitmask of active vertical lines passing through each visible row. |
| 38 | + * The index maps to the child position in the RecyclerView, and the 64-bit Long stores the active depths. |
| 39 | + * It dynamically resizes if the RecyclerView holds more visible items. |
| 40 | + */ |
| 41 | + private var siblingCache = LongArray(50) |
| 42 | + |
| 43 | + override fun onDrawOver( |
| 44 | + c: Canvas, |
| 45 | + parent: RecyclerView, |
| 46 | + state: RecyclerView.State, |
| 47 | + ) { |
| 48 | + val childCount = parent.childCount |
| 49 | + if (childCount == 0) return |
| 50 | + |
| 51 | + val currentList = (parent.adapter as? DeckAdapter)?.currentList ?: return |
| 52 | + if (currentList.isEmpty()) return |
| 53 | + |
| 54 | + if (siblingCache.size < childCount) { |
| 55 | + siblingCache = LongArray(childCount * 2) |
| 56 | + } |
| 57 | + // We use a 64-bit Long as a bitmask. Each bit index represents a depth level. |
| 58 | + // If bit 'd' is 1, it means there is a sibling further down the list at depth 'd' |
| 59 | + // so we must draw a vertical line |
| 60 | + var activeLines = 0L |
| 61 | + |
| 62 | + // Look ahead past the last visible item to find lines that continue below the screen |
| 63 | + val lastView = parent.getChildAt(childCount - 1) |
| 64 | + val maxPos = parent.getChildAdapterPosition(lastView) |
| 65 | + if (maxPos != RecyclerView.NO_POSITION && maxPos + 1 < currentList.size) { |
| 66 | + for (i in maxPos + 1 until currentList.size) { |
| 67 | + val d = currentList[i].depth |
| 68 | + if (d >= 64) continue |
| 69 | + // Mark this depth as having an active sibling by shifting a 1 into the appropriate bit |
| 70 | + activeLines = activeLines or (1L shl d) |
| 71 | + |
| 72 | + // Any node resets the active lines for all depths deeper than itself |
| 73 | + // For example, if we hit a depth 1 node, depths 2, 3, etc. are cleared |
| 74 | + // eg:- If we have a deck Math with Algebra as a subdeck and linear, abstract are below it |
| 75 | + // this ends at depth 2. If there's another child of Math say Geometry, this would be at depth 1 |
| 76 | + // If we can see this, then that means we have no more depth 2 children, using a mask allows us |
| 77 | + // to terminate the deeper sub-nodes of a parent that has no more children like in the example |
| 78 | + val mask = if (d >= 63) -1L else (1L shl (d + 1)) - 1L |
| 79 | + activeLines = activeLines and mask |
| 80 | + if (d == 0) break // Root node resets everything below it |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + // Scan backwards over the visible items to record the active lines after each node |
| 85 | + for (i in childCount - 1 downTo 0) { |
| 86 | + val view = parent.getChildAt(i) |
| 87 | + val pos = parent.getChildAdapterPosition(view) |
| 88 | + if (pos == RecyclerView.NO_POSITION) { |
| 89 | + siblingCache[i] = 0L |
| 90 | + continue |
| 91 | + } |
| 92 | + |
| 93 | + // We store the bitmask we made earlier in the siblingcache for each row so that we can |
| 94 | + // know which lines need to pass through this row to reach the decks after it |
| 95 | + siblingCache[i] = activeLines |
| 96 | + |
| 97 | + // We look at the last deck that we can see and store it in d |
| 98 | + // eg:- if we have a child at the bottom of depth 2 our bits look like 0100 |
| 99 | + val d = currentList[pos].depth |
| 100 | + if (d < 64) { |
| 101 | + // We do an or with the activeLines (activelines stores the number of lines passing through |
| 102 | + // the current row) and this will give us the number of lines to draw |
| 103 | + activeLines = activeLines or (1L shl d) |
| 104 | + // We use a mask to mask off the bits for a deeper depth than what we calculated above |
| 105 | + val mask = if (d >= 63) -1L else (1L shl (d + 1)) - 1L |
| 106 | + activeLines = activeLines and mask |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + // Loop to draw the lines |
| 111 | + for (i in 0 until childCount) { |
| 112 | + val view = parent.getChildAt(i) |
| 113 | + val position = parent.getChildAdapterPosition(view) |
| 114 | + if (position == RecyclerView.NO_POSITION) continue |
| 115 | + |
| 116 | + val node = currentList[position] |
| 117 | + val depth = node.depth |
| 118 | + |
| 119 | + val top = view.y |
| 120 | + val bottom = view.y + view.height |
| 121 | + val centerY = top + view.height / 2f |
| 122 | + |
| 123 | + // Helper to check the precomputed bitmask |
| 124 | + val hasSibling = { targetDepth: Int -> |
| 125 | + targetDepth < 64 && (siblingCache[i] and (1L shl targetDepth)) != 0L |
| 126 | + } |
| 127 | + |
| 128 | + for (level in 0 until depth - 1) { |
| 129 | + if (hasSibling(level + 1)) { |
| 130 | + val x = getLineX(level) |
| 131 | + c.drawLine(x, top, x, bottom, paint) |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + if (depth > 0) { |
| 136 | + val level = depth - 1 |
| 137 | + val x = getLineX(level) |
| 138 | + val endX = x + cornerRadius |
| 139 | + |
| 140 | + if (hasSibling(depth)) { |
| 141 | + c.drawLine(x, top, x, bottom, paint) |
| 142 | + c.drawLine(x, centerY, endX, centerY, paint) |
| 143 | + } else { |
| 144 | + reusablePath.reset() |
| 145 | + reusablePath.moveTo(x, top) |
| 146 | + reusablePath.lineTo(x, centerY - cornerRadius) |
| 147 | + reusableRect.set(x, centerY - 2 * cornerRadius, x + 2 * cornerRadius, centerY) |
| 148 | + reusablePath.arcTo(reusableRect, 180f, -90f, false) |
| 149 | + c.drawPath(reusablePath, paint) |
| 150 | + } |
| 151 | + } |
| 152 | + |
| 153 | + if (position + 1 < currentList.size && currentList[position + 1].depth == depth + 1) { |
| 154 | + val x = getLineX(depth) |
| 155 | + val iconOffset = 12.dp.toPx(view.context).toFloat() |
| 156 | + c.drawLine(x, centerY + iconOffset, x, bottom, paint) |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + private fun getLineX(depth: Int): Float = depth * nestedIndent + expanderCenterOffset |
| 162 | +} |
0 commit comments