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.
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.
- kotlin
- resources
- jsMain
- jvmMain
- jvmTest
- wasmJsMain
- 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.
Source code and resources for that are usable for platforms are stored in commonMain
. This app is designed to work on all platforms, so our app code and all logic is found under this directory.
The kotlin
directory is where all code for a platform resides. In this case, we have all the classes for our app including CalculatorApp
, Calculator
, CalculatorButtonBehavior
and NumberFormatter
.
All of these classes are platform agnostic and used by all targets. This makes our app work on any target Doodle supports.
An app can have resources like fonts, images, etc. that it loads at runtime. This directory contains these resources.
In our case, we will be loading two fonts that we store here.
Source code and resources that are needed for Web (JS) target are stored in jsMain
. Our app is platform agnostic except for the launch portion, which is located in the source below this directory.
The Web launch portion of our app is located here in the program's main
function. The JS version uses a platform-specific NumberFormatter
that is based on Intl.NumberFormat
.
Holds the index.html
file that loads the generated JS file produced for the Web (JS) target.
Source code and resources that are needed for Desktop (JVM) target are stored in jvmMain
.
The Desktop launch portion of our app is located here in the program's main
function. This version uses a platform-specific NumberFormatter
that is based on java.text.NumberFormat
.
Tests written for our Calculator
that will be run on the JVM. These tests are here instead of in commonTest
because they use Mockk, which only runs on the JVM.
Tests written for our Calculator
that will be run on the JVM. These tests are here instead of in commonTest
because they use Mockk, which only runs on the JVM.
Source code and resources that are needed for Web (WASM) target are stored in wasmJsMain
. Our app is platform agnostic except for the launch portion, which is located in the source below this directory.
The Web launch portion of our app is located here in the program's main
function. The WASM version uses a platform-specific NumberFormatter
that is based on Intl.NumberFormat
.
Holds the index.html
file that loads the generated JS file produced for the Web (WASM) target.
The build.gradle.kts
file defines how the app is configured and all its dependencies. The Calculator app uses a multi-platform configuration so it can run on all Doodle supported targets.
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}"
}
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.
package io.nacular.doodle.examples
// ...
class CalculatorApp(display: Display): Application {
init {
// creat and display a single Calculator
display += Calculator()
}
override fun shutdown() { /* no-op */ }
}
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.
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.
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:
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
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
.
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:
- Load fonts
- Setup buttons in
GridPanel
- Add
Output
andgrid
as children - 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.
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.