Skip to main content

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:

0.10.x
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 Views cannot override the Layout they are managed by.

0.11.0
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.

Inspired by Mehmet Özsoy
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 Strokes like other shapes. This includes StyledText, which now supports strokes for styled segments.

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 } }

Inline Constraints for Forms

Forms have Layouts 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 Ts 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 }
tip

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.

tip

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.

tip

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!

Mon
Tue
Wed
Thu
Fri
Sat
Sun
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 } }
info

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
tip

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() {} }