Skip to main content

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.

tip

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.

Directory Layout
  • src
    • commonMain
      • kotlin
  • 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 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}" }
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 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.

TodoApp.kt

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")!! }
tip

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.

tip

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 PushButtons instead of HyperLinks 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()) }