Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions modules/termflow-bench/src/main/scala/termflow/bench/Fixtures.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package termflow.bench

import termflow.tui.*
import termflow.tui.ScreenPrelude.*

/**
* Deterministic, terminal-free [[termflow.tui.Layout]] trees shared by the
* Layout microbenchmarks ([[LayoutMeasureBench]], [[LayoutResolveBench]]).
*
* Three representative shapes, each available at a few sizes, exercise the
* distinct code paths in `Layout.measure` / `Layout.resolve`:
*
* - `flatRow` — a single [[termflow.tui.Layout.Row]] of leaf text
* nodes, built with the public `Layout.row` helper (one level deep).
* - `nestedColumn` — a [[termflow.tui.Layout.Column]] of
* [[termflow.tui.Layout.Row]]s, built from the enum cases directly because
* the fluent helpers only accept leaf `VNode` children.
* - `grid` — a fixed-column [[termflow.tui.Layout.Grid]] of
* [[termflow.tui.GridCell]]s.
* - `fillRow` — a [[termflow.tui.Layout.Row]] of multiple
* [[termflow.tui.Layout.Fill]] siblings, so the budgeted resolve path
* (`resolveTo` with a non-negative budget) exercises the Fill / even-split
* distribution branch — natural sizing leaves that branch untouched.
*
* All trees are pure data: no terminal, no I/O, no randomness — the same
* `(shape, size)` always yields the same tree, so benchmark runs are
* comparable across machines and JVM invocations.
*/
object Fixtures:

/** Benchmark shape axis — drives [[tree]]'s structure. */
val Shapes: List[String] = List("flatRow", "nestedColumn", "grid", "fillRow")

/** Benchmark size axis — drives the child / cell counts in [[tree]]. */
val Sizes: List[String] = List("small", "medium", "large")

/** Origin used by the resolve benchmarks (1-based top-left). */
val Origin: Coord = Coord(XCoord(1), YCoord(1))

/** A leaf text node authored at the layout-friendly `(1, 1)` origin. */
private def leaf(label: String): VNode =
TextNode(1.x, 1.y, List(label.text))

/** Per-size counts: (number of items along the major axis). */
private def count(size: String): Int = size match
case "small" => 4
case "medium" => 16
case "large" => 64
case other => sys.error(s"unknown size: $other")

/**
* Build the layout tree for a `(shape, size)` pair.
*
* @throws RuntimeException for an unrecognised shape or size.
*/
def tree(shape: String, size: String): Layout =
val n = count(size)
shape match
case "flatRow" => flatRow(n)
case "nestedColumn" => nestedColumn(n)
case "grid" => grid(n)
case "fillRow" => fillRow(n)
case other => sys.error(s"unknown shape: $other")

/** A single Row of `n` leaf text nodes (built via the public helper). */
private def flatRow(n: Int): Layout =
Layout.row(gap = 1)((0 until n).map(i => leaf(s"cell$i"))*)

/**
* A Column of `rows` Rows, each holding `cols` leaf cells. Built from the
* `Layout.Column` / `Layout.Row` case constructors since the fluent helpers
* can't take Layout children. `rows * cols ≈ n` keeps the node count
* comparable to the flat shape at the same size.
*/
private def nestedColumn(n: Int): Layout =
val cols = math.max(2, math.sqrt(n.toDouble).round.toInt)
val rows = math.max(1, (n + cols - 1) / cols)
val rowLayouts = (0 until rows).map { r =>
val cells = (0 until cols).map(c => Layout.Elem(leaf(s"r${r}c$c"))).toList
Layout.Row(gap = 1, cells)
}.toList
Layout.Column(gap = 1, rowLayouts)

/**
* A fixed-column Grid of `n` single-span cells. Built from `Layout.Grid` +
* [[termflow.tui.GridCell]] directly to exercise the grid placement /
* sizing path.
*/
private def grid(n: Int): Layout =
val cols = math.max(2, math.sqrt(n.toDouble).round.toInt)
val cells = (0 until n).map(i => GridCell(Layout.Elem(leaf(s"g$i")))).toList
Layout.Grid(columns = cols, rowGap = 1, colGap = 2, cells = cells)

/**
* A Row of `n` [[termflow.tui.Layout.Fill]] siblings, each wrapping a leaf.
* Under a budgeted `resolveTo` the Fills split the available major axis
* evenly, so this is the shape that actually drives the Fill distribution
* branch (every other shape leaves it dormant).
*/
private def fillRow(n: Int): Layout =
val children = (0 until n).map(i => Layout.Fill(Layout.Elem(leaf(s"fill$i")))).toList
Layout.Row(gap = 1, children)
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package termflow.bench

import java.util.concurrent.TimeUnit

import org.openjdk.jmh.annotations.*

import termflow.tui.Layout

/**
* Microbenchmark for [[termflow.tui.Layout.measure]] over the shared
* [[Fixtures]] trees.
*
* The `(shape, size)` axes are JMH `@Param`s so a single run sweeps every
* representative tree. Trees are built once in [[setup]] (so the measured
* region only times `measure`, not construction) and the result tuple is
* returned from the `@Benchmark` so the JIT can't eliminate it.
*/
@BenchmarkMode(Array(Mode.AverageTime))
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
class LayoutMeasureBench:

@Param(Array("flatRow", "nestedColumn", "grid", "fillRow"))
var shape: String = ""

@Param(Array("small", "medium", "large"))
var size: String = ""

private var layout: Layout = null

@Setup
def setup(): Unit =
layout = Fixtures.tree(shape, size)

@Benchmark
def measure: (Int, Int) =
Layout.measure(layout)
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package termflow.bench

import java.util.concurrent.TimeUnit

import org.openjdk.jmh.annotations.*

import termflow.tui.{ Layout, VNode }

/**
* Microbenchmark for [[termflow.tui.Layout.resolve]] / `resolveTo` over the
* shared [[Fixtures]] trees.
*
* Mirrors [[LayoutMeasureBench]]: the `(shape, size)` axes are JMH `@Param`s,
* trees are built once in [[setup]], and each `@Benchmark` returns the
* resolved `List[VNode]` so dead-code elimination can't drop the work.
*
* - [[resolveNatural]] resolves at the natural size (the unbounded
* `availableWidth = availableHeight = -1` path that `Layout.resolve`
* takes).
* - [[resolveBudgeted]] resolves against a fixed terminal-sized budget,
* exercising the `Fill` / even-split distribution code in `resolveTo`.
*/
@BenchmarkMode(Array(Mode.AverageTime))
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
class LayoutResolveBench:

/** A representative bounded budget for the budgeted resolve path. */
private val BudgetWidth = 120
private val BudgetHeight = 40

@Param(Array("flatRow", "nestedColumn", "grid", "fillRow"))
var shape: String = ""

@Param(Array("small", "medium", "large"))
var size: String = ""

private var layout: Layout = null

@Setup
def setup(): Unit =
layout = Fixtures.tree(shape, size)

@Benchmark
def resolveNatural: List[VNode] =
Layout.resolve(layout, Fixtures.Origin)

@Benchmark
def resolveBudgeted: List[VNode] =
Layout.resolveTo(layout, Fixtures.Origin, BudgetWidth, BudgetHeight)
Loading