Skip to main content

Calculator Tutorial

Our goal is to build the following calculator app using Doodle. This is a simple calculator that only performs addition, subtraction, multiplication, and division. It also supports negative values, decimals, and has a convenience function for converting to a percentage.

However, it does not have more advanced features, like parentheses, or other math operations. This means the implementation is simpler, and we can focus on the way Doodle is used instead of the complexity of the app itself. Play around with the calculator to get a feel for it.

tip

You can also see the full-screen apps here: JavaScript, WebAssembly.

Project Setup

The app will use a Kotlin Multiplatform setup, which means we can run it on a range of targets supported by Doodle. The directory structure follows a fairly common layout, with common classes and resources in one source set and platform-specific items in their own.

Directory Layout
  • src
    • commonMain
      • kotlin
      • resources
  • build.gradle.kts

All source code and resources are located under the src directory.

The application logic itself is located in the common source set (src/commonMain), which means it is entirely reused for each platform. In fact, the same app is used unchanged (just targeting JS) within this documentation.

Doodle apps are built using gradle like other Kotlin apps. The build is controlled by the build.gradle.kts script in the root of the Calculator directory.

build.gradle.kts

@file:OptIn(ExperimentalWasmDsl::class) import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl //sampleStart plugins { kotlin("multiplatform") application } kotlin { js { browser { binaries.executable() } } // Web (JS ) executable wasmJs { browser { binaries.executable() // Web (WASM) executable applyBinaryen {} // Binary size optimization } } jvm { // Desktop (JVM ) executable compilations.all { kotlinOptions { jvmTarget = "11" } // JVM 11 is needed for Desktop } withJava() } sourceSets { // Source set for all platforms commonMain.dependencies { api(libs.coroutines.core) // async resource loading (fonts, images, ...) api(libs.doodle.controls) } // Web (JS) platform source set jsMain.dependencies { implementation(libs.doodle.browser) } // Web (WASM) platform source set val wasmJsMain by getting { dependencies { implementation(libs.doodle.browser) } } // Desktop (JVM) platform source set jvmMain.dependencies { // helper to derive OS/architecture pair when (osTarget()) { "macos-x64" -> implementation(libs.doodle.desktop.jvm.macos.x64 ) "macos-arm64" -> implementation(libs.doodle.desktop.jvm.macos.arm64 ) "linux-x64" -> implementation(libs.doodle.desktop.jvm.linux.x64 ) "linux-arm64" -> implementation(libs.doodle.desktop.jvm.linux.arm64 ) "windows-x64" -> implementation(libs.doodle.desktop.jvm.windows.x64 ) "windows-arm64" -> implementation(libs.doodle.desktop.jvm.windows.arm64) } } // JVM tests source set jvmTest.dependencies { implementation(kotlin("test-junit")) implementation(libs.bundles.test.libs) } } } // Desktop entry point application { mainClass.set("io.nacular.doodle.examples.MainKt") } //sampleEnd // could be moved to buildSrc, but kept here for clarity fun osTarget(): String { val osName = System.getProperty("os.name") val targetOs = when { osName == "Mac OS X" -> "macos" osName.startsWith("Win" ) -> "windows" osName.startsWith("Linux") -> "linux" else -> error("Unsupported OS: $osName") } val targetArch = when (val osArch = System.getProperty("os.arch")) { "x86_64", "amd64" -> "x64" "aarch64" -> "arm64" else -> error("Unsupported arch: $osArch") } return "${targetOs}-${targetArch}" }
info

The gradle build uses gradle version catalogs; see libs.versions.toml file for library info.

The Application

All Doodle apps must implement the Application interface. The constructor is used as the app's initialization entry point, and it is called as part of the application launch block. Doodle apps can be created in a platform agnostic way and placed into the commonMain source set so they are usable on multiple targets. This is how we will define the Calculator app.

This app will be fairly simple: just create an instance of our calculator and add it to the display.

CalculatorApp.kt

package io.nacular.doodle.examples // ... class CalculatorApp(display: Display): Application { init { // creat and display a single Calculator display += Calculator() } override fun shutdown() { /* no-op */ } }
tip

Notice that shutdown is a no-op, since we don't have any cleanup to do when the app closes.

