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
We will use a multi-platform library setup for this app, with a multiplatform launcher that depends on it. This is not necessary to use Doodle. You could create a single multiplatform build with the common parts of your app in commonMain
etc.. This setup is used here because these apps are also launched by an app within DocApps
when embedding them like below. Therefore, we need a pure library for each app. This is why there is an app and a runner.
- Todo
- TodoRunner
plugins {
kotlin("multiplatform" )
alias(libs.plugins.serialization)
}
kotlin {
// Defined in buildSrc/src/main/kotlin/Common.kt
jsTargets ()
wasmJsTargets()
jvmTargets ()
sourceSets {
commonMain.dependencies {
api(libs.coroutines.core )
api(libs.serialization.json)
api(libs.doodle.themes )
api(libs.doodle.controls )
}
jsTest.dependencies {
implementation(kotlin("test-js"))
}
jvmTest.dependencies {
implementation(kotlin("test-junit"))
implementation(libs.bundles.test.libs)
}
}
}
plugins {
kotlin("multiplatform")
application
}
kotlin {
jsTargets (executable = true)
wasmJsTargets(executable = true)
jvmTargets ( )
sourceSets {
commonMain.dependencies {
implementation(project(":Todo"))
}
jsMain.dependencies {
implementation(libs.doodle.browser)
}
val wasmJsMain by getting {
dependencies {
implementation(libs.doodle.browser)
}
}
jvmMain.dependencies {
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)
}
}
}
}
application {
mainClass.set("MainKt")
}
installFullScreenDemo("Development")
installFullScreenDemo("Production" )
Build uses libs.versions.toml file.
Defining Our 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. Therefore, we will do the same and place ours in commonMain/kotlin/io/nacular/doodle/examples
.
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.
Creating A Fullscreen App
Doodle apps can be launched in a few different ways. We create a helper to launch the app in full screen.
package io.nacular.doodle.examples
import io.nacular.doodle.application.Modules.Companion.FontModule
import io.nacular.doodle.application.Modules.Companion.ImageModule
import io.nacular.doodle.application.Modules.Companion.KeyboardModule
import io.nacular.doodle.application.Modules.Companion.PointerModule
import io.nacular.doodle.application.application
import io.nacular.doodle.coroutines.Dispatchers
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicLabelBehavior
import io.nacular.doodle.theme.native.NativeTheme.Companion.nativeHyperLinkBehavior
import io.nacular.doodle.theme.native.NativeTheme.Companion.nativeScrollPanelBehavior
import io.nacular.doodle.theme.native.NativeTheme.Companion.nativeTextFieldBehavior
import kotlinx.browser.window
import org.kodein.di.DI.Module
import org.kodein.di.bindSingleton
import org.kodein.di.instance
/**
* Creates a [TodoApp]
*/
//sampleStart
fun main() {
application(modules = listOf(
FontModule,
PointerModule,
KeyboardModule,
ImageModule,
basicLabelBehavior(),
nativeTextFieldBehavior(),
nativeHyperLinkBehavior(),
nativeScrollPanelBehavior(smoothScrolling = true),
Module(name = "AppModule") {
bindSingleton<PersistentStore> { LocalStorePersistence() }
bindSingleton { DataStore(instance()) }
bindSingleton<Router> { TrivialRouter(window) }
bindSingleton<FilterButtonProvider> { LinkFilterButtonProvider(instance(), instance(), instance()) }
}
)) {
// load app
TodoApp(
display = instance(),
fonts = instance(),
theme = instance(),
themes = instance(),
images = instance(),
dataStore = instance(),
linkStyler = instance(),
textMetrics = instance(),
focusManager = instance(),
uiDispatcher = Dispatchers.UI,
filterButtonProvider = instance()
)
}
}
//sampleEnd
Normally this would just be your main
function. But main
would prevent the app from being used as a library. Which is what happens to allow both an embedded (in the docs) and full-screen version.
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())
}