Ananda is a small Kotlin/JVM UI and drawing experiment backed by Skiko/Skia. The current codebase is intentionally compact: rendering, layout, events, animation, reactive state, themes, and controls are visible as plain Kotlin types.
Sceneowns top-level drawables, the event pipeline, animation ticks, focus, and the activeTheme.Componentis the base UI node. It supports lifecycle hooks, recursive measurement, layout, pointer hit testing, focus, interaction state, style, and children.RenderBackendabstracts drawing, including gradient fills, radial glows, and soft shadows.SkiaRenderBackendis the default backend.WindowManagementProviderabstracts window creation and lifecycle.SkiaWindowManagementProvideris the default Swing/Skiko implementation.InteractionProvidersplits pointer, keyboard, text input, focused dispatch, focus management, and component interaction state behind small interfaces.Sceneis the default implementation.TimeSystemtracks scaled and unscaled time, frame count, pause state, and timer callbacks.FunctionalComponentrebuilds its child tree when a readStatechanges or when the viewport changes.
val count = stateOf(0)
val scene = scene {
theme(Theme.Default)
component(32f, 32f, 360f, 160f) {
column(12f)
button("Increment") {
count.value += 1
}
}
functional(32f, 220f, 360f, 80f) {
label("Count: ${count.value}", width = 360f, height = 32f)
}
}Reusable functional components can be packaged as FunctionalScope extension functions:
import dev.unknownuser.ananda.annotations.FunctionalComponent
import dev.unknownuser.ananda.component.FunctionalScope
import dev.unknownuser.ananda.reactive.State
@FunctionalComponent
fun FunctionalScope.CounterLabel(count: State<Int>) {
label("Count: ${count.value}", width = 240f, height = 32f)
}
val scene = scene {
mount(32f, 32f, 240f, 32f) {
CounterLabel(count)
}
}For a more composable-style DSL, return ui { ... } from a reusable function and mount it as a normal functional body:
import dev.unknownuser.ananda.annotations.functional
import dev.unknownuser.ananda.dsl.Button
import dev.unknownuser.ananda.dsl.Label
import dev.unknownuser.ananda.dsl.Ui
import dev.unknownuser.ananda.dsl.ui
import dev.unknownuser.ananda.reactive.inc
@functional
fun Counter(name: String): Ui = ui {
var count = useState(0)
Label("$name : $count") {
size(200, 32)
}
Button("+") {
offset(y = 38)
onClick {
count++
}
}
}
val scene = scene {
mount(32f, 32f, 200f, 72f, Counter("Clicks"))
}Kotlin requires var for count++, because ++ reassigns the local variable after calling State.inc(). The returned object is the same state instance, so remembered state is preserved.
Layout is split into recursive measure and layout.
- Measurement flows from parent constraints into children.
- Parent padding reduces child constraints.
StackLayout,RowLayout, andColumnLayoutall measure children before placing them.- Explicit
widthandheightwin; otherwise content size is used. - Containers can be nested directly in
ComponentBuilder/FunctionalScopewithrow {},column {},box {},flowRow {}, andadaptiveGrid {}. fillMaxWidth,fillMaxHeight,fillMaxSize, and per-childalignmodifiers are available alongside the existing CSS grid and positioned layout APIs.
The material3 package provides seed-generated light/dark themes and a Skia-native Material 3 component set. Its outlined text field includes an animated floating label, border notch, placeholder transition, IME/text editing, and a vertically aligned caret. HarmonyOS Sans SC and Source Han Sans SC are bundled for stable Chinese rendering while the Skia backend retains per-codepoint system fallback.
Run the interactive showcase with:
.\gradlew.bat material3DemoSee src/main/kotlin/dev/unknownuser/ananda/material3/README.md for the component catalog.
Animatable<T> keeps target-driven animation state separate from components. Tween and numerically stable spring specs use the scene animator and therefore participate in pause, cancellation, OnDemand rendering, and retargeting:
val offset = Animatable.float(0f)
scene.animateTo(offset, 240f, SpringSpec(stiffness = 220f, dampingRatio = 0.78f))
scene.transitionTheme(
MaterialTheme.light(),
SpringSpec(stiffness = 180f, dampingRatio = 0.9f)
)Components support animateEnter(...), animateExit(...), and staggered fade/slide/scale entrances through staggerChildren(...). Custom drawing code can wrap related primitives in context.interpolationPart(owner, part) { ... } so automatic shape interpolation remains stable when draw order changes.
The event pipeline currently supports:
- pointer:
pointerDown,pointerUp,pointerMove - pointer wheel:
pointerScroll - keyboard:
keyDown,keyUp - text input:
textInput - IME:
imeCompose,imeCommit - focus:
focus,blur
Prefer typed matchers over string event names in component code:
on(PointerDown) {
requestFocus()
it.consume()
}
on(KeyDown.Enter or KeyDown.Space) {
select()
it.consume()
}Input events are filtered against Component.disabled during dispatch. Lifecycle and focus events still dispatch normally.
SkiaWindow connects Swing key events and InputMethodEvent to Ananda events. Text controls should listen to textInput and IME events rather than assuming keyboard events produce text.
Scene.focusNext() provides keyboard focus traversal for host integrations such as Minecraft Screen.
Theme contains Palette, Typography, Spacing, and a default control Style. A scene theme flows down the component tree, and any component may override it locally.
Style is a lightweight override object for background, foreground, border, padding, and text size. Controls use theme tokens by default and component state for hover, pressed, focused, disabled, and selected behavior.
The first control set includes:
PanelLabelButtonTextFieldCheckboxToggleSwitchRadioButtonSliderProgressBarSeparatorScrollContainerTextureViewElevatedPanelGlowOrb
These are minimal but wired through the same component, event, theme, and reactive-state systems as user components.
ElevatedPanel and GlowOrb are Skia-native visual-effect components. They are modeled after common client UI effects such as gradient rounded panels, blurred shadows, and glow layers, without requiring callers to manage shader passes directly.
ScrollContainer clips its children to bounds and handles pointerScroll.
TextureView draws a TextureRegion through the active backend, so desktop and Minecraft integrations can map the same UI node to different texture systems.
The core runtime includes a version-neutral MinecraftGuiAdapter in dev.unknownuser.ananda.minecraft.
It maps a Minecraft-style screen lifecycle into Scene rendering and input dispatch without taking a hard dependency on Fabric, Forge, NeoForge, or a specific Minecraft version.
See docs/minecraft-adapter.md for the loader-specific module shape and backend requirements.
SkiaWindow supports:
RenderMode.Continuous: requests frames every 16 ms.RenderMode.OnDemand: requests frames only when the scene is invalidated.
Continuous mode is useful while animation is active. On-demand mode avoids repainting static scenes.
ManagedWindow also exposes common lifecycle operations such as requestRender, setTitle, resize, close, and dispose.
Window creation can go through the provider boundary:
val window = SkiaWindowManagementProvider.createWindow(
options = WindowOptions(title = "Ananda", width = 800, height = 600),
scene = scene,
interactions = scene
)
window.show()Scene.animate keeps the original progress callback API and now returns a controllable Animation:
val animation = scene.animate(
durationSeconds = 0.6f,
easing = Easings.EaseOutCubic,
repeatCount = 1,
repeatMode = RepeatMode.Reverse
) { progress ->
component.x = 32f + progress * 120f
}
animation.pause()
animation.resume()
animation.cancel()For simple numeric transitions, use animateFloat:
scene.animateFloat(0f, 1f, durationSeconds = 0.25f) { alpha ->
// apply alpha
}In RenderMode.OnDemand, active animations request the next render frame automatically.
Each Scene owns a TimeSystem exposed as scene.time and through the DSL:
val scene = scene {
timeScale(1.5f)
onUpdate { frame ->
println("scaled=${frame.elapsedSeconds} unscaled=${frame.unscaledElapsedSeconds}")
}
every(intervalSeconds = 1f) {
println("one scaled second")
}
after(delaySeconds = 3f, useScaledTime = false) {
pauseTime()
}
}RenderContext.time and FunctionalScope.time expose the latest TimeFrame. Animations use scaled time, so scene.time.pause() and scene.time.timeScale affect animation playback.
DebuggerBridge opens a TCP socket and streams line-delimited JSON-like render events to connected debugger clients.
val bridge = DebuggerBridge(54231).start()
SkiaWindow(scene = scene, debuggerBridge = bridge).show()Clients can connect to localhost:54231 and receive operations such as clear, rect, line, circle, and text. This is deliberately dependency-free so it can evolve into a richer debugger protocol later.
Run:
.\gradlew.bat test