App Launcher

Doodle apps can be launched in a few different ways on Web and Desktop. Use the application function in a platform source-set (i.e. jsMain, jvmMain, etc.) to launch top-level apps. It takes a list of modules to load and a lambda that builds the app. This lambda is within a Kodein injection context, which means we can inject dependencies into our app via instance, provider, etc.

main.kt

package io.nacular.doodle.examples import io.nacular.doodle.application.Modules.Companion.FontModule import io.nacular.doodle.application.Modules.Companion.PointerModule import io.nacular.doodle.application.application import org.kodein.di.instance /** * Creates a [CalculatorApp] */ //sampleStart fun main() { application(modules = listOf(FontModule, PointerModule)) { // load app CalculatorApp( display = instance(), textMetrics = instance(), fonts = instance(), numberFormatter = NumberFormatterImpl() ) } } //sampleEnd

Notice that we have included the FontModule and PointerModule. These are needed to enable font loading and pointer interactions. Our app will not directly know we loaded the PointerModule, but pointer related events will only work because we have.

tip

Check out Kodein to learn more about how it handles dependency injection.

The application function also takes an optional HTML element (for Web targets) within which the app will be hosted. The app will be hosted in document.body if no element is specified.

App launching is the only part of our code that is platform-specific. This makes sense, since it is where we define concrete dependencies to pass into our app that can vary by platform. It is also where we control how our app runs, which is platform specific.

Calculator View

We will implement our calculator as a single View that manages its state directly. This makes sense for simple use-cases, but might not be the right choice for larger apps.

This View will be broken into a hierarchy of views, with two top-level items: a custom Output and a GridPanel.

The Output is a really simple View that tracks a number and its text representation, which it renders to the screen. It also exposes the number as the current "answer" on the calculator. Its most complex role is displaying the text with proper alignment and scaling to avoid any clipping.

tip

Notice how the output text starts off center aligned with the operator buttons; and how it shrinks as the number grows beyond the screen size.

Take a look at the Output class, and you will see it has a text property that it tracks the width of. It also uses textTransform to perform the text scaling.

