0.11.0 • Mar 2025
New Layout System
A View
's bounds is no longer editable directly as it was in previous versions of Doodle. This is a major change to the way Doodle layout functions; but it is important to avoid some major pitfalls of the previous approach. Namely, it was very easy to write code that would not produce the expected layout. This is a good example of something that would cause issues before:
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.core.view
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.fill
fun labelFitText() {
//sampleStart
view {
+ Label("Hello") // Label fits its text by default
layout = constrain(children.first(), fill) // ❌ Label would ignore layout
}
//sampleEnd
}
Now it just works as expected since View
s cannot override the Layout
they are managed by.
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.core.view
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.fill
fun labelFitText() {
//sampleStart
view {
+ Label("Hello") // Label fits its text by default
layout = constrain(children.first(), fill) // ✅ works as expected
}
//sampleEnd
}
Frosted Glass Paint
New Paint
that lets you create glass like material that blurs the underlying content. This, like all paints, can be used to fill any shape, stroke, or text.
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.View.SizeAuditor.Companion.preserveAspect
import io.nacular.doodle.core.center
import io.nacular.doodle.core.view
import io.nacular.doodle.core.width
import io.nacular.doodle.drawing.AffineTransform.Companion.Identity
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.FrostedGlassPaint
import io.nacular.doodle.drawing.GradientPaint.Stop
import io.nacular.doodle.drawing.LinearGradientPaint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.SweepGradientPaint
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.geometry.ringSection
import io.nacular.doodle.geometry.star
import io.nacular.doodle.utils.Resizer
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.times
import kotlin.math.min
class GlassPaintApp(display: Display, textMetrics: TextMetrics): Application {
val inset = 20
val cardRadius = 20.0
val cardWidth = min(display.width - 2 * inset, 340.0)
val cardNumber = "1253 5432 3521 3090"
val cardHolder = "John Smith"
val cardExpiry = "Exp 02/29"
val cardAspect = 1.586
val cardNumberSize = textMetrics.size(cardNumber)
val cardHolderSize = textMetrics.size(cardHolder)
val expirySize = textMetrics.size(cardExpiry)
val starRadius = 12.0
val starLogo = star(Circle(radius = starRadius), points = 4)!!
val colors = listOf(0xd278efu, 0x8dedd7u, 0xeecf87u, 0xd278efu).map { Color(it) }
val signalRadius = 12.0
val drawLogo: Canvas.(Point) -> Unit = { point ->
translate(by = point) {
poly(
starLogo, fill = SweepGradientPaint(
colors = colors.mapIndexed { index, color ->
Stop(color, index * 1f / colors.size)
},
center = Point(starRadius, starRadius),
rotation = 120 * (degrees)
)
)
}
}
val drawSignals: Canvas.(Point) -> Unit = { point ->
val thickness = 2.0
val gap = 2.0
translate(by = point) {
repeat(3) {
val outerRadius = signalRadius - it * (thickness + gap)
path(
ringSection(
center = Origin,
innerRadius = outerRadius - thickness,
outerRadius = outerRadius,
start = -45 * degrees,
end = 45 * degrees,
startCap = { _, it ->
arcTo(it, radius = thickness / 2, largeArch = true, sweep = true)
},
endCap = { _, it ->
arcTo(it, radius = thickness / 2, largeArch = true, sweep = true)
}
),
White.paint
)
}
}
}
init {
display += view {
render = {
val textScale = (width - inset * 2) / cardNumberSize.width
val cardNumberY = height - textScale * cardNumberSize.height - 2 * inset - cardHolderSize.height
val cardHolderY = cardNumberY + textScale * cardNumberSize.height + inset
// Card background
rect(
rectangle = bounds.atOrigin,
radius = cardRadius,
fill = SweepGradientPaint(
colors = colors.mapIndexed { index, color ->
Stop(color, index * 1f / colors.size)
},
center = Point(width / 2.0, height / 2.0),
rotation = 120 * (degrees)
)
)
scale(around = Point(inset, cardNumberY), textScale, textScale) {
text(cardNumber, at = Point(inset, cardNumberY), fill = White.paint) // Card Number
}
drawLogo(Point(2 * inset, 2 * inset)) // Card Logo
text(cardHolder, at = Point(inset, cardHolderY ), fill = White.paint) // Cardholder Name
text(cardExpiry, at = Point(width - expirySize.width - inset, cardHolderY), fill = White.paint) // Expiry
}
// ensure aspect ratio
sizeAuditor = preserveAspect(cardAspect)
suggestWidth(cardWidth)
Resizer(this).apply { directions = emptySet() }
boundsChanged += { _,o,n ->
if (o.size != n.size) {
// Center card once its size is updated
suggestPosition(display.center - Point(n.width / 2, n.height / 2))
}
}
}
display += view {
val borderThickness = 2.0
render = {
val textScale = (width - inset * 2) * 0.75 / cardNumberSize.width
val cardNumberY = inset
val cardHolderY = height - cardHolderSize.height - inset
// Card background
//sampleStart
rect(
rectangle = bounds.atOrigin.inset(borderThickness / 2),
radius = cardRadius,
fill = FrostedGlassPaint(Color(0x09008bu) opacity 0.2f, blurRadius = 10.0)
)
//sampleEnd
// Card outline
rect(
rectangle = bounds.atOrigin.inset(borderThickness / 2),
radius = cardRadius,
stroke = Stroke(
fill = LinearGradientPaint(
color1 = Color(0xf1d580u),
color2 = Color(0x8780e5u),
start = Origin,
end = Point(width, height),
),
thickness = borderThickness
)
)
scale(around = Point(inset, cardNumberY), textScale, textScale) {
text(cardNumber, at = Point(inset, cardNumberY), fill = White.paint) // Card Number
}
drawLogo (Point(width - starRadius - inset, cardHolderY - starRadius - inset )) // Card Logo
drawSignals(Point(width - signalRadius - inset, (cardNumberY + textScale * cardHolderSize.height / 2))) // Signal Logo
text(cardHolder, at = Point(inset, cardHolderY), fill = White.paint) // Cardholder Name
text(cardExpiry, at = Point(width - expirySize.width - inset, cardHolderY), fill = White.paint) // Expiry
}
sizeAuditor = preserveAspect(cardAspect)
suggestWidth(cardWidth)
Resizer(this).apply { directions = emptySet() }
boundsChanged += { _,o,n ->
if (o.size != n.size) {
val newCenter = display.center + Point(n.height / 3, 0.0)
// Center card once it's size is updated
suggestPosition(newCenter - Point(n.width / 2, n.height / 2))
// Rotate card
transform = Identity.rotate(by = 45 * degrees, around = newCenter)
}
}
}
display.fill(Color(0x0e131fu).paint)
}
override fun shutdown() {}
}
Text Outlining
You can now outline text using Stroke
s like other shapes. This includes StyledText
, which now supports strokes for styled segments.
- Regular Text
- Styled Text
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Color.Companion.Transparent
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Point.Companion.Origin
fun outlinedText() {
view {
//sampleStart
render = {
text(
text = "Hello Doodle!",
at = Origin,
fill = Transparent.paint,
stroke = Stroke()
)
}
//sampleEnd
}
}
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.text.invoke
import io.nacular.doodle.text.rangeTo
fun outlinedStyledText(stroke: Stroke) {
view {
//sampleStart
render = {
text(
text = "Hello " .. stroke { "Doodle!" },
at = Origin,
)
}
//sampleEnd
}
}
Inline Constraints for Forms
Forms have Layout
s that you can specify explicitly. But now you can also define constraint-based layouts declaratively when defining a Form.
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.labeled
import io.nacular.doodle.controls.form.spinButton
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.core.Behavior
import io.nacular.doodle.core.Display
import io.nacular.doodle.docs.utils.BlueColor
import io.nacular.doodle.docs.utils.colorStrip
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Blue
import io.nacular.doodle.drawing.Color.Companion.Green
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.Color.Companion.Red
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.lighter
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.Size.Companion.Empty
import io.nacular.doodle.layout.Insets
import io.nacular.doodle.layout.constraints.Strength.Companion.Strong
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.utils.Direction.East
import io.nacular.doodle.utils.Direction.West
import io.nacular.doodle.utils.Resizer
import kotlin.Double.Companion.POSITIVE_INFINITY
class FormLayoutApp(display: Display, themeManager: ThemeManager, theme: Theme): Application {
init {
themeManager.selected = theme
//sampleStart
val form = Form { this (
"Bob" to labeled("Name" ) { textField ( ) },
21 to labeled("Age" ) { spinButton(1..120 ) },
Green to labeled("Color") { colorStrip(Red, Green, Blue, BlueColor, Black) },
// define constraint layout for form with fields in declaration order
layout = { name, age, color ->
name.top eq parent.insets.top
name.left eq parent.insets.left
name.right eq age.left - 12 strength Strong
name.height eq name.idealHeight
age.top eq name.top
age.width eq 80
age.right eq parent.right - parent.insets.right
age.height eq age.idealHeight
color.top eq name.bottom + 12
color.left eq name.top
color.right eq age.right strength Strong
color.height eq color.preferredSize(
min = Empty,
max = Size(parent.width.readOnly, POSITIVE_INFINITY)
).height
parent.bottom eq color.bottom + parent.insets.bottom
},
onInvalid = {}
) { name: String, color: Int, age: Color ->
// ...
} }
//sampleEnd
form.apply {
insets = Insets(12.0)
focusable = false
behavior = object: Behavior<Form> {
override fun render(view: Form, canvas: Canvas) {
canvas.rect(view.bounds.atOrigin, Lightgray.lighter(0.75f).paint)
}
}
suggestWidth(300.0)
Resizer(this, movable = false).apply { directions = setOf(East, West) }
}
display += form
display.layout = constrain(form) {
it.center eq parent.center
it.height eq it.idealHeight
}
display.fill(White.paint)
}
override fun shutdown() {}
}
0.10.2 • June 2024
Animation Chaining
Animations can now be chained within an animation block using the new then
method. This makes it easier to have sequential animations and avoids the need to explicitly track secondary animations for cancellation, since these are tied to their "parent" animation.
package io.nacular.doodle.docs.apps
import io.nacular.doodle.animation.Animation
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.animation.transition.easeOutBounce
import io.nacular.doodle.animation.tween
import io.nacular.doodle.animation.tweenDouble
import io.nacular.doodle.animation.tweenPoint
import io.nacular.doodle.application.Application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.View
import io.nacular.doodle.core.height
import io.nacular.doodle.core.renderProperty
import io.nacular.doodle.core.view
import io.nacular.doodle.core.width
import io.nacular.doodle.docs.utils.controlBackgroundColor
import io.nacular.doodle.drawing.AffineTransform.Companion.Identity
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.event.PointerListener.Companion.on
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.Vector3D
import io.nacular.doodle.geometry.circumference
import io.nacular.doodle.geometry.inset
import io.nacular.doodle.geometry.lineTo
import io.nacular.doodle.geometry.path
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.fill
import io.nacular.doodle.utils.autoCanceling
import io.nacular.measured.units.Angle.Companion.acos
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.Time.Companion.seconds
import io.nacular.measured.units.times
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sqrt
class AnimationChainingApp(private val display: Display, private val animate: Animator): Application {
private class Wheel: View() {
val radius get() = circle.radius + thickness / 2
val circumference get() = 2 * PI * radius
var bottom get() = position + Point(radius, 2 * radius); set(new) {
suggestPosition(new - Point(radius, 2 * radius))
}
var rotation by renderProperty(0 * degrees)
private val thickness = 20.0
private val dashLength get() = circle.circumference / 8
private val circle get() = Circle(
center = Point(width, height) / 2.0,
radius = minOf(width/2, height/2) - thickness / 2
)
override fun contains(point: Point) = false
override fun render(canvas: Canvas) {
val c = circle
val d = dashLength
canvas.circle(c.inset(-thickness / 2 + 0.5), Stroke(Lightgray))
canvas.circle(c.inset( thickness / 2 ), Stroke(Lightgray))
canvas.rotate(around = Point(radius, radius), by = rotation) {
circle(
c,
Stroke(
color = Color.Blue,
dashes = doubleArrayOf(d, d),
thickness = thickness,
)
)}
}
}
private var animation: Animation<*>? by autoCanceling()
private val wheel = Wheel().apply { suggestSize(Size(100)) }
private var rampBounds = calculateRampBounds()
private val rollBounce = easeOutBounce(0.15f)
private val fallDuration = 1 * seconds
private val rollDuration = 3 * seconds
init {
display += view {
+ wheel
render = {
outerShadow(
color = Black opacity 0.05f,
vertical = -10.0,
blurRadius = 10.0
) {
path(
path(0.0, rampBounds.y)
.lineTo(rampBounds.position )
.lineTo(rampBounds.right, rampBounds.bottom)
.lineTo(0.0, rampBounds.bottom)
.finish(),
White.paint
)
}
}
pointerChanged += on(
entered = { startAnimation() },
pressed = { startAnimation() }
)
wheel.boundsChanged += { _,old,new ->
if (old.size != new.size) {
rampBounds = calculateRampBounds()
animation?.cancel()
resetWheel()
rerender()
}
}
}
display.fill(controlBackgroundColor.paint)
display.layout = constrain(display.first(), fill)
display.sizeChanged += { _,_,_ ->
rampBounds = calculateRampBounds()
animation?.cancel()
resetWheel()
}
}
private fun resetWheel() {
wheel.bottom = Point(wheel.radius, 2 * wheel.radius)
wheel.rotation = 0 * degrees
wheel.transform = Identity
}
private fun startAnimation() {
resetWheel()
animation = animate {
val wheelBottomStart = Point(wheel.radius, 2 * wheel.radius)
val rampLength = sqrt(rampBounds.width * rampBounds.width + rampBounds.height * rampBounds.height)
val radiusLengthRatio = wheel.radius / rampLength
val bottomYOffsetFromCenter = rampBounds.width * radiusLengthRatio
val bottomXOffsetFromCenter = rampBounds.height * radiusLengthRatio
val bottomXOffsetWall = wheel.radius + bottomXOffsetFromCenter
val bottomYOffsetFloor = bottomXOffsetWall * rampBounds.height / rampBounds.width
val tippingPoint = Identity.rotate(
around = Point(wheel.radius, rampBounds.y),
by = acos(rampBounds.width / rampLength)
)(Point(wheel.radius, rampBounds.y - wheel.radius))
val wheelBottomEnd = Point(
rampBounds.right - bottomXOffsetWall + bottomXOffsetFromCenter,
rampBounds.bottom - bottomYOffsetFloor - bottomYOffsetFromCenter + wheel.radius
)
val rollLength = wheelBottomEnd distanceFrom wheelBottomStart
val rotations = rollLength / wheel.circumference
//sampleStart
wheelBottomStart to Point(wheel.radius, rampBounds.y) using (tweenPoint(easeOutBounce, fallDuration)) {
// (1) Ball falls and bounces
wheel.bottom = it
} then {
// (2) Then it slides down the hill and bounces off the wall, by animating x and deriving y
wheel.bottom.x to rampBounds.width using (tweenDouble(rollBounce, rollDuration)) { x ->
wheel.bottom = Point(x, wheelBottomY(x, tippingPoint))
}
// (2) While rolling at the same time
0 * degrees to 360 * degrees * rotations using (tween(degrees, rollBounce, rollDuration)) {
wheel.rotation = it
}
}
//sampleEnd
}
}
/**
* Calculate the ball's bottom position to keep it on the ramp as it moves horizontally.
*
* The first phase of the move is the portion where the ball is rolling over the edge.
* Then it transitions to normal linear motion.
*/
private fun wheelBottomY(centerX: Double, tippingPoint: Vector3D) = when {
centerX < tippingPoint.x -> rampBounds.y - wheel.radius * cos((centerX - wheel.radius) / wheel.radius)
else -> tippingPoint.y + (centerX - tippingPoint.x) * rampBounds.height / rampBounds.width
} + wheel.radius
private fun calculateRampBounds() = Rectangle(
x = wheel.radius,
y = display.height / 2,
width = display.width - wheel.radius,
height = display.height / 2
)
override fun shutdown() {
animation?.cancel()
}
}
Desktop Accessibility
Doodle's web apps have had accessibility support for some time. Now those capabilities are available for desktop apps as well. You simply include the AccessibilityModule
in your app and follow the guidelines of how to add roles, labels, etc. to your Views.
SpinButton Accessibility
SpinButtons now use the new SpinButtonRole
that allows assistive tools to better read them. This role exposes the currently selected value based on a new valueAccessibilityLabeler
function that converts the value to a String
.
Improved Sliders
Arbitrary Types
Sliders can now represent values of any Comparable
type T
between two start
and end
values. This is possible for T
s that have some interpolation between a start
and end
based on some value between 0
and 1
. This is done via a new TypeConverter<T>
that defines the interpolation (and its inverse).
This means you can now create sliders for numeric types like Measure<T>
directly and their value
will be of the right type.
package controls
import io.nacular.doodle.controls.range.Slider
import io.nacular.measured.units.Length.Companion.meters
import io.nacular.measured.units.Length.Companion.miles
import io.nacular.measured.units.Time.Companion.hours
import io.nacular.measured.units.Time.Companion.seconds
import io.nacular.measured.units.div
import io.nacular.measured.units.times
//sampleStart
val velocitySlider = Slider(10 * meters / seconds .. 100 * miles / hours)
//sampleEnd
package controls
import io.nacular.doodle.controls.range.Slider
//sampleStart
val charSlider = Slider('A' .. 'Z')
//sampleEnd
You can also create Sliders for any type T
, as long as it is Comparable
and you can create an Interpolator
for it.
package controls
import io.nacular.doodle.controls.ConfinedValueModel
import io.nacular.doodle.controls.range.Slider
import io.nacular.doodle.utils.Interpolator
fun <T: Comparable<T>> customSlider(model: ConfinedValueModel<T>, interpolator: Interpolator<T>) {
//sampleStart
val slider: Slider<T> = Slider(model, interpolator = interpolator)
//sampleEnd
}
These, more flexible Sliders can also be used in forms as expected.
package controls
import io.nacular.doodle.controls.ConfinedValueModel
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.slider
import io.nacular.doodle.utils.Interpolator
import io.nacular.measured.units.Length.Companion.meters
import io.nacular.measured.units.Length.Companion.miles
import io.nacular.measured.units.Measure
import io.nacular.measured.units.Time.Companion.hours
import io.nacular.measured.units.Time.Companion.seconds
import io.nacular.measured.units.Velocity
import io.nacular.measured.units.div
import io.nacular.measured.units.times
fun <T: Comparable<T>> slidersInForm(model: ConfinedValueModel<T>, interpolator: Interpolator<T>) {
//sampleStart
Form {this(
+ slider('A' .. 'Z'),
+ slider(10 * meters/seconds .. 10 * miles/hours),
+ slider(model, interpolator = interpolator),
onInvalid = {}
) { _: Char, _: Measure<Velocity>, _: T ->
}}
//sampleEnd
}
This change applies to range, circular and circular-range sliders as well.
Non-linearity
Sliders are linear by default, which means a change in their position translates to a linear change in their value. There are cases however, when it makes sense to have a slider's value change in a non-linear way. You can do this by providing a function that maps values between the slider's input and output spaces. These values are all within the [0-1] domain, and work very similarly to easing functions used for animations. The big difference is they have two forms: f(x) and f^-1(x).
This examples shows two sliders that control the rectangle's opacity. One uses a logarithmic function while the other is the typical linear slider. Notice the difference in how quickly the opacity changes in the beginning when adjusting the logarithmic slider.
- Non-linearity
- Usage
package controls
import io.nacular.doodle.controls.range.InvertibleFunction
import io.nacular.doodle.controls.range.Slider
import kotlin.math.log
import kotlin.math.pow
//sampleStart
/**
* Logarithmic function and inverse https://www.desmos.com/calculator/qq59ey0bub
*/
private object LogFunction: InvertibleFunction {
override fun invoke (value: Float) = log((10f - 1) * value + 1, 10f)
override fun inverse(value: Float) = (10f.pow(value) - 1)/(10 - 1)
}
val logarithmicSlider = Slider(0.0 .. 1.0, function = LogFunction)
//sampleEnd
All slider types support custom functions to make them non-linear.
0.10.1 • May 2024
This version is mostly focused on bug fixes, but it also includes a new Paint
type.
Sweep Gradient Paint
You can now render content using the new SweepGradientPaint
. This paint creates a smooth gradient between colors around a center point. It is a great match for radial progress indicators.
package rendering
import io.nacular.doodle.core.view
import io.nacular.doodle.drawing.Color
import io.nacular.doodle.drawing.GradientPaint.Stop
import io.nacular.doodle.drawing.SweepGradientPaint
import io.nacular.doodle.geometry.Point
import io.nacular.measured.units.Angle
import io.nacular.measured.units.Measure
/**
* Example showing how to use [SweepGradientPaint]s.
*/
fun sweepGradientPaint(color1: Color, color2: Color, center: Point, rotation: Measure<Angle>) {
//sampleStart
view {
render = {
// Simple version with 2 colors
rect(bounds.atOrigin, SweepGradientPaint(
color1,
color2,
center,
rotation
))
}
}
view {
render = {
// Also able to use a list of color stops
rect(
bounds.atOrigin, SweepGradientPaint(
listOf(
Stop(color1, 0f),
Stop(color1, 1f / 3),
// ...
),
center,
rotation
)
)
}
}
//sampleEnd
}
This new paint makes it easy to create gradient controls like this simple progress indicator.
- Example
- Code
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.View
import io.nacular.doodle.core.container
import io.nacular.doodle.core.renderProperty
import io.nacular.doodle.docs.utils.BlueColor
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.SweepGradientPaint
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.event.PointerEvent
import io.nacular.doodle.event.PointerListener.Companion.entered
import io.nacular.doodle.event.PointerMotionListener.Companion.on
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.ringSection
import io.nacular.doodle.layout.constraints.center
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.fill
import io.nacular.doodle.utils.lerp
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.times
import kotlin.math.max
import kotlin.math.min
class SweepGradientProgressApp(display: Display): Application {
private class Progress: View() {
var progress by renderProperty(1f)
private val thickness = 50.0
private val startAngle = 20 * degrees
private val endAngle = 360 * degrees
init {
clipCanvasToBounds = false
}
override fun render(canvas: Canvas) {
val center = Point(width / 2, height / 2)
val outerRadius = min(center.x, center.y)
//sampleStart
canvas.outerShadow(vertical = 10.0, blurRadius = 10.0, color = Black opacity 0.5f) {
path(
ringSection(
center = center,
innerRadius = max(0.0, outerRadius - thickness),
outerRadius = outerRadius,
start = startAngle,
end = lerp(startAngle, endAngle, progress),
endCap = { _,it ->
arcTo(it, radius = thickness / 2, largeArch = true, sweep = true)
}
),
SweepGradientPaint(
color1 = BlueColor opacity 0f,
color2 = BlueColor opacity 1f,
center = center,
rotation = startAngle
)
)
}
//sampleEnd
}
}
init {
display += container {
val bar = Progress().apply { suggestSize(Size(200)) }
+bar
layout = constrain(bar, center)
val updateProgress = { event: PointerEvent ->
bar.progress = (toLocal(event.location, event.target).x / width).toFloat()
}
pointerChanged += entered {
updateProgress(it)
}
pointerMotionChanged += on(
moved = updateProgress,
dragged = updateProgress
)
}
display.layout = constrain(display.first(), fill)
display.fill(White.paint)
}
override fun shutdown() {}
}
You can also use this paint with circular ProgressIndicator and CircularRangeSlider via basicCircularProgressIndicatorBehavior
and basicCircularRangeSliderBehavior
respectively.
0.10.0 • Feb 2024
The latest version of Doodle brings lots of important updates, especially in terms of better platform support for both Browser and Desktop. Some of the key highlights include:
- New ability to host embed arbitrary HTML elements as Views on Web
- WASM JS support
- Multiple windows in Desktop apps
- OS menu bars in Desktop
- More native context menus in Desktop apps
Host arbitrary HTML elements (Browser)
You can now embed any HTML element into your app as a View. This means Doodle apps can now host React and other web components and interop with a much larger part of the Web ecosystem out of the box!
- App
- Example Launcher
package io.nacular.doodle.docs.apps
import io.nacular.doodle.HtmlElementViewFactory
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.core.Display
import io.nacular.doodle.docs.utils.DateRangeSelectionModel
import io.nacular.doodle.docs.utils.HorizontalCalendar
import io.nacular.doodle.docs.utils.ShadowCard
import io.nacular.doodle.drawing.Font
import io.nacular.doodle.geometry.PathMetrics
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import org.w3c.dom.HTMLElement
class ReactCalendarApp(
display : Display,
font : Font,
today : LocalDate,
animate : Animator,
pathMetrics : PathMetrics,
themeManager : ThemeManager,
theme : Theme,
htmlElementView: HtmlElementViewFactory,
reactCalendar : HTMLElement,
appHeight : (Double) -> Unit
): Application {
private val doodleCalendar = HorizontalCalendar(
today = today,
animate = animate,
pathMetrics = pathMetrics,
startDate = today,
endDate = today + DatePeriod(years = 10),
selectionModel = DateRangeSelectionModel()
).apply {
this.font = font
}
init {
themeManager.selected = theme
//sampleStart
display += Label("Doodle").apply { this.font = font }
display += Label("React" ).apply { this.font = font }
display += ShadowCard(doodleCalendar)
display += htmlElementView(element = reactCalendar)
//sampleEnd
val spacing = 20
display.children.last().boundsChanged += { _,_,new ->
// signal to outer docs about height of the app
appHeight(display.children.maxOf { it.bounds.bottom } + spacing)
}
display.layout = constrain(
display.children[0],
display.children[1],
display.children[2],
display.children[3]
) { doodleLabel, reactLabel, doodle, react ->
doodle.top eq doodleLabel.bottom + spacing
doodle.height eq 280
react.height eq doodle.height
doodleLabel.top eq spacing
doodleLabel.centerX eq doodle.centerX
doodleLabel.size.preserve
reactLabel.centerX eq react.centerX
reactLabel.size.preserve
when {
parent.width.readOnly > 800 -> {
doodle.width eq (parent.width - 3 * spacing) / 2
doodle.right eq parent.centerX - spacing / 2
react.top eq doodle.top
react.width eq doodle.width
react.left eq doodle.right + spacing
reactLabel.top eq doodleLabel.top
}
else -> {
doodle.left eq spacing
doodle.right eq parent.right - spacing
react.top eq reactLabel.bottom + spacing
react.left eq spacing
react.right eq parent.right - spacing
reactLabel.top eq doodle.bottom + spacing
}
}
}
}
override fun shutdown() {
// no-op
}
}
package elementview
import io.nacular.doodle.HtmlElementViewFactory
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.HtmlElementViewModule
import io.nacular.doodle.application.Modules
import io.nacular.doodle.application.application
import io.nacular.doodle.core.Display
import org.kodein.di.instance
import org.w3c.dom.HTMLElement
private class MyApp(
display : Display,
viewFactory: HtmlElementViewFactory,
element : HTMLElement
): Application {
init {
display += viewFactory(element)
}
override fun shutdown() {}
}
fun main(element: HTMLElement) {
//sampleStart
application(modules = listOf(Modules.HtmlElementViewModule)) {
MyApp(display = instance(), viewFactory = instance(), element = element)
}
//sampleEnd
}
This app embeds a react-calendar.
WASM JS (Browser)
Doodle now supports the WasmJS build target. This means apps can also target WebAssembly for the Browser. The APIs/features for this new target are identical as those for the js
target; which means code can be shared between apps targeting both. The only difference is that the application
launchers need to be called from separate source sets (i.e. jsMain
vs wasmJsMain
).
Multi-window apps (Desktop)
Apps for Desktop can now create/manage multiple windows using the new WindowGroup
interface. Simply inject it into your app to get started. The API provides access to an app's main
window as well as methods for creating new windows. Single window apps continue to work as they did before. That is, an app that injects the Display
will receive the main
window display and can manipulate it as before. But apps that want to manage their window(s) will need to inject this new type.
package display
import io.nacular.doodle.application.Application
import io.nacular.doodle.core.WindowGroup
import io.nacular.doodle.core.view
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.layout.constraints.fill
//sampleStart
class MyCoolApp(windows: WindowGroup): Application {
init {
// main window's display, same as if Display were injected
windows.main.apply {
title = "Main Window"
// manipulate main window's display
display += view {}
}
// create a new window
windows {
title = "A New Window!"
size = Size(500)
enabled = false
resizable = false
triesToAlwaysBeOnTop = true
// manipulate the new window's display
display += view {}
display.layout = constrain(display.first(), fill)
closed += {
// handle window close
}
}
}
override fun shutdown() {}
}
//sampleEnd
There's no need to inject Display
if you already inject WindowGroup
. That's because the injected Display
is equivalent to windowGroup.main.display
Native window menus (Desktop)
Apps can now set up native menus for their windows. This looks a lot like working with the existing menu APIs, but it results in changes to the OS window decoration. These menus are just as interactive as the in-app ones as well, meaning they trigger events when the user interacts with them.
package display
import io.nacular.doodle.controls.popupmenu.MenuBehavior.ItemInfo
import io.nacular.doodle.core.Icon
import io.nacular.doodle.core.Window
fun example(window: Window, icon1: Icon<ItemInfo>, icon2: Icon<ItemInfo>) {
//sampleStart
window.menuBar {
menu("Menu 1") {
action("Do action 2", icon1) { /*..*/ }
menu("Sub menu") {
action("Do action sub", icon = icon2) { /*..*/ }
separator()
prompt("Some Prompt sub") { /*..*/ }
}
separator()
prompt("Some Prompt") { /*..*/ }
}
menu("Menu 2") {
// ...
}
}
//sampleEnd
}
Native context menus
Apps can now set up native context/popup menus for their windows. The API is very similar to native menus.
package display
import io.nacular.doodle.controls.popupmenu.MenuBehavior.ItemInfo
import io.nacular.doodle.core.Icon
import io.nacular.doodle.core.Window
import io.nacular.doodle.geometry.Point
fun contextMenu(window: Window, icon1: Icon<ItemInfo>, icon2: Icon<ItemInfo>) {
//sampleStart
window.popupMenu(at = Point()) {
action("Do action 2", icon1) { /*..*/ }
menu("Sub menu") {
action("Do action sub", icon = icon2) { /*..*/ }
separator()
prompt("Some Prompt sub") { /*..*/ }
}
separator()
prompt("Some Prompt") { /*..*/ }
}
//sampleEnd
}
Key event filters and bubbling (All Platforms)
Key events now "sink" and "bubble" like pointer events. This means ancestor Views can intercept (and veto) them before they are delivered to their target (the focused View). They also bubble up to ancestors after being delivered to the target if they are not consumed. The notifications for the first phase happen via a new keyFilter
property, while the bubbling phase is notified via the existing keyChanged
property.
This change makes it much easier to create Views like the following; which intercepts the ENTER
key to press the submit button.
package io.nacular.doodle.docs.apps
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.buttons.PushButton
import io.nacular.doodle.controls.form.Always
import io.nacular.doodle.controls.form.Form
import io.nacular.doodle.controls.form.labeled
import io.nacular.doodle.controls.form.textField
import io.nacular.doodle.controls.form.verticalLayout
import io.nacular.doodle.controls.text.TextField
import io.nacular.doodle.core.Display
import io.nacular.doodle.drawing.Font
import io.nacular.doodle.event.KeyCode.Companion.Enter
import io.nacular.doodle.event.KeyListener.Companion.pressed
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.layout.constraints.constrain
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.utils.Resizer
class EnterKeyInterceptApp(
display : Display,
font : Font,
themeManager: ThemeManager,
theme : Theme
): Application {
private lateinit var name : TextField
private lateinit var password: TextField
private val submit = PushButton("Submit").apply {
this.font = font
this.enabled = false
this.fired += {
// clear fields
name.text = ""
password.text = ""
}
suggestSize(Size(100, 32))
}
private val form = Form { this (
+ labeled("Name", showRequired = Always()) { textField(Regex(".{3,}")) { name = textField } },
+ labeled("Password", showRequired = Always()) { textField(Regex(".{3,}")) { password = textField } },
onInvalid = { submit.enabled = false },
) { _,_ ->
submit.enabled = true
} }.apply {
this.font = font
this.layout = verticalLayout(spacing = 12.0)
this.focusable = false
suggestSize(Size(300, 100))
Resizer(this, movable = false)
}
init {
themeManager.selected = theme
//sampleStart
form.keyFilter += pressed {
if (it.code == Enter && submit.enabled) {
it.consume()
submit.click()
}
}
//sampleEnd
display += listOf(form, submit)
display.layout = constrain(form, submit) { form_, submit_ ->
val spacing = 10
form_.size eq form_.idealSize
form_.center eq parent.center - Point(y = (spacing + submit_.height.readOnly) / 2)
submit_.top eq form_.bottom + spacing
submit_.centerX eq form_.centerX
}
}
override fun shutdown() {}
}