Skip to content
Draft
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
52 changes: 52 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,30 @@ 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,36 @@ 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 +292,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))
}
}
}
30 changes: 20 additions & 10 deletions svg/shared/src/main/scala/doodle/svg/algebra/Svg.scala
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,16 @@ trait SvgModule { self: Base =>
svgAttrs.width := w,
svgAttrs.height := h,
svgAttrs.viewBox := s"${bb.left - border} ${-bb.top - border} ${w} ${h}",
bundle.attrs.style :=
"pointer-events: bounding-box; " ++
frame.background
.map(c => s"background-color: ${Svg.toOklch(c)};")
.getOrElse("")
bundle.attrs.style := {
val parts = List(
Some("pointer-events: bounding-box;"),
frame.background.map(c =>
s"background-color: ${Svg.toOklch(c)};"
),
frame.isolation.map(i => s"isolation: ${i.toCSS};")
).flatten
parts.mkString(" ")
}
)

case Size.FixedSize(w, h) =>
Expand All @@ -106,11 +111,16 @@ trait SvgModule { self: Base =>
svgAttrs.width := w,
svgAttrs.height := h,
svgAttrs.viewBox := s"${-w / 2} ${-h / 2} ${w} ${h}",
bundle.attrs.style :=
"pointer-events: bounding-box; " ++
frame.background
.map(c => s"background-color: ${Svg.toOklch(c)};")
.getOrElse("")
bundle.attrs.style := {
val parts = List(
Some("pointer-events: bounding-box;"),
frame.background.map(c =>
s"background-color: ${Svg.toOklch(c)};"
),
frame.isolation.map(i => s"isolation: ${i.toCSS};")
).flatten
parts.mkString(" ")
}
)

}
Expand Down
10 changes: 8 additions & 2 deletions svg/shared/src/main/scala/doodle/svg/effect/Frame.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,20 @@ import doodle.core.Color
final case class Frame(
id: String,
size: Size,
background: Option[Color] = None
background: Option[Color] = None,
isolation: Option[Isolation] = None
) {

/** Use the given color as the background.
*/
def withBackground(color: Color): Frame =
this.copy(background = Some(color))

/** Set the CSS isolation property on the SVG element.
*/
def withIsolation(isolation: Isolation): Frame =
this.copy(isolation = Some(isolation))

/** Size the canvas to fit to the picture's bounding box, plus the given
* border around the bounding box.
*/
Expand All @@ -54,5 +60,5 @@ final case class Frame(
}
object Frame {
def apply(id: String): Frame =
Frame(id, Size.fitToPicture(), None)
Frame(id, Size.fitToPicture(), None, None)
}
31 changes: 31 additions & 0 deletions svg/shared/src/main/scala/doodle/svg/effect/Isolation.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2015 Creative Scala
*
* 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 doodle
package svg
package effect

sealed abstract class Isolation extends Product with Serializable {
def toCSS: String =
this match {
case Isolation.Auto => "auto"
case Isolation.Isolate => "isolate"
}
}
object Isolation {
case object Auto extends Isolation
case object Isolate extends Isolation
}
Loading
Loading