Todo Tutorial
This tutorial shows how you might build the TodoMVC app using Doodle. This version deviates from the official app spec in that (like all Doodle apps) it does not use CSS or HTML directly. Therefore, it does not include the assets provided by the official spec. Instead, it replicates the UX with Doodle primitives.
This version is also designed to work well as an embedded app. The version below (unlike the full-screen version) does not use routing for the filters. This means there is no way to deep-link to a filter, like the full-screen version has. The launch code decides this by injecting a different strategy for creating the filter buttons, while the app itself is unaware of this difference.
You can also see the full-screen app 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
- jsMain
- jvmMain
- 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.
There are also a couple interfaces that need platform-specific implementations that are defined here: PersistentStore
and Router
. This allows our app to work with them in a platform independent way, even if they will vary depending on the target it is run on.
The kotlin
directory is where all code for a platform resides. In this case, we have all the classes for our app, including TodoApp
, TaskRow
, FilterBox
, Router
, etc..
All of these classes are platform agnostic and used by all targets. This makes our app work on any target Doodle supports.
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 LocalStorePersistence
that is based on the browser's LocalStorage
. It also have a TrivialRouter
that uses window.location.hash
to track/update routes.
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 FilePersistence
that is based on the file system via java.io.File
. It also has an InMemoryRouter
that uses a hashmap internally to track/update routes.
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 LocalStorePersistence
that is based on the browser's LocalStorage
. It also have a TrivialRouter
that uses window.location.hash
to track/update routes.
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 Todo 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 Todo
directory.
build.gradle.kts
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
//sampleStart
plugins {
kotlin("multiplatform" )
alias(libs.plugins.serialization)
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 {
commonMain.dependencies {
api(libs.coroutines.core ) // font loading
api(libs.serialization.json) // persistence
api(libs.doodle.themes )
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)
}
}
}
}
// 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 framework will then initialize our app via the constructor.
Doodle apps can be defined in commonMain
, since they do not require any platform-specific dependencies (we will do this as well). They can also 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.
package io.nacular.doodle.examples
import io.nacular.doodle.application.Application
import io.nacular.doodle.controls.IndexedItem
import io.nacular.doodle.controls.buttons.Button
import io.nacular.doodle.controls.buttons.HyperLink
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.list.MutableList
import io.nacular.doodle.controls.list.listEditor
import io.nacular.doodle.controls.panels.ScrollPanel
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.controls.theme.CommonLabelBehavior
import io.nacular.doodle.core.Behavior
import io.nacular.doodle.core.Container
import io.nacular.doodle.core.Display
import io.nacular.doodle.core.View
import io.nacular.doodle.core.container
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.Transparent
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.Font
import io.nacular.doodle.drawing.Font.Style.Italic
import io.nacular.doodle.drawing.FontLoader
import io.nacular.doodle.drawing.PatternPaint
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.drawing.height
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.drawing.rect
import io.nacular.doodle.event.PointerListener.Companion.released
import io.nacular.doodle.examples.DataStore.DataStoreListModel
import io.nacular.doodle.examples.DataStore.Filter
import io.nacular.doodle.examples.DataStore.Filter.Active
import io.nacular.doodle.examples.DataStore.Filter.Completed
import io.nacular.doodle.focus.FocusManager
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.image.Image
import io.nacular.doodle.image.ImageLoader
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.layout.constraints.fill
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.theme.adhoc.DynamicTheme
import io.nacular.doodle.theme.basic.list.BasicListBehavior
import io.nacular.doodle.theme.basic.list.BasicVerticalListPositioner
import io.nacular.doodle.theme.basic.list.TextEditOperation
import io.nacular.doodle.theme.basic.list.basicItemGenerator
import io.nacular.doodle.theme.native.NativeHyperLinkStyler
import io.nacular.doodle.utils.Dimension.Height
import io.nacular.doodle.utils.Encoder
import io.nacular.doodle.utils.diff.Delete
import io.nacular.doodle.utils.diff.Insert
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.Result.Companion.success
import kotlin.math.min
/**
* This app is designed to run both top-level and nested. The filter buttons use hyperlinks in the spec,
* but we will delegate their definition to the app creator to allow a different approach when nested
* (i.e. using buttons instead of hyperlinks).
*
* The app will then use this provider to create the filter buttons.
*/
interface FilterButtonProvider {
operator fun invoke(text: String, filter: Filter? = null, behavior: Behavior<Button>): Button
}
/**
* Default implementation intended for use when app is top-level. It handles routing and provides
* hyperlinks for the filter buttons.
*/
class LinkFilterButtonProvider(private val dataStore: DataStore, router: Router, private val linkStyler: NativeHyperLinkStyler): FilterButtonProvider {
init {
router["/" ] = { dataStore.filter = null }
router["/active" ] = { dataStore.filter = Active }
router["/completed"] = { dataStore.filter = Completed }
router.fireAction()
}
override fun invoke(text: String, filter: Filter?, behavior: Behavior<Button>): Button {
val url = when (filter) {
Active -> "#/active"
Completed -> "#/completed"
else -> "#/"
}
return HyperLink(url = url, text = text).apply {
this.behavior = linkStyler(this, behavior) as Behavior<Button>
this.acceptsThemes = false
dataStore.filterChanged += { rerender() }
}
}
}
/**
* General styling config
*/
data class TodoConfig(
val listFont : Font,
val titleFont : Font,
val lineColor : Color = Color(0xEDEDEDu),
val filterFont : Font,
val footerFont : Font,
val headerColor : Color = Color(0xAF2F2Fu) opacity 0.15f,
val deleteColor : Color = Color(0xCC9A9Au),
val appBackground : Color = Color(0xF5F5F5u),
val boldFooterFont : Font,
val selectAllColor : Color = Color(0x737373u),
val checkForeground : Image,
val checkBackground : Image,
val placeHolderFont : Font,
val placeHolderText : String = "What needs to be done?",
val placeHolderColor : Color = Color(0xE6E6E6u),
val labelForeground : Color = Color(0x4D4D4Du),
val footerForeground : Color = Color(0xBFBFBFu),
val deleteHoverColor : Color = Color(0xAF5B5Eu),
val taskCompletedColor : Color = Color(0xD9D9D9u),
val clearCompletedText : String = "Clear completed",
val textFieldBackground : Color = White,
val filterButtonForeground: Color = Color(0x777777u),
)
/**
* A [TextEditOperation] that translates a String to a [Task]. It also customizes the textField to fit the app's styling
*/
private class TaskEditOperation(focusManager: FocusManager?, list: MutableList<Task, *>, task: Task, current: View): TextEditOperation<Task>(focusManager, TaskEncoder(task.completed), list, task, current) {
private class TaskEncoder(private val completed: Boolean = false): Encoder<Task, String> {
override fun decode(b: String) = success(Task(b, completed))
override fun encode(a: Task ) = success(a.text)
}
init {
textField.fitText = emptySet()
textField.backgroundColor = Transparent
}
override fun invoke() = container {
children += textField
layout = constrain(textField) { it.edges eq parent.edges + Insets(top = 1.0, left = 58.0, bottom = 1.0) }
}
}
/**
* This is the main view of the app. It contains all the visual elements.
*
* @property config that provides styling and resources (i.e. font, images)
* @property dataStore that tracks the overall state of tasks
* @property linkStyler used to wrap our custom Hyperlink Behavior in a native one so we get the default link behavior as well
* @property textMetrics used to measure text
* @property focusManager used to control focus in the app
* @property filterButtonProvider used to create the filter buttons
*/
private class TodoView(
private val config : TodoConfig,
private val dataStore : DataStore,
private val linkStyler : NativeHyperLinkStyler,
private val textMetrics : TextMetrics,
private val focusManager : FocusManager,
private val filterButtonProvider: FilterButtonProvider
): View() {
init {
val header = Label("todos").apply {
font = config.titleFont
behavior = CommonLabelBehavior(textMetrics)
acceptsThemes = false
foregroundColor = config.headerColor
}
lateinit var list: View
val footer = Footer(textMetrics, linkStyler, config)
val taskList = object: Container() {
init {
clipCanvasToBounds = false
// Maps tasks to TaskRow and updates them when recycled
val visualizer = itemVisualizer<Task, IndexedItem> { item, previous, _ ->
when (previous) {
is TaskRow -> previous.also { it.task = item }
else -> TaskRow(config, dataStore, item)
}
}
// List containing Tasks. It is mutable since items can be edited
list = MutableList(DataStoreListModel(dataStore), itemVisualizer = visualizer, fitContent = setOf(Height)).apply {
val rowHeight = 58.0
font = config.listFont
cellAlignment = fill
editor = listEditor { list, row, _, current -> TaskEditOperation(focusManager, list, row, current) }
behavior = BasicListBehavior(focusManager,
basicItemGenerator {
// edit when double-clicked
pointerChanged += released { event ->
if (event.clickCount >= 2) { this@apply.startEditing(index).also { event.consume() } }
}
},
BasicVerticalListPositioner(rowHeight),
PatternPaint(Size(10.0, rowHeight)) {
rect(Rectangle(size = this.size), color = White)
line(Point(y = 1), Point(10, 1), Stroke(config.lineColor))
},
)
itemsChanged += { _, differences ->
// Scroll when a single item added
var numAdded = 0
var numRemoved = 0
var indexInList = 0
differences.forEach {
when (it) {
is Insert -> {
if (it.items.size > 1) {
return@forEach
}
++numAdded
++indexInList
}
is Delete -> {
numRemoved += it.items.size
}
else -> indexInList += it.items.size
}
}
if (numAdded == 1 && numRemoved <= 1) {
scrollTo(indexInList)
}
}
boundsChanged += { _, old, new ->
if (old.width != new.width || old.height != new.height) {
this@TodoView.relayout()
}
}
}
children += listOf(
TaskCreationBox(focusManager, textMetrics, config, dataStore),
ScrollPanel (list).apply { contentWidthConstraints = { it eq width - verticalScrollBarWidth } },
FilterBox (config, dataStore, textMetrics, filterButtonProvider)
)
layout = constrain(children[0], children[1], children[2]) { input, panel, filter ->
listOf(input, panel, filter).forEach { it.width eq parent.width }
input.top eq 0
input.height.preserve
panel.top eq input.bottom
if (children[2].visible) {
filter.top eq panel.bottom
filter.bottom eq parent.bottom
filter.height.preserve
}
}
}
override fun render(canvas: Canvas) {
canvas.outerShadow(vertical = 2.0, blurRadius = 4.0, color = Black opacity 0.2f) {
outerShadow (vertical = 25.0, blurRadius = 50.0, color = Black opacity 0.1f) {
// Create stacked effect
if (!dataStore.isEmpty) {
rect(bounds.atOrigin.inset(Insets(top = height, left = 8.0, right = 8.0, bottom = -8.0)), color = White)
rect(bounds.atOrigin.inset(Insets(top = height, left = 4.0, right = 4.0, bottom = -4.0)), color = White)
}
rect(bounds.atOrigin, color = White)
}
}
}
}
children += listOf(header, taskList, footer)
list.boundsChanged += { _,_,_ -> doLayout() }
layout = constrain(header, taskList, footer) { header, body, footer ->
listOf(header, body, footer).forEach { it.centerX eq parent.centerX }
header.top eq 9
header.height.preserve
val minHeight = taskList.children[0].height + (taskList.children[2].takeIf { it.visible }?.height ?: 0.0)
body.top eq header.bottom + 5
body.width eq min(550.0, parent.width - 10)
body.height eq minHeight + list.height
footer.top eq body.bottom + 65
footer.width eq body.width
footer.height.preserve
(footer.bottom lessEq parent.bottom) .. Strong
}
}
}
/**
* Todo App based on TodoMVC
*/
class TodoApp(display : Display,
uiDispatcher : CoroutineDispatcher,
fonts : FontLoader,
theme : DynamicTheme,
themes : ThemeManager,
private val images : ImageLoader,
dataStore : DataStore,
linkStyler : NativeHyperLinkStyler,
textMetrics : TextMetrics,
focusManager : FocusManager,
filterButtonProvider: FilterButtonProvider): Application {
//sampleStart
init {
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Launch coroutine to fetch fonts/images
appScope.launch(uiDispatcher) {
val titleFont = fonts { size = 100; weight = 100; families = listOf("Helvetica Neue", "Helvetica", "Arial", "sans-serif") }!!
val listFont = fonts(titleFont) { size = 24 }!!
val footerFont = fonts(titleFont) { size = 10 }!!
val config = TodoConfig(
listFont = listFont,
titleFont = titleFont,
footerFont = footerFont,
filterFont = fonts(titleFont ) { size = 14 }!!,
boldFooterFont = fonts(footerFont) { weight = 400 }!!,
placeHolderFont = fonts(listFont ) { style = Italic }!!,
checkForeground = checkForegroundImage(),
checkBackground = checkBackgroundImage(),
)
// install theme
themes.selected = theme
display += TodoView(config, dataStore, linkStyler, textMetrics, focusManager, filterButtonProvider)
display.layout = constrain(display.children[0]) { it.edges eq parent.edges }
display.fill(config.appBackground.paint)
}
}
//sampleEnd
override fun shutdown() {}
private suspend fun checkForegroundImage() = images.load("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E")!!
private suspend fun checkBackgroundImage() = images.load("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E")!!
}
Notice that shutdown
is a no-op, since we don't have any cleanup to do when the app closes.
Use the application
function to launch top-level apps. It takes a list of modules, 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.
Notice that we have included several modules for our app. This includes one for fonts, pointer, keyboard, and several for various View Behaviors
(i.e. nativeTextFieldBehavior()
) which loads the native behavior for TextFields. We also define some bindings directly in a new module. These are items with no built-in module, or items that only exist in our app code.
Check out Kodein to learn more about how it handles dependency injection.
The application
function also takes an optional HTML element within which the app will be hosted. The app will be hosted in document.body
if you do not specify an element.
App launching is the only part of our code that is platform-specific; since it is the only time we might care about an HTML element. It also helps support embedding apps into non-Doodle contexts.
Supporting Docs Embedding
These docs actually launch the app using a custom main
with a slightly different set of inputs. The big difference is in FilterButtonProvider
used. The docs inject a provider that creates PushButton
s instead of HyperLink
s for the filter controls. The app itself treats these the same. The end result is that the docs version does not use routing.
// Notice the element is provided for embedded version
application(root = element, modules = listOf(FontModule, PointerModule, KeyboardModule, basicLabelBehavior(),
nativeTextFieldBehavior(), nativeHyperLinkBehavior(), nativeScrollPanelBehavior(smoothScrolling = true),
Module(name = "AppModule") {
// ...
// Different behavior for docs version
bind<FilterButtonProvider>() with singleton { EmbeddedFilterButtonProvider(instance()) }
}
)) {
// load app just like full-screen
TodoApp(instance(), instance(), instance(), instance(), instance(), instance(), instance(), instance(), instance(), instance())
}