Skip to content
Open
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
54 changes: 54 additions & 0 deletions algebra/shared/src/main/scala/doodle/algebra/Layout.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import doodle.core.Vec

trait Layout extends Algebra {

import Layout.Scalar

/** Place the origin of top on the origin of bottom */
def on[A](top: Drawing[A], bottom: Drawing[A])(implicit
s: Semigroup[A]
Expand Down Expand Up @@ -55,13 +57,36 @@ trait Layout extends Algebra {
left: Double
): Drawing[A]

/** Expand the bounding box of img by the given amounts, evaluated relative to
* the image's current bounding box.
*
* `top` and `bottom` are evaluated relative to the current bounding box
* height; `left` and `right` are evaluated relative to the current bounding
* box width.
*/
def margin[A](
img: Drawing[A],
top: Scalar,
right: Scalar,
bottom: Scalar,
left: Scalar
): Drawing[A]

/** Set the width and height of the given `Drawing's` bounding box to the
* given values. The new bounding box has the same origin as the original
* bounding box, and extends symmetrically above and below, and left and
* right of the origin.
*/
def size[A](img: Drawing[A], width: Double, height: Double): Drawing[A]

/** Set the width and height of the given `Drawing's` bounding box to values
* evaluated relative to the current bounding box.
*
* `width` is evaluated relative to the current bounding box width, and
* `height` is evaluated relative to the current bounding box height.
*/
def size[A](img: Drawing[A], width: Scalar, height: Scalar): Drawing[A]

// Derived methods

def under[A](bottom: Drawing[A], top: Drawing[A])(implicit
Expand Down Expand Up @@ -104,3 +129,32 @@ trait Layout extends Algebra {
def size[A](img: Drawing[A], extent: Double): Drawing[A] =
size(img, extent, extent)
}

object Layout {

/** A scalar magnitude that can be evaluated relative to a baseline.
*
* This is used for layout operations like `margin` and `size`, where we want
* to express absolute values (usually pixels) or values relative to the
* current bounding box (e.g. a fraction of width or height).
*/
sealed trait Scalar {
def eval(baseline: Double): Double
}

object Scalar {
final case class Absolute(value: Double) extends Scalar {
def eval(baseline: Double): Double = value
}

/** A fraction of the baseline. For example, `0.1` means 10% of the
* baseline.
*/
final case class Fraction(value: Double) extends Scalar {
def eval(baseline: Double): Double = baseline * value
}

def absolute(value: Double): Scalar = Absolute(value)
def fraction(value: Double): Scalar = Fraction(value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import doodle.core.Transform
trait GenericLayout[G[_]] extends Layout {
self: GivenApply[G] with Algebra { type Drawing[A] = Finalized[G, A] } =>
import Renderable.*
import Layout.Scalar

def on[A](top: Finalized[G, A], bottom: Finalized[G, A])(implicit
s: Semigroup[A]
Expand Down Expand Up @@ -118,6 +119,28 @@ trait GenericLayout[G[_]] extends Layout {
(newBb, rdr)
}

def margin[A](
img: Finalized[G, A],
top: Scalar,
right: Scalar,
bottom: Scalar,
left: Scalar
): Finalized[G, A] =
img.map { case (bb, rdr) =>
val topV = top.eval(bb.height)
val bottomV = bottom.eval(bb.height)
val rightV = right.eval(bb.width)
val leftV = left.eval(bb.width)

val newBb = BoundingBox(
left = bb.left - leftV,
top = bb.top + topV,
right = bb.right + rightV,
bottom = bb.bottom - bottomV
)
(newBb, rdr)
}

def size[A](
img: Finalized[G, A],
width: Double,
Expand Down Expand Up @@ -145,4 +168,35 @@ trait GenericLayout[G[_]] extends Layout {
(newBb, rdr)
}
}

def size[A](
img: Finalized[G, A],
width: Scalar,
height: Scalar
): Finalized[G, A] =
img.map { case (bb, rdr) =>
val resolvedWidth = width.eval(bb.width)
val resolvedHeight = height.eval(bb.height)

assert(
resolvedWidth >= 0,
s"Called `size` with a width of ${resolvedWidth}. The bounding box's width must be non-negative."
)
assert(
resolvedHeight >= 0,
s"Called `size` with a height of ${resolvedHeight}. The bounding box's height must be non-negative."
)

val w = resolvedWidth / 2.0
val h = resolvedHeight / 2.0

val newBb = BoundingBox(
left = -w,
top = h,
right = w,
bottom = -h
)

(newBb, rdr)
}
}
18 changes: 18 additions & 0 deletions algebra/shared/src/main/scala/doodle/syntax/LayoutSyntax.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ trait LayoutSyntax {
implicit class LayoutPictureOps[Alg <: Algebra, A](
picture: Picture[Alg, A]
) {
import Layout.Scalar
def on[Alg2 <: Algebra](
that: Picture[Alg2, A]
)(implicit s: Semigroup[A]): Picture[Alg with Alg2 with Layout, A] =
Expand Down Expand Up @@ -151,6 +152,17 @@ trait LayoutSyntax {
algebra.margin(picture(algebra), top, right, bottom, left)
}

def margin(
top: Scalar,
right: Scalar,
bottom: Scalar,
left: Scalar
): Picture[Alg with Layout, A] =
new Picture[Alg with Layout, A] {
def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] =
algebra.margin(picture(algebra), top, right, bottom, left)
}

def margin(width: Double, height: Double): Picture[Alg with Layout, A] =
new Picture[Alg with Layout, A] {
def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] =
Expand All @@ -169,6 +181,12 @@ trait LayoutSyntax {
algebra.size(picture(algebra), width, height)
}

def size(width: Scalar, height: Scalar): Picture[Alg with Layout, A] =
new Picture[Alg with Layout, A] {
def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] =
algebra.size(picture(algebra), width, height)
}

def size(extent: Double): Picture[Alg with Layout, A] =
new Picture[Alg with Layout, A] {
def apply(implicit algebra: Alg with Layout): algebra.Drawing[A] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package generic

import cats.implicits.*
import doodle.algebra.generic.reified.Reification
import doodle.algebra.Layout.Scalar
import doodle.core.BoundingBox
import doodle.core.Transform as Tx
import org.scalacheck.*
Expand Down Expand Up @@ -245,6 +246,41 @@ object LayoutSpec extends Properties("Layout properties") {
}
}

property("relative margin expands bounding box by the correct amount") = {
val algebra = TestAlgebra()
val genShape = Generators.finalizedOfDepth(algebra, 5)
val genFraction = Gen.choose[Double](-0.5, 0.5)

forAllNoShrink(
genShape,
genFraction,
genFraction,
genFraction,
genFraction
) { (shape, topF, rightF, bottomF, leftF) =>
val bb = shape.boundingBox
val newBb = algebra
.margin(
shape,
Scalar.fraction(topF),
Scalar.fraction(rightF),
Scalar.fraction(bottomF),
Scalar.fraction(leftF)
)
.boundingBox

val top = bb.height * topF
val bottom = bb.height * bottomF
val right = bb.width * rightF
val left = bb.width * leftF

(newBb.left ?= bb.left - left) &&
(newBb.top ?= bb.top + top) &&
(newBb.right ?= bb.right + right) &&
(newBb.bottom ?= bb.bottom - bottom)
}
}

property("size sets bounding box to the correct size") = {
val algebra = TestAlgebra()
val genShape = Generators.finalizedOfDepth(algebra, 5)
Expand All @@ -261,4 +297,25 @@ object LayoutSpec extends Properties("Layout properties") {
(newBb.bottom ?= -(height / 2))
}
}

property("relative size sets bounding box to the correct size") = {
val algebra = TestAlgebra()
val genShape = Generators.finalizedOfDepth(algebra, 5)
val genFraction = Gen.choose[Double](0.0, 2.0)

forAllNoShrink(genShape, genFraction, genFraction) { (shape, wF, hF) =>
val bb = shape.boundingBox
val width = bb.width * wF
val height = bb.height * hF

val newBb = algebra
.size(shape, Scalar.fraction(wF), Scalar.fraction(hF))
.boundingBox

(newBb.left ?= -(width / 2)) &&
(newBb.right ?= (width / 2)) &&
(newBb.top ?= (height / 2)) &&
(newBb.bottom ?= -(height / 2))
}
}
}