private inner class Output: View() { //... // Transform used to scale text down as it grows beyond window width private var textTransform = Identity //... /** Text representation of number */ var text = "0" set(new) { field = new val textWidth = textMetrics.width(field, font) val windowWidth = width - inset * 2 // use transform when text grows beyond window width textTransform = when { textWidth > windowWidth -> (windowWidth/textWidth).let { Identity.scale(x = it, y = it, around = Point(width / 2, height)) } else -> Identity } rerender() } //... override fun render(canvas: Canvas) { val textPosition = textMetrics.size(text, font).let { val x = when { textTransform.isIdentity -> width - it.width - inset else -> (width - it.width) / 2 } Point(x, height - it.height) } // scaling, if present, is applied to the canvas before text rendered canvas.transform(textTransform) { text(text, at = textPosition, font = font, color = foregroundColor ?: White) } } }

More Dependencies

The Output class, and others in Calculator need things like fonts, and the ability to measure text. Doodle provides these capabilities via interfaces like FontLoader and TextMetrics. These are provided to Calculator via constructor injection. That results in a constructor as follows:

Calculator.kt

class Calculator( private val fonts : FontLoader, appScope : CoroutineScope, private val textMetrics : TextMetrics, private val numberFormatter: NumberFormatter ): View() { }

This means CalculatorApp needs to be updated as well. We continue by injecting these dependencies there.

package io.nacular.doodle.examples import io.nacular.doodle.application.Application import io.nacular.doodle.core.Display import io.nacular.doodle.drawing.FontLoader import io.nacular.doodle.drawing.TextMetrics import io.nacular.doodle.layout.constraints.constrain import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob /** * Simple calculate app that places a [Calculator] at the center of the display. */ //sampleStart class CalculatorApp( display : Display, textMetrics : TextMetrics, fonts : FontLoader, numberFormatter: NumberFormatter ): Application { init { val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) display += Calculator(fonts, appScope, textMetrics, numberFormatter).apply { sizePreferencesChanged += { _,_,_ -> display.relayout() } } display.layout = constrain(display.children[0]) { it.width eq (display.children.firstOrNull()?.idealSize?.width ?: 0.0) it.height eq (display.children.firstOrNull()?.idealSize?.height ?: 0.0) it.center eq parent.center } } override fun shutdown() { /* no-op */ } } //sampleEnd

Now main needs to provide these, along with Display, when constructing the app.

package io.nacular.doodle.examples import io.nacular.doodle.application.Modules.Companion.FontModule import io.nacular.doodle.application.Modules.Companion.PointerModule import io.nacular.doodle.application.application import org.kodein.di.instance /** * Creates a [CalculatorApp] */ //sampleStart fun main() { application(modules = listOf(FontModule, PointerModule)) { // load app CalculatorApp( display = instance(), textMetrics = instance(), fonts = instance(), numberFormatter = NumberFormatterImpl() ) } } //sampleEnd
tip

Unlike TextMetrics, FontLoader and is not included in Doodle's default modules, so we have to load it explicitly using the FontModule.

The Buttons

We can manage the set of buttons within the calculator with a GridPanel, which provides the kind of layout we need. This results in the following initialization for Calculator.

Calculator.kt

package io.nacular.doodle.examples import io.nacular.doodle.controls.buttons.Button import io.nacular.doodle.controls.buttons.ButtonGroup import io.nacular.doodle.controls.buttons.PushButton import io.nacular.doodle.controls.buttons.ToggleButton import io.nacular.doodle.controls.panels.GridPanel import io.nacular.doodle.core.Layout import io.nacular.doodle.core.PositionableContainer import io.nacular.doodle.core.View 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.Darkgray import io.nacular.doodle.drawing.Color.Companion.Lightgray import io.nacular.doodle.drawing.Color.Companion.Orange import io.nacular.doodle.drawing.Color.Companion.White import io.nacular.doodle.drawing.FontLoader import io.nacular.doodle.drawing.TextMetrics import io.nacular.doodle.drawing.darker import io.nacular.doodle.drawing.lighter import io.nacular.doodle.drawing.rect import io.nacular.doodle.drawing.text import io.nacular.doodle.geometry.Point import io.nacular.doodle.geometry.Size import io.nacular.doodle.layout.constraints.constrain import io.nacular.doodle.system.Cursor.Companion.Pointer import io.nacular.doodle.utils.roundToNearest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlin.js.JsName import kotlin.math.pow /** * Simple calculator with basic math operations. * * @property fonts used to find fonts * @param appScope used to run coroutines * @property textMetrics used to measure text * @property numberFormatter used to display number output */ class Calculator( private val fonts : FontLoader, appScope : CoroutineScope, private val textMetrics : TextMetrics, private val numberFormatter: NumberFormatter ): View() { // region ================ Helper Classes ================================== private inner class Output: View() { // Default width when input starts at `0` private val defaultWidth get() = textMetrics.width("0", font) // Text inset from the left/right edge of the output private val inset by lazy { (clear.width - defaultWidth) / 2 } // Transform used to scale text down as it grows beyond window width private var textTransform = Identity /** Numeric value of the output */ var number = 0.0; set(new) { field = new text = numberFormatter(number) } /** Text representation of [number] */ var text = "0"; set(new) { field = new val textWidth = textMetrics.width(field, font) val windowWidth = width - inset * 2 // use transform when text grows beyond window width textTransform = when { textWidth > windowWidth -> (windowWidth/textWidth).let { Identity.scale(x = it, y = it, around = Point(width / 2, height)) } else -> Identity } rerender() } init { foregroundColor = White } override fun render(canvas: Canvas) { val textPosition = textMetrics.size(text, font).let { val x = when { textTransform.isIdentity -> width - it.width - inset else -> (width - it.width) / 2 } Point(x, height - it.height) } // scaling, if present, is applied to the canvas before text rendered canvas.transform(textTransform) { text(text, at = textPosition, font = font, color = foregroundColor ?: White) } } } inner class OperatorButton(text: String, background: Color = operatorColor, foreground: Color = White, private val method: Double.(Double) -> Double): ToggleButton(text) { init { configure(this, background, foreground) fired += { compute() activeOperator = this } } internal operator fun invoke(left: Double, right: Double) = method(left, right) } // endregion // region ================ Colors ================================== private val numberColor = Darkgray.darker(0.5f) private val operatorColor = Orange.lighter (0.1f) // endregion // region ================ Internal State ================================== private var activeOperator: OperatorButton? = null; set(new) { field = new when (field) { null -> { div.selected = false times.selected = false minus.selected = false plus.selected = false } } } private var reset = true // indicates when to begin a new operand private var negated = false // tracks whether number is negative private var leftValue = null as Double? // left-side operand private var rightValue = null as Double? // right-side operand private var decimalPlace = 0 // tracks decimal place for fractions private var committedOperator = null as OperatorButton? // operator to be applied to left and right values // endregion // region ================ Visual Elements ================================= private val output = Output() val result get() = output.number val div = OperatorButton("÷", method = Double::div ) val times = OperatorButton("×", method = Double::times) val minus = OperatorButton("-", method = Double::minus) val plus = OperatorButton("+", method = Double::plus ) val clear = func("AC" ).apply { fired += { output.number = 0.0 clearInternalState() } } val negate = func("+/-").apply { fired += { if (reset) { output.number = 0.0 } reset = false negated = !negated output.number *= -1 } } val percent = func("%").apply { fired += { output.number *= 0.01 } } val decimal = func(".", background = numberColor, foreground = White).apply { fired += { if (decimalPlace == 0) { if (reset) { output.number = 0.0 } reset = false output.text += "." // bit of a hack/short-cut since the number formatter should be used decimalPlace = 1 } } } val equal = func("=", operatorColor, White).apply { fired += { compute() clearInternalState() } } @JsName("nine" ) val `9` = number(9) @JsName("eight") val `8` = number(8) @JsName("seven") val `7` = number(7) @JsName("six" ) val `6` = number(6) @JsName("five" ) val `5` = number(5) @JsName("four" ) val `4` = number(4) @JsName("three") val `3` = number(3) @JsName("two" ) val `2` = number(2) @JsName("one" ) val `1` = number(1) @JsName("zero" ) val `0` = number(0) // endregion // region ================ Button Helpers ================================== private fun button(text: String, background: Color = operatorColor, foreground: Color = White) = configure(PushButton(text), background, foreground) private fun func (text: String, background: Color = Lightgray, foreground: Color = Black) = button(text, background, foreground) private fun number(number: Int ) = button("$number", background = numberColor).apply { fired += { committedOperator = activeOperator val newDigit = (if (negated) -1 else 1) * number.toDouble() output.number = when { reset -> number.toDouble() decimalPlace == 0 -> output.number * 10 + newDigit decimalPlace < 10 -> { val fraction = 1 / 10.0.pow(decimalPlace) (output.number + newDigit * fraction).roundToNearest(fraction).also { ++decimalPlace } } else -> output.number } reset = false } } private fun configure(button: Button, background: Color, foreground: Color) = button.apply { size = Size(60) cursor = Pointer behavior = CalcButtonBehavior(textMetrics) foregroundColor = foreground backgroundColor = background } // endregion /** * Computes the current value based on [leftValue], [rightValue], and [committedOperator]. */ private fun compute() { when { committedOperator != null -> rightValue = output.number leftValue == null -> leftValue = output.number } committedOperator?.let { operator -> leftValue?.let { left -> rightValue?.let { right -> operator(left, right).also { output.number = it leftValue = it rightValue = null } committedOperator = null } } } reset = true decimalPlace = 0 } /** * Resets all internal state, except [output]. */ private fun clearInternalState() { reset = true negated = false leftValue = null rightValue = null decimalPlace = 0 activeOperator = null committedOperator = null } /** * Updates font for [output] and function buttons using different sizes and weights than the given [font]. */ private suspend fun loadFonts() { font = fonts("Roboto-Regular.ttf") { family = "Roboto" weight = 400 size = 32 } font?.let { font -> fonts("Roboto-Light.ttf") { family = "Roboto" size = font.size - 5 weight = 100 }?.let { lightFont -> output.font = fonts(lightFont) { size = 72 } clear.font = lightFont negate.font = lightFont percent.font = lightFont } } } //sampleStart init { appScope.launch { loadFonts() ButtonGroup(allowDeselectAll = true, buttons = arrayOf(times, times, minus, plus)) val outputHeight = 100.0 val buttonSpacing = 10.0 // Use GridPanel to hold all buttons val gridPanel = GridPanel().apply { add(clear, 0, 0); add(negate, 0, 1); add(percent, 0, 2); add(div, 0, 3) add(`7`, 1, 0); add(`8`, 1, 1); add(`9`, 1, 2); add(times, 1, 3) add(`4`, 2, 0); add(`5`, 2, 1); add(`6`, 2, 2); add(minus, 2, 3) add(`1`, 3, 0); add(`2`, 3, 1); add(`3`, 3, 2); add(plus, 3, 3) add(`0`, 4, 0, columnSpan = 2 ); add(decimal, 4, 2); add(equal, 4, 3) rowSpacing = { buttonSpacing } columnSpacing = { buttonSpacing } } // Add output and gridPanel to the Calculator view children += listOf(output, gridPanel) val insets = 10.0 // Place Output outside grid so the height can be more easily controlled val constraints = constrain(output, gridPanel) { output, grid -> output.top eq insets output.left eq insets output.right eq parent.right - insets output.height eq outputHeight grid.top eq output.bottom + buttonSpacing grid.left eq output.left grid.right eq output.right grid.bottom eq parent.bottom - insets } layout = object: Layout by constraints { // Set total height to grid panel's ideal width and height, plus output and spacing override fun idealSize(container: PositionableContainer, default: Size?) = gridPanel.idealSize?.let { Size(it.width + 2 * insets, it.height + outputHeight + buttonSpacing + 2 * insets) } } // Force idealSize when gridPanel is laid out gridPanel.sizePreferencesChanged += { _,_,new -> idealSize = new.idealSize?.let { Size(it.width + 2 * insets, it.height + outputHeight + buttonSpacing + 2 * insets) } } } } //sampleEnd override fun render(canvas: Canvas) { canvas.rect(bounds.atOrigin, radius = 40.0, color = Black) } }

The final initialization steps are:

  1. Load fonts
  2. Setup buttons in GridPanel
  3. Add Output and grid as children
  4. Configure the layout

This example uses non-standard/recommended property names for buttons to improve readability slightly. This also makes tests a little simpler to understand.

Button Styling

The calculator buttons come in a few different color schemes. But they all share the same Behavior, defined by CalcButtonBehavior. Buttons--like many Views--let you define their look-and-feel using a Behavior. Ours is fairly simple; it draws the rounded rectangle for the button background and centers the text above it. These are both managed with the right color based on the button's state. It gets state tracking and text positioning for free via its base class: CommonButtonBehavior.

Custom Hit Detection

CalcButtonBehavior provides a rounded style for our buttons. But the default hit-detection for Views is tied to their rectangular bounds. We can fix this by writing custom pointer hit-detection in our behavior.

class CalcButtonBehavior(textMetrics: TextMetrics): CommonTextButtonBehavior<Button>(textMetrics) { //... override fun contains(view: Button, point: Point): Boolean { val radius = view.height / 2 val leftCircle = Circle(center = Point(view.x + radius, view.center.y), radius = radius) val rightCircle = Circle(center = Point(view.bounds.right - radius, view.center.y), radius = radius) return when { point.x < radius -> point in leftCircle point.x > view.width - radius -> point in rightCircle else -> point in view.bounds } } }

The contains(Button, Point) method is called by Button to check whether the pointer is within its bounds. This logic ensures the pointer will only "hit" our button when it goes within the rounded rectangle.

tip

The contains check provides a Point in the View's parent's coordinates.

Testing

Doodle is designed to avoid platform specific dependencies except in the small amount of launch code. The CalculatorApp and Calculator are written in commonMain, which means we can test them by writing tests in commonTest and running them on each platform.

In our case, we will use the Mockk library for testing, which means we will actually only write tests for the JVM where that library works. Test speed is a big advantage of this setup, since there are no external dependencies.

The tests in CalculatorTests are a bit contrived, but they illustrate how you might validate various parts of your app.