Repository: cashapp/redwood
Pull request #382 (2022-09-26 01:30:57)

Add a test that lays out strings visually
Testing flexbox should be easy. With this its a picnic.

swankjesse: Rebased!

Patch:
diff --git a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/FlexboxEngine.kt b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/FlexboxEngine.kt
index 4910dd609..8cf11e2c2 100644
--- a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/FlexboxEngine.kt
+++ b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/FlexboxEngine.kt
@@ -71,7 +71,7 @@ public class FlexboxEngine {
   )
 
   /**
-   * The computed flex lines after calling [measure].
+   * The computed flex lines after calling [applyMeasure].
    */
   internal var flexLines = listOf<FlexLine>()
 
@@ -235,7 +235,7 @@ public class FlexboxEngine {
             direction.crossMarginStart(child) + direction.crossMarginEnd(child) + sumCrossSize,
           childDimension = direction.crossSize(child),
         )
-        child.measure(childMainMeasureSpec, childCrossMeasureSpec)
+        child.applyMeasure(childMainMeasureSpec, childCrossMeasureSpec)
         updateMeasureCache(i, childMainMeasureSpec, childCrossMeasureSpec, child)
       } else {
         childCrossMeasureSpec = MeasureSpec.getChildMeasureSpec(
@@ -250,7 +250,7 @@ public class FlexboxEngine {
             direction.mainMarginStart(child) + direction.mainMarginEnd(child),
           childDimension = childMainSize,
         )
-        child.measure(childCrossMeasureSpec, childMainMeasureSpec)
+        child.applyMeasure(childCrossMeasureSpec, childMainMeasureSpec)
         updateMeasureCache(i, childCrossMeasureSpec, childMainMeasureSpec, child)
       }
 
@@ -287,7 +287,7 @@ public class FlexboxEngine {
               padding = padding.top + padding.bottom + child.margin.top + child.margin.bottom + sumCrossSize,
               childDimension = child.height,
             )
-            child.measure(childMainMeasureSpec, childCrossMeasureSpec)
+            child.applyMeasure(childMainMeasureSpec, childCrossMeasureSpec)
             measureWithConstraints(child, i)
           }
         } else {
@@ -304,7 +304,7 @@ public class FlexboxEngine {
               padding = padding.start + padding.end + child.margin.start + child.margin.end + sumCrossSize,
               childDimension = child.width,
             )
-            child.measure(childCrossMeasureSpec, childMainMeasureSpec)
+            child.applyMeasure(childCrossMeasureSpec, childMainMeasureSpec)
             measureWithConstraints(child, i)
           }
         }
@@ -456,7 +456,7 @@ public class FlexboxEngine {
     if (needsMeasure) {
       val widthSpec = MeasureSpec.from(childWidth, MeasureSpecMode.Exactly)
       val heightSpec = MeasureSpec.from(childHeight, MeasureSpecMode.Exactly)
-      node.measure(widthSpec, heightSpec)
+      node.applyMeasure(widthSpec, heightSpec)
       updateMeasureCache(index, widthSpec, heightSpec, node)
     }
   }
@@ -642,7 +642,7 @@ public class FlexboxEngine {
             flexLine.sumCrossSizeBefore,
           )
           val childWidthMeasureSpec = MeasureSpec.from(newWidth, MeasureSpecMode.Exactly)
-          child.measure(childWidthMeasureSpec, childHeightMeasureSpec)
+          child.applyMeasure(childWidthMeasureSpec, childHeightMeasureSpec)
           childMeasuredWidth = child.measuredWidth
           childMeasuredHeight = child.measuredHeight
           updateMeasureCache(index, childWidthMeasureSpec, childHeightMeasureSpec, child)
@@ -702,7 +702,7 @@ public class FlexboxEngine {
             padding = flexLine.sumCrossSizeBefore,
           )
           val childHeightMeasureSpec = MeasureSpec.from(newHeight, MeasureSpecMode.Exactly)
-          child.measure(childWidthMeasureSpec, childHeightMeasureSpec)
+          child.applyMeasure(childWidthMeasureSpec, childHeightMeasureSpec)
           childMeasuredWidth = child.measuredWidth
           childMeasuredHeight = child.measuredHeight
           updateMeasureCache(index, childWidthMeasureSpec, childHeightMeasureSpec, child)
@@ -825,7 +825,7 @@ public class FlexboxEngine {
             flexLine.sumCrossSizeBefore,
           )
           val childWidthMeasureSpec = MeasureSpec.from(newWidth, MeasureSpecMode.Exactly)
-          child.measure(childWidthMeasureSpec, childHeightMeasureSpec)
+          child.applyMeasure(childWidthMeasureSpec, childHeightMeasureSpec)
           childMeasuredWidth = child.measuredWidth
           childMeasuredHeight = child.measuredHeight
           updateMeasureCache(index, childWidthMeasureSpec, childHeightMeasureSpec, child)
