Skip to content

Commit

Permalink
WIP Add support for only rendering certain sub-trees.
Browse files Browse the repository at this point in the history
Fixes #58.
  • Loading branch information
zach-klippenstein committed Aug 27, 2020
1 parent fb698d8 commit 5c9afac
Show file tree
Hide file tree
Showing 10 changed files with 233 additions and 113 deletions.
3 changes: 2 additions & 1 deletion radiography/src/main/java/radiography/Radiography.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.os.Looper
import android.view.View
import android.view.WindowManager
import radiography.Radiography.scan
import radiography.ViewFilter.FilterResult.INCLUDE
import radiography.ViewStateRenderers.DefaultsNoPii
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit.SECONDS
Expand Down Expand Up @@ -73,7 +74,7 @@ public object Radiography {
listOf(it)
} ?: WindowScanner.findAllRootViews()

val matchingRootViews = rootViews.filter(viewFilter::matches)
val matchingRootViews = rootViews.filter { viewFilter.matches(it) == INCLUDE }
val viewVisitor = ViewTreeRenderingVisitor(viewStateRenderers, viewFilter)

for (view in matchingRootViews) {
Expand Down
89 changes: 53 additions & 36 deletions radiography/src/main/java/radiography/RenderTreeString.kt
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
package radiography

import radiography.TreeRenderingVisitor.ChildToVisit
import radiography.TreeRenderingVisitor.RenderingScope
import java.util.ArrayDeque
import java.util.BitSet

/**
* Renders [rootNode] as a [String] by passing it to [visitor]'s [TreeRenderingVisitor.visitNode]
* method.
*
* @param skip If true, this node's description will be ignored and its children will be treated
* as the roots.
*/
internal fun <N> StringBuilder.renderTreeString(
rootNode: N,
visitor: TreeRenderingVisitor<N>
visitor: TreeRenderingVisitor<N>,
skip: Boolean = false
) {
renderRecursively(rootNode, visitor, depth = 0, lastChildMask = BitSet())
renderRecursively(rootNode, visitor, skip = skip, depth = 0, lastChildMask = BitSet())
}

internal abstract class TreeRenderingVisitor<in N> {

internal data class ChildToVisit<N>(
val node: N,
val visitor: TreeRenderingVisitor<N>,
val skip: Boolean
)

/**
* Renders nodes of type [N] by rendering them as strings to [RenderingScope.description] and
* recursively rendering their children via [RenderingScope.addChildToVisit].
Expand All @@ -24,31 +36,31 @@ internal abstract class TreeRenderingVisitor<in N> {
*/
abstract fun RenderingScope.visitNode(node: N)

/**
* Convenience overload of [RenderingScope.addChildToVisit] for children of the same type as this
* visitor.
*/
protected fun RenderingScope.addChildToVisit(childNode: N) =
addChildToVisit(childNode, this@TreeRenderingVisitor)

class RenderingScope(
/**
* A [StringBuilder] which should be used to render the current node, and will be included in
* the final rendering before any of this node's children.
*
* If null, this node is being skipped, and no description will be rendered.
*/
val description: StringBuilder,
private val children: MutableList<Pair<Any?, TreeRenderingVisitor<Any?>>>
val description: StringBuilder?,
private val children: MutableCollection<ChildToVisit<Any?>>
) {

/**
* Recursively visits a child of the current node using [visitor].
*
* @param skip If true, [childNode]'s description will be ignored, and its children (this node's
* grandchildren) will be rendered as direct children of this node.
*/
// TODO unit tests for when skip=true
fun <C> addChildToVisit(
childNode: C,
visitor: TreeRenderingVisitor<C>
visitor: TreeRenderingVisitor<C>,
skip: Boolean = false
) {
@Suppress("UNCHECKED_CAST")
children += Pair(childNode, visitor as TreeRenderingVisitor<Any?>)
children += ChildToVisit(childNode, visitor as TreeRenderingVisitor<Any?>, skip)
}
}
}
Expand All @@ -57,45 +69,50 @@ private fun <N> StringBuilder.renderRecursively(
node: N,
visitor: TreeRenderingVisitor<N>,
depth: Int,
skip: Boolean,
lastChildMask: BitSet
) {
// Collect the children before actually visiting them. This ensures we know the full list of
// children before we start iterating, which we need in order to be able to render the correct
// line prefix for the last child.
val children = mutableListOf<Pair<Any?, TreeRenderingVisitor<Any?>>>()
val children = ArrayDeque<ChildToVisit<Any?>>()

// Render node into a separate buffer to append a prefix to every line.
val nodeDescription = StringBuilder()
val scope = RenderingScope(nodeDescription, children)
with(visitor) { scope.visitNode(node) }

nodeDescription.lineSequence().forEachIndexed { index, line ->
appendLinePrefix(depth, continuePreviousLine = index > 0, lastChildMask = lastChildMask)
@Suppress("DEPRECATION")
appendln(line)
}

if (children.isEmpty()) return

val lastChildIndex = children.size - 1
for (index in 0..lastChildIndex) {
val isLastChild = (index == lastChildIndex)
// Set bit before recursing, will be unset again before returning.
if (isLastChild) {
lastChildMask.set(depth)
if (!skip) {
nodeDescription.lineSequence().forEachIndexed { index, line ->
appendLinePrefix(depth, continuePreviousLine = index > 0, lastChildMask = lastChildMask)
@Suppress("DEPRECATION")
appendln(line)
}
}

val (childNode, childNodeVisitor) = children[index]
// Never null on the main thread, but if called from another thread all bets are off.
childNode?.let {
renderRecursively(childNode, childNodeVisitor, depth + 1, lastChildMask)
}
while (children.isNotEmpty()) {
val (childNode, childNodeVisitor, skipChild) = children.removeFirst()
if (!skipChild) {
if (children.isEmpty()) {
// No more children can be enqueued for this node, so we can be certain this is the last
// child.
lastChildMask.set(depth)
}

// Unset the bit we set above before returning.
if (isLastChild) {
lastChildMask.clear(depth)
// Never null on the main thread, but if called from another thread all bets are off.
childNode?.let {
renderRecursively(childNode, childNodeVisitor, depth + 1, skipChild, lastChildMask)
}
} else {
// Visit the child directly, without generating a description but adding its children to our
// queue to be processed by this loop.
val childScope = RenderingScope(null, children)
with(childNodeVisitor) { childScope.visitNode(childNode) }
}
}

// Unset the bit we set above before returning.
lastChildMask.clear(depth)
}

private fun StringBuilder.appendLinePrefix(
Expand Down
28 changes: 23 additions & 5 deletions radiography/src/main/java/radiography/ViewFilter.kt
Original file line number Diff line number Diff line change
@@ -1,26 +1,44 @@
package radiography

import radiography.ViewFilter.FilterResult
import radiography.ViewFilter.FilterResult.INCLUDE

/**
* Used to filter out views from the output of [Radiography.scan].
*/
// TODO this isn't really just a "filter" anymore, it's more of a "selector". Rename?
public interface ViewFilter {

enum class FilterResult {
/** Include this view and all of its children which also match the filter. */
INCLUDE,

/** Exclude this view, but include all of its children which match the filter. */
INCLUDE_ONLY_CHILDREN,

/** Exclude this view and don't process any of its children. */
EXCLUDE
}

/**
* @return true to keep the view in the output of [Radiography.scan], false to filter it out.
*/
public fun matches(view: Any): Boolean
public fun matches(view: Any): FilterResult
}

/**
* Base class for implementations of [ViewFilter] that only want to filter instances of a specific
* type. Instances of other types are always "matched" by this filter.
* type. Instances of other types are always [included][INCLUDE] by this filter.
*/
internal abstract class TypedViewFilter<in T : Any>(
private val filterClass: Class<T>
) : ViewFilter {
public abstract fun matchesTyped(view: T): Boolean
public abstract fun matchesTyped(view: T): FilterResult

final override fun matches(view: Any): FilterResult {
if (!filterClass.isInstance(view)) return INCLUDE

final override fun matches(view: Any): Boolean {
@Suppress("UNCHECKED_CAST")
return !filterClass.isInstance(view) || matchesTyped(view as T)
return matchesTyped(view as T)
}
}
44 changes: 30 additions & 14 deletions radiography/src/main/java/radiography/ViewFilters.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package radiography

import android.view.View
import radiography.ViewFilter.FilterResult
import radiography.ViewFilter.FilterResult.EXCLUDE
import radiography.ViewFilter.FilterResult.INCLUDE
import radiography.ViewFilter.FilterResult.INCLUDE_ONLY_CHILDREN

public object ViewFilters {

/** A [ViewFilter] that matches everything (does not do any filtering). */
@JvmStatic
public val NoFilter: ViewFilter = object : ViewFilter {
override fun matches(view: Any): Boolean = true
override fun matches(view: Any) = INCLUDE
}

/**
Expand All @@ -16,7 +20,7 @@ public object ViewFilters {
*/
@JvmField
public val FocusedWindowViewFilter: ViewFilter = viewFilterFor<View> { view ->
view.parent?.parent != null || view.hasWindowFocus()
if (view.parent?.parent != null || view.hasWindowFocus()) INCLUDE else EXCLUDE
}

/**
Expand All @@ -25,39 +29,51 @@ public object ViewFilters {
@JvmStatic
public fun skipIdsViewFilter(vararg skippedIds: Int): ViewFilter = viewFilterFor<View> { view ->
val viewId = view.id
(viewId == View.NO_ID || skippedIds.isEmpty() || skippedIds.binarySearch(viewId) < 0)
if (viewId == View.NO_ID ||
skippedIds.isEmpty() ||
skippedIds.binarySearch(viewId) < 0
) INCLUDE else EXCLUDE
}

/**
* Creates a new filter that combines this filter with [otherFilter]
*/
// TODO unit tests for all combinations of FilterResults.
@JvmStatic
public infix fun ViewFilter.and(otherFilter: ViewFilter): ViewFilter {
val thisFilter = this
return object : ViewFilter {
override fun matches(view: Any) = thisFilter.matches(view) && otherFilter.matches(view)
override fun matches(view: Any): FilterResult {
val thisResult = thisFilter.matches(view)
val otherResult = otherFilter.matches(view)

if (thisResult == EXCLUDE || otherResult == EXCLUDE) return EXCLUDE
if (thisResult == INCLUDE && otherResult == INCLUDE) return INCLUDE

// At least one filter wants to exclude this node, but both filters want to include
// children.
return INCLUDE_ONLY_CHILDREN
}
}
}

/**
* Returns a [ViewFilter] that matches any instances of [T] for which [predicate] returns true.
*/
/** Returns a [ViewFilter] that matches instances of [T] using [matches]. */
// This function is only visible to Kotlin consumers of this library.
public inline fun <reified T : Any> viewFilterFor(noinline predicate: (T) -> Boolean): ViewFilter {
public inline fun <reified T : Any> viewFilterFor(
noinline matches: (T) -> FilterResult
): ViewFilter {
// Don't create an anonymous instance here, since that would generate a new anonymous class at
// every call site.
return viewFilterFor(T::class.java, predicate)
return viewFilterFor(T::class.java, matches)
}

/**
* Returns a [ViewFilter] that matches any instances of [T] for which [predicate] returns true.
*/
/** Returns a [ViewFilter] that matches instances of [T] using [matches]. */
// This function is only visible to Java consumers of this library.
@JvmStatic
@PublishedApi internal fun <T : Any> viewFilterFor(
filterClass: Class<T>,
predicate: (T) -> Boolean
matches: (T) -> FilterResult
): ViewFilter = object : TypedViewFilter<T>(filterClass) {
override fun matchesTyped(view: T): Boolean = predicate(view)
override fun matchesTyped(view: T) = matches(view)
}
}
34 changes: 22 additions & 12 deletions radiography/src/main/java/radiography/ViewTreeRenderingVisitor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import android.annotation.TargetApi
import android.os.Build.VERSION_CODES
import android.view.View
import android.view.ViewGroup
import radiography.ViewFilter.FilterResult.INCLUDE
import radiography.ViewFilter.FilterResult.INCLUDE_ONLY_CHILDREN
import radiography.compose.tryVisitComposeView

/**
Expand All @@ -16,7 +18,7 @@ internal class ViewTreeRenderingVisitor(
) : TreeRenderingVisitor<View>() {

override fun RenderingScope.visitNode(node: View) {
description.viewToString(node)
description?.viewToString(node)

val isComposeView = tryVisitComposeView(
this, node, viewStateRenderers, viewFilter, this@ViewTreeRenderingVisitor
Expand All @@ -26,17 +28,8 @@ internal class ViewTreeRenderingVisitor(
return
}

if (node !is ViewGroup) return

// Capture this value, since it might change while we're iterating.
val childCount = node.childCount
for (index in 0 until childCount) {
// Child may be null, if children were removed by another thread after we captured the child
// count. getChildAt returns null for invalid indices, it doesn't throw.
val child = node.getChildAt(index) ?: continue
if (viewFilter.matches(child)) {
addChildToVisit(child)
}
if (node is ViewGroup) {
visitChildrenOf(node)
}
}

Expand All @@ -51,4 +44,21 @@ internal class ViewTreeRenderingVisitor(
}
append(" }")
}

private fun RenderingScope.visitChildrenOf(view: ViewGroup) {
// Capture this value, since it might change while we're iterating.
val childCount = view.childCount
for (index in 0 until childCount) {
// Child may be null, if children were removed by another thread after we captured the child
// count. getChildAt returns null for invalid indices, it doesn't throw.
val child = view.getChildAt(index) ?: continue
when (viewFilter.matches(child)) {
INCLUDE -> addChildToVisit(child, this@ViewTreeRenderingVisitor)
INCLUDE_ONLY_CHILDREN -> addChildToVisit(child, this@ViewTreeRenderingVisitor, skip = true)
else -> {
// Noop.
}
}
}
}
}
Loading

0 comments on commit 5c9afac

Please sign in to comment.