@@ -883,7 +883,7 @@ public class FlexboxEngine {
             padding = flexLine.sumCrossSizeBefore,
           )
           val childHeightMeasureSpec = MeasureSpec.from(newHeight, MeasureSpecMode.Exactly)
-          child.measure(childWidthMeasureSpec, childHeightMeasureSpec)
+          child.applyMeasure(childWidthMeasureSpec, childHeightMeasureSpec)
           childMeasuredWidth = child.measuredWidth
           childMeasuredHeight = child.measuredHeight
           updateMeasureCache(index, childWidthMeasureSpec, childHeightMeasureSpec, child)
@@ -1212,7 +1212,7 @@ public class FlexboxEngine {
     }
     val childWidthSpec = MeasureSpec.from(measuredWidth, MeasureSpecMode.Exactly)
     val childHeightSpec = MeasureSpec.from(newHeight, MeasureSpecMode.Exactly)
-    node.measure(childWidthSpec, childHeightSpec)
+    node.applyMeasure(childWidthSpec, childHeightSpec)
     updateMeasureCache(index, childWidthSpec, childHeightSpec, node)
   }
 
@@ -1233,7 +1233,7 @@ public class FlexboxEngine {
     }
     val childHeightSpec = MeasureSpec.from(measuredHeight, MeasureSpecMode.Exactly)
     val childWidthSpec = MeasureSpec.from(newWidth, MeasureSpecMode.Exactly)
-    node.measure(childWidthSpec, childHeightSpec)
+    node.applyMeasure(childWidthSpec, childHeightSpec)
     updateMeasureCache(index, childWidthSpec, childHeightSpec, node)
   }
 
@@ -1853,4 +1853,13 @@ public class FlexboxEngine {
     }
     return indexToReorderedIndex?.getOrNull(index)?.let(nodes::getOrNull)
   }
+
+  /**
+   * Call [Node.measure] and update [Node.measuredWidth] and [Node.measuredHeight] with the result.
+   */
+  private fun Node.applyMeasure(widthSpec: MeasureSpec, heightSpec: MeasureSpec) {
+    val size = measure(widthSpec, heightSpec)
+    this.measuredWidth = size.width
+    this.measuredHeight = size.height
+  }
 }
diff --git a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Node.kt b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Node.kt
index 09b99cb32..80eed45b3 100644
--- a/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Node.kt
+++ b/redwood-flexbox-engine/src/commonMain/kotlin/app/cash/redwood/Node.kt
@@ -159,13 +159,13 @@ public class Node(
   public var measuredHeight: Int = -1
 
   /**
-   * A callback to to measure this node according to the `widthSpec` and `heightSpec` constraints
-   * and update [measuredWidth] and [measuredHeight] with the result.
+   * A callback to to measure this node according to the `widthSpec` and `heightSpec` constraints.
    */
   public var measure: (widthSpec: MeasureSpec, heightSpec: MeasureSpec) -> Size = { widthSpec, heightSpec ->
-    measuredWidth = MeasureSpec.resolveSize(width, widthSpec)
-    measuredHeight = MeasureSpec.resolveSize(height, heightSpec)
-    Size(measuredWidth, measuredHeight)
+    Size(
+      width = MeasureSpec.resolveSize(width, widthSpec),
+      height = MeasureSpec.resolveSize(height, heightSpec),
+    )
   }
 
   /**


Test patch:
diff --git a/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxTest.kt b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxTest.kt
new file mode 100644
index 000000000..cc750a24a
--- /dev/null
+++ b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/FlexboxTest.kt
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood
+
+import app.cash.redwood.FlexDirection.Companion.Row
+import app.cash.redwood.FlexDirection.Companion.RowReverse
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class FlexboxTest {
+  private val imdbTop4 = listOf(
+    "The Shawshank Redemption",
+    "The Godfather",
+    "The Dark Knight",
+    "The Godfather Part II",
+  )
+
+  @Test
+  fun column() {
+    val widgets = imdbTop4.map { StringWidget(it) }
+    val engine = FlexboxEngine().apply {
+      flexDirection = FlexDirection.Column
+      nodes += widgets.map { it.toNode() }
+    }
+
+    assertEquals(
+      """
+      ┌──────────┐··
+      |The       │··
+      |Shawshank │··
+      |Redemption│··
+      └──────────┘··
+      ┌─────────┐···
+      |The      │···
+      |Godfather│···
+      └─────────┘···
+      ┌────────┐····
+      |The Dark│····
+      |Knight  │····
+      └────────┘····
+      ┌─────────┐···
+      |The      │···
+      |Godfather│···
+      |Part II  │···
+      └─────────┘···
+      ··············
+      ··············
+      """.trimIndent(),
+      engine.layout(14, 20, widgets),
+    )
+  }
+
+  @Test
+  fun row() {
+    val widgets = imdbTop4.map { StringWidget(it) }
+    val engine = FlexboxEngine().apply {
+      flexDirection = Row
+      nodes += widgets.map { it.toNode() }
+    }
+
+    assertEquals(
+      """
+      ┌───────────────────┐┌─────────┐┌──────────┐┌──────────────┐
+      |The Shawshank      │|The      │|The Dark  │|The Godfather │
+      |Redemption         │|Godfather│|Knight    │|Part II       │
+      └───────────────────┘└─────────┘└──────────┘└──────────────┘
+      ····························································
+      ····························································
+      ····························································
+      ····························································
+      """.trimIndent(),
+      engine.layout(60, 8, widgets),
+    )
+  }
+
+  @Test
+  fun rowCrossAxisCentered() {
+    val widgets = imdbTop4.map { StringWidget(it) }
+    val engine = FlexboxEngine().apply {
+      flexDirection = Row
+      nodes += widgets.map { it.toNode() }
+      alignItems = AlignItems.Center
+    }
+
+    assertEquals(
+      """
+      ··········································
+      ┌──────────┐···········┌──────┐┌─────────┐
+      |The       │┌─────────┐|The   │|The      │
+      |Shawshank │|The      │|Dark  │|Godfather│
+      |Redemption│|Godfather│|Knight│|Part II  │
+      └──────────┘└─────────┘└──────┘└─────────┘
+      ··········································
+      ··········································
+      """.trimIndent(),
+      engine.layout(42, 8, widgets),
+    )
+  }
+
+  @Test
+  fun rowMainAxisCentered() {
+    val widgets = imdbTop4.map { StringWidget(it) }
+    val engine = FlexboxEngine().apply {
+      flexDirection = Row
+      nodes += widgets.map { it.toNode(flexBasisPercent = 0f) }
+      justifyContent = JustifyContent.Center
+    }
+
+    assertEquals(
+      """
+      ·········┌──────────┐┌─────────┐┌──────┐┌─────────┐·········
+      ·········|The       │|The      │|The   │|The      │·········
+      ·········|Shawshank │|Godfather│|Dark  │|Godfather│·········
+      ·········|Redemption│└─────────┘|Knight│|Part II  │·········
+      ·········└──────────┘···········└──────┘|         │·········
+      ········································└─────────┘·········
+      """.trimIndent(),
+      engine.layout(60, 6, widgets),
+    )
+  }
+
+  private fun FlexboxEngine.layout(
+    width: Int,
+    height: Int,
+    widgets: List<StringWidget>,
+  ): String {
+    val canvas = StringCanvas(width, height)
+    val widthSpec = MeasureSpec.from(width, MeasureSpecMode.Exactly)
+    val heightSpec = MeasureSpec.from(height, MeasureSpecMode.Exactly)
+
+    flexLines = when (flexDirection) {
+      Row, RowReverse -> calculateHorizontalFlexLines(widthSpec, heightSpec)
+      else -> calculateVerticalFlexLines(widthSpec, heightSpec)
+    }
+
+    measure(widthSpec, heightSpec)
+    layout(0, 0, width, height)
+
+    for (widget in widgets) {
+      widget.draw(canvas)
+    }
+
+    return canvas.toString()
+  }
+}
diff --git a/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/StringCanvas.kt b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/StringCanvas.kt
new file mode 100644
index 000000000..abcf4a8f6
--- /dev/null
+++ b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/StringCanvas.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood
+
+/**
+ * A 2D grid of characters. Attempts to draw beyond the bounds are silently ignored.
+ *
+ * The default content contains the '·' character; this is chosen over ' ' as its less likely to
+ * be trimmed by an editor when used as expected output.
+ */
+class StringCanvas(
+  private val width: Int,
+  private val height: Int,
+) {
+  private val lines = Array(height) { CharArray(width) { '·' } }
+
+  operator fun set(x: Int, y: Int, value: Char) {
+    if (x in 0 until width && y in 0 until height) {
+      lines[y][x] = value
+    }
+  }
+
+  override fun toString() = lines.joinToString(separator = "\n") { it.concatToString() }
+}
diff --git a/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/StringWidget.kt b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/StringWidget.kt
new file mode 100644
index 000000000..29fe6f99f
--- /dev/null
+++ b/redwood-flexbox-engine/src/commonTest/kotlin/app/cash/redwood/StringWidget.kt
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2022 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package app.cash.redwood
+
+/**
+ * A string that wraps on space characters, and is wrapped with an ASCII-art box. We use a string
+ * and not a fixed-size box because it works more like real widgets.
+ */
+class StringWidget(
+  private val text: String,
+) {
+  private val words = text.split(" ")
+
+  private var left = -1
+  private var top = -1
+  private var right = -1
+  private var bottom = -1
+
+  // TODO: fix the Node API to not conflate layout parameters with measurements.
+  fun toNode(
+    flexBasisPercent: Float = Node.DefaultFlexBasisPercent,
+  ): Node {
+    return Node(
+      minWidth = words.maxOf { it.length } + 2,
+      minHeight = 2,
+      flexBasisPercent = flexBasisPercent,
+    ).apply {
+      measure = this@StringWidget::measure
+      layout = this@StringWidget::layout
+    }
+  }
+
+  private fun measure(widthSpec: MeasureSpec, heightSpec: MeasureSpec): Size {
+    val widthSpecMode = widthSpec.mode
+    val width = widthSpec.size
+    val heightSpecMode = heightSpec.mode
+    val height = heightSpec.size
+
+    val lines = when (widthSpecMode) {
+      MeasureSpecMode.Exactly -> lines(maxWidth = width - 2)
+      MeasureSpecMode.AtMost -> lines(maxWidth = width - 2)
+      else -> listOf(listOf(text))
+    }
+
+    val measuredWidth = when (widthSpecMode) {
+      MeasureSpecMode.Exactly -> width
+      MeasureSpecMode.AtMost -> minOf(
+        width,
+        (lines.maxOfOrNull { line -> line.sumOf { word -> word.length + 1 } - 1 } ?: 0) + 2,
+      )
+      else -> text.length + 2
+    }
+
+    val measuredHeight = when (heightSpecMode) {
+      MeasureSpecMode.Exactly -> height
+      MeasureSpecMode.AtMost -> minOf(height, lines.size + 2)
+      else -> lines.size + 2
+    }
+
+    return Size(measuredWidth, measuredHeight)
+  }
+
+  private fun layout(left: Int, top: Int, right: Int, bottom: Int) {
+    this.left = left
+    this.top = top
+    this.right = right
+    this.bottom = bottom
+  }
+
+  /** Returns a list of rows, each containing the words of that row. */
+  private fun lines(maxWidth: Int): List<List<String>> {
+    var i = 0
+    val result = mutableListOf<List<String>>()
+
+    // Build each row.
+    while (i < words.size) {
+      val row = mutableListOf<String>()
+      result += row
+      var rowWidth = 0
+
+      // Collect the words in this row. Each row gets at least one word!
+      while (i < words.size) {
+        val word = words[i]
+        if (rowWidth == 0) {
+          row += word
+          rowWidth = word.length
+          i++
+        } else if (rowWidth + 1 + word.length <= maxWidth) {
+          row += word
+          rowWidth += 1 + word.length
+          i++
+        } else {
+          break
+        }
+      }
+    }
+
+    return result
+  }
+
+  fun draw(canvas: StringCanvas) {
+    val lines = lines(maxWidth = right - left - 2)
+
+    var y = top
+
+    if (y < bottom) {
+      drawHorizontalLine(canvas, '┌', '┐', y, left, right)
+      y++
+    }
+
+    for (line in lines) {
+      if (y < bottom) {
+        drawWords(canvas, line, y, left, right)
+      }
+      y++
+    }
+
+    while (y < bottom - 1) {
+      drawWords(canvas, listOf(), y, left, right)
+      y++
+    }
+
+    if (top < bottom) {
+      drawHorizontalLine(canvas, '└', '┘', bottom - 1, left, right)
+    }
+  }
+
+  private fun drawHorizontalLine(
+    canvas: StringCanvas,
+    leftCorner: Char,
+    rightCorner: Char,
+    y: Int,
+    left: Int,
+    right: Int,
+  ) {
+    if (left < right) {
+      canvas[left, y] = leftCorner
+      canvas[right - 1, y] = rightCorner
+    }
+    for (i in (left + 1) until (right - 1)) {
+      canvas[i, y] = '─'
+    }
+  }
+
+  private fun drawWords(
+    canvas: StringCanvas,
+    words: List<String>,
+    y: Int,
+    left: Int,
+    right: Int,
+  ) {
+    var x = left
+
+    if (x < right) {
+      canvas[left, y] = '|'
+      x++
+    }
+
+    for ((w, word) in words.withIndex()) {
+      if (x >= right) break
+
+      if (w > 0) {
+        canvas[x++, y] = ' '
+      }
+
+      for (c in word.toCharArray()) {
+        if (x < right) {
+          canvas[x++, y] = c
+        }
+      }
+    }
+
+    while (x < right - 1) {
+      canvas[x++, y] = ' '
+    }
+
+    if (left < right) {
+      canvas[right - 1, y] = '│'
+    }
+  }
+}