feat(ui): ludic.ui is a UI framework - input, controls, render3d backend, the CSS a game needs

A game no longer writes a host:
- The runtime reads Input itself: focus and keyboard navigation (Tab, arrows, Enter/Space,
  autofocus), the pointer (hover, :active, click on release, drag), and scroll boxes with the
  wheel, a draggable scrollbar, clipping and scroll-into-view.
- HTML's controls are built in (button, checkbox, radio, range, select, text, key capture), made
  of plain parts a stylesheet styles, each reporting with on-change and event.value.
- ludic.ui/render3d.ludic draws with render3d's overlay: textures, named atlases (icon:NAME),
  nine-slice border-image, rounded rects and rings, clipping, and a scale.
- Hooks for the program's language, sounds and clock (ui_translator, ui_sounds, ui_clock).
- ui_dev: hot reload, errors on screen, LUDIC_UI_DUMP.
- CSS:
  - colours as #rgb / #rrggbbaa / rgb() / rgba() / names;
  - border-radius and outline (following the radius), box-shadow, background-image and a tinted
    border-image;
  - group opacity, @keyframes / animation, transition;
  - :focus, :focus-visible and :focus-within.
- HTML mixed content, and boolean attributes.
- render3d gains tex_width / tex_height, and the XML reader keeps text runs in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-24 17:26:09 +03:00
parent 20d011f8e9
commit 1dad96102a
45 changed files with 1386 additions and 218 deletions

View file

@ -423,15 +423,59 @@ applies to both:
- `<slot name="title"/>` takes the user's `<template slot="title">`.
- `on-mount` and `on-unmount` run when an element or component appears and goes.
- Words inside a text element read as one line: `<p>Hi <b>there</b></p>`.
- **Natives and forms.** `ui_native("dial", measure, draw)` makes `<dial>` an element the program
draws itself, laid out and styled like any other. It reports with `ui_fire(n, "change",
value)`, which runs the element's `on-change` with `event.value`, and it reads its attributes
with `ui_attr`. `<input type="checkbox|radio|text|range">`, `<select>` with `<option
value>`, and `<textarea>` are natives with simple defaults, which a renderer replaces with its
own.
- **Developing.**
- Every error names the template's file and line, and `ui_errors()` lists them for a host to show.
- `ui_reload()` reads changed templates and stylesheets again and keeps each screen's state.
- **Interaction is the runtime's.** It reads the runtime's `Input`, so a renderer only draws.
- Tab and Shift+Tab, or the arrows, move focus through the controls in document order. Enter
and Space activate what has it, and `autofocus` picks where a screen starts.
- The pointer hovers, presses (`:active`) and clicks on release; a drag keeps the pointer until
it is let go.
- A scroll box (`overflow: auto` or `<scroll>`) takes the wheel, has a scrollbar that can be
dragged (`scrollbar-color`), clips what it holds and pulls the focused control into view.
- `:focus`, `:focus-visible` and `:focus-within` style the focus, and `outline` (which follows
`border-radius`) draws the ring.
- A test drives all of it with `ui_input(i)`.
- **Controls are built in**, each made of plain parts a stylesheet styles (`.ui-label`,
`.ui-track`, `.ui-knob`, `.ui-fill`, `.ui-thumb`, `.ui-field`, `.ui-value`, `.ui-caret`,
`.ui-prev`, `.ui-next`):
- `<button>`;
- `<input type="checkbox|radio" label checked>`;
- `<input type="range" label min max step value>`, dragged or stepped with the arrows;
- `<select label value>` with `<option value>`s;
- `<input type="text" label value maxlength>`, which takes typing, and Backspace takes a letter
back;
- `<input type="key" label value>`, which takes the next key pressed.
Each reports with `on-change` and `event.value`, and plays `sound="..."` (or "click") through
the program's `ui_sounds`.
- **Natives** are for what markup cannot say. `ui_native("minimap", measure, draw)` makes
`<minimap>` an element the program draws itself, laid out and styled like any other. It reports
with `ui_fire(n, "change", value)` and reads its attributes with `ui_attr` / `ui_attr_on`.
- **Renderers.** `ui_backend(b)` takes any renderer with `rect`, `text` and `measure`, and uses
`round`, `ring`, `image`, `nine`, `clip`, `scale` and `now` when it has them.
`import "ludic.ui/render3d.ludic"` and `ui_render3d()` draw with render3d's overlay:
- images by path, cells of an atlas named with `ui_atlas(prefix, texture, cols, names)` as
`<img src="prefix:name">`, and nine-slices for `border-image`;
- a scale from the screen's height (`ui_render3d_scale` for the player's interface size).
`ui_translator(fn)`, `ui_sounds(fn)` and `ui_clock(fn)` hand the runtime the program's
language, sounds and time. `import "ludic.ui/screen.ludic"` is the 2D screen's renderer.
- **The look is CSS.**
- Colours: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()` and the basic names,
usually through `var(--...)` from a theme.
- Boxes: `border-radius`, `box-shadow` (sharp, offset), `background-image: url(...)`, and
`border-image: url(...) slice / width` as a nine-slice. A nine-slice is tinted by the
background colour, so one rounded texture serves every colour.
- Motion: `opacity` fades the element and everything inside it. `@keyframes` with `animation:
name 0.25s [infinite] [alternate]` animate numeric properties from when the element first
appeared, and `transition: opacity 0.2s` eases a changed opacity.
- **Mixed content.** Text beside elements keeps its place: `<button><img src="icon:arrow"/>Resume
</button>`, `<p>Hi <b>there</b></p>`. A boolean attribute present with no value is true, as in
HTML (`<button autofocus>`).
- **Developing.** `ui_dev(true)` turns on the runtime's own tools:
- it re-reads changed templates and stylesheets once a second (`ui_reload`), keeping every
instance's state;
- it draws template errors over the screen, each with its file and line (`ui_errors()` lists
them);
- `LUDIC_UI_DUMP=<screen>` prints that screen's tree once.
- `ui_dump` prints the tree the way an inspector would (`div#id.class`, its box and its
computed styles).
- **Bindings.** Any attribute and any text can hold `{expressions}`. An attribute that is one

View file

@ -39,6 +39,20 @@ with:
- native elements a program draws itself (`ui_native`, `ui_fire`), and form controls;
- errors with file and line, hot reload (`ui_reload`), and an inspector-style dump.
The runtime is a UI framework, not only a template engine:
- it takes input itself: focus and keyboard navigation, the pointer, scroll boxes, `autofocus`;
- it has built-in controls (button, checkbox, radio, range, select, text, key), styled as CSS
parts;
- `ludic.ui/render3d.ludic` is a render3d backend, with textures, atlases, nine-slices, clipping
and scale;
- more CSS: `rgba()`/`#rrggbbaa`, `border-radius`, `outline`, `box-shadow`, `background-image`,
`border-image`, group `opacity`, `@keyframes` / `animation` / `transition`;
- HTML mixed content, and boolean attributes;
- hooks for the program's language, sounds and clock.
render3d gains `tex_width` / `tex_height`, and the XML reader keeps text runs among elements in
order (`mixed`).
A `view` field set to a literal or a named function's result needs no type.
Screens are drawn through a registered renderer. `Value` gains a float kind.

View file

@ -0,0 +1,27 @@
# ui_anim.ludic — CSS animation and transition on ludic.ui's frame clock (sixty frames a second
# when the renderer gives no clock of its own): a fade-in from @keyframes, a pulse that loops back
# and forth, and a transition easing an opacity that changed.
import "ludic.ui"
program UiAnim {
numbers float
var dim: bool = false
view Anim {
dim = dim
}
const PAGE: string = "<ui><style>@keyframes fade { from { opacity: 0 } to { opacity: 1 } }\n@keyframes pulse { from { opacity: 1 } to { opacity: 0.5 } }\n.in { animation: fade 1s }\n.beat { animation: pulse 0.5s infinite alternate }\n.soft { transition: opacity 1s }\n.dim { opacity: 0.2 }</style><screen name='a'><p class='in'>a</p><p class='beat'>b</p><p class=\"soft {dim ? 'dim' : ''}\">c</p></screen></ui>"
function at() -> string {
ui_show("a", view_anim(), 0.0, 0.0, 200.0, 200.0)
let r: UiNode = ui_nodes("a", view_anim())
return `{int(r.children[0].alpha * 100.0)} {int(r.children[1].alpha * 100.0)} {int(r.children[2].alpha * 100.0)}`
}
entry {
ui_load_text(PAGE, "a.xml")
var out = at()
for i in 0 .. 29 { at() }
out = out + " | " + at()
dim = true
for i in 0 .. 29 { at() }
out = out + " | " + at()
print(out)
}
}

View file

@ -0,0 +1,71 @@
# ui_controls.ludic — ludic.ui's own controls, driven as a player would drive them: Tab and the
# arrows move the focus, Enter presses and flips, the arrows move a range and step a select, typing
# fills a text field and Backspace takes a letter back, a key field takes the next key, the pointer
# drags a range, and the wheel scrolls a box. Every change reaches the component as an event.
import "ludic.ui"
import "ui_controls_parts/Settings.ludic"
program UiControls {
numbers float
function frame(i: UiInput) -> UiNode {
ui_input(i)
ui_show("Settings", null, 0.0, 0.0, 400.0, 400.0)
let root: UiNode = ui_nodes("Settings", null)
ui_place(root, 0.0, 0.0, 400.0, 400.0)
return root
}
function press(tab: bool, down: bool, right: bool, enter: bool) -> UiInput {
let i = new UiInput
i.tab = tab
i.down_key = down
i.right = right
i.enter = enter
return i
}
function state(root: UiNode) -> string {
let page = root.children[0]
let last = page.children[len(page.children) - 1]
return last.text
}
entry {
ui_backend(new UiBackend)
var root = frame(new UiInput)
root = frame(press(true, false, false, false))
root = frame(press(false, false, false, true))
root = frame(press(false, true, false, false))
root = frame(press(false, false, false, true))
root = frame(press(false, true, false, false))
root = frame(press(false, false, true, false))
root = frame(press(false, false, true, false))
root = frame(press(false, true, false, false))
root = frame(press(false, false, true, false))
root = frame(press(false, true, false, false))
let typed = new UiInput
typed.typed = "lovelace"
root = frame(typed)
let back = new UiInput
back.backspace = true
root = frame(back)
root = frame(press(false, true, false, false))
root = frame(press(false, false, false, true))
let k = new UiInput
k.key = 70
root = frame(k)
print(state(root))
let range = root.children[0].children[2]
let track = range.children[1]
let at = new UiInput
at.x = track.x + track.cw * 0.8
at.y = track.y + 2.0
at.down = true
root = frame(at)
root = frame(new UiInput)
let list = root.children[0].children[6]
let w = new UiInput
w.x = list.x + 5.0
w.y = list.y + 5.0
w.wheel = -1.0
root = frame(w)
root = frame(new UiInput)
print(`{state(root)}; scrolled {int(root.children[0].children[6].scroll_y)}`)
}
}

View file

@ -0,0 +1,4 @@
.page { width: 400px; gap: 4px }
input, select { width: fill; height: 30px }
.list { overflow: auto; height: 60px }
p { height: 20px }

View file

@ -0,0 +1,10 @@
# Settings.ludic - a screen of controls: every change comes back as an event with its value
component Settings {
state music: bool = true
state volume: int = 5
state quality: int = 1
state name: string = "Ada"
state jump: int = 32
state pressed: int = 0
on go() { pressed += 1 }
}

View file

@ -0,0 +1,12 @@
<div class="page">
<button id="go" on-click="go()">Go</button>
<input type="checkbox" label="Music" checked="{music}" on-change="set music = event.value"/>
<input type="range" label="Volume" min="{0}" max="{10}" value="{volume}" on-change="set volume = event.value"/>
<select label="Quality" value="{quality}" on-change="set quality = event.value">
<option>Low</option><option>Medium</option><option>High</option>
</select>
<input type="text" label="Name" value="{name}" maxlength="{8}" on-change="set name = event.value"/>
<input type="key" label="Jump" value="{jump}" on-change="set jump = event.value"/>
<div class="list"><p>1</p><p>2</p><p>3</p><p>4</p><p>5</p><p>6</p><p>7</p><p>8</p></div>
<p class="state">{pressed} {music} {volume} {quality} {name} {jump}</p>
</div>

View file

@ -11,7 +11,9 @@ program UiReload {
const V2: string = "<ui><screen name='note'><state n='{0}'/><h1>{title} again {n}</h1></screen></ui>"
function shown() -> string {
let root: UiNode = ui_nodes("note", view_note())
return root.children[0].text
let first = root.children[0]
if len(first.children) > 0 { return first.children[0].text }
return first.text
}
entry {
Fs.write_text(PATH, V1)

View file

@ -274,8 +274,30 @@ function tex_load_ex(path: pointer, srgb: bool, dilate: int) -> int {
if dilate > 0 { tex_dilate(px, 60, dilate) }
let id = tex_upload(px, srgb, true)
free(px)
tex_note_size(id)
return id
}
# the size each loaded texture was, by id: a nine-slice or a UI image asks
var tex_size_ids: []int = new []int
var tex_size_ws: []int = new []int
var tex_size_hs: []int = new []int
function tex_note_size(id: int) -> void {
push(tex_size_ids, id)
push(tex_size_ws, tex_w)
push(tex_size_hs, tex_h)
}
function tex_width(id: int) -> int {
for i in 0 .. len(tex_size_ids) {
if tex_size_ids[i] == id { return tex_size_ws[i] }
}
return 0
}
function tex_height(id: int) -> int {
for i in 0 .. len(tex_size_ids) {
if tex_size_ids[i] == id { return tex_size_hs[i] }
}
return 0
}
# A small solid-colour fallback texture (linear rgb 0..255), for missing maps.
function tex_solid(r: int, g: int, b: int, a: int) -> int {

View file

@ -0,0 +1,89 @@
# anim.ludic - CSS animation: `@keyframes fade { from { opacity: 0 } to { opacity: 1 } }` and
# `animation: fade 0.25s [infinite] [alternate]` interpolate numeric properties from when the
# element first appeared; `transition: opacity 0.2s` eases a property that changed. Wall-clock time.
export property UiKeyframes {
name: string = ""
stops: []float = null
rules: []UiRule = null
}
var an_frames: []UiKeyframes = new []UiKeyframes
var an_keys: []string = new []string
var an_start: []float = new []float
var an_frame: int = 0
# the renderer's clock in seconds, or the frames shown at sixty a second (a test, a still picture)
function an_now() -> float {
if ui_be != null and ui_be.now != null { return ui_be.now() }
return float(an_frame) / 60.0
}
# @keyframes NAME { from {...} 50% {...} to {...} }
function an_read(name: string, body: string, err: []string) -> void {
let k = new UiKeyframes
k.name = tpl_words(name)
k.stops = new []float
k.rules = new []UiRule
let r = new UiRd
r.s = body
while r.err == "" {
rd_ws(r)
if r.i >= len(r.s) { break }
let stop = tpl_words(lss_upto(r, 123))
let decls = lss_decls(lss_upto(r, 125), err)
var at = el_num(stop) / 100.0
if stop == "from" { at = 0.0 }
if stop == "to" { at = 1.0 }
push(k.stops, at)
push(k.rules, decls)
}
push(an_frames, k)
}
function an_find(name: string) -> UiKeyframes {
for i in 0 .. len(an_frames) {
if an_frames[i].name == name { return an_frames[i] }
}
return null
}
# when this element's animation began: the first frame it was seen with it
function an_began(key: string) -> float {
for i in 0 .. len(an_keys) {
if an_keys[i] == key { return an_start[i] }
}
push(an_keys, key)
push(an_start, an_now())
return an_now()
}
# the element's animation at this moment: each property between the stops around it
function an_apply(n: UiNode) -> void {
if n.anim == "" or n.anim_dur <= 0.0 { return }
let k = an_find(n.anim)
if k == null {
ui_err(`animation {n.anim}: there is no @keyframes of that name`)
return
}
var t = (an_now() - an_began(`{n.key}@{n.anim}`)) / n.anim_dur
if n.anim_loop {
let lap = int(t)
t = t - float(lap)
if n.anim_alt and lap % 2 == 1 { t = 1.0 - t }
} else { t = Math.clamp(t, 0.0, 1.0) }
an_at(n, k, t)
}
function an_at(n: UiNode, k: UiKeyframes, t: float) -> void {
var lo = 0
var hi = len(k.stops) - 1
for i in 0 .. len(k.stops) {
if k.stops[i] <= t and k.stops[i] >= k.stops[lo] { lo = i }
if k.stops[i] >= t and k.stops[i] <= k.stops[hi] { hi = i }
}
var f = 0.0
if k.stops[hi] > k.stops[lo] { f = (t - k.stops[lo]) / (k.stops[hi] - k.stops[lo]) }
let a = k.rules[lo]
let b = k.rules[hi]
for i in 0 .. len(a.keys) {
let va = el_num(vl_text(ui_eval(a.vals[i], n.env)))
var vb = va
for j in 0 .. len(b.keys) {
if b.keys[j] == a.keys[i] { vb = el_num(vl_text(ui_eval(b.vals[j], n.env))) }
}
el_apply(n, a.keys[i], Value.float(va + (vb - va) * f))
}
}

View file

@ -1,7 +1,7 @@
# attr.ludic - what a property sets, by its CSS name or the short one (width or w, background or
# bg, font-size or size, ...). An attribute on an element is the same property, applied last.
const AT_ALIAS: string = " width=w height=h min-width=min-w max-width=max-w min-height=min-h max-height=max-h padding=pad background=bg background-color=bg font-size=size opacity=alpha flex-grow=grow flex-direction=dir flex-wrap=wrap justify-content=justify align-items=align align-self=self row-gap=gap column-gap=gap text-align=talign "
const AT_UNDRAWN: string = " border-radius font-weight font-family font-style box-shadow text-shadow cursor transition animation line-height letter-spacing text-decoration "
const AT_UNDRAWN: string = " font-weight font-family font-style box-shadow text-shadow cursor transition animation line-height letter-spacing text-decoration "
# a stylesheet's property: one that means nothing is said
function el_attr(n: UiNode, key: string, v: Val) -> void {
if el_apply(n, key, v) { return }
@ -17,7 +17,7 @@ function el_apply(n: UiNode, key: string, v0: Val) -> bool {
return true
}
let v = cs_vars_in(n, v0)
return el_size(n, k, v) or el_box(n, k, v) or el_flex(n, k, v) or el_look(n, k, v) or el_place_prop(n, k, v)
return el_size(n, k, v) or el_box(n, k, v) or el_flex(n, k, v) or el_look(n, k, v) or el_place_prop(n, k, v) or el_edge(n, k, v)
}
function at_alias(key: string) -> string {
let pat = ` {key}=`
@ -51,7 +51,18 @@ function el_flex(n: UiNode, key: string, v: Val) -> bool {
}
function el_look(n: UiNode, key: string, v: Val) -> bool {
let s = vl_text(v)
if key == "size" { n.size = el_len(v) } else if key == "color" { n.fg = ui_color(s) } else if key == "bg" { n.bg = ui_color(s) } else if key == "alpha" { n.alpha = el_num(s) } else if key == "enabled" { n.enabled = vl_truthy(v) and s != "false" } else if key == "border-color" { n.border_c = ui_color(s) } else if key == "border-width" { n.border = el_len(v) } else if key == "border" { el_border(n, s) } else { return false }
if key == "size" { n.size = el_len(v) } else if key == "color" {
n.fg = ui_color(s)
n.fg_a = cl_alpha
} else if key == "bg" {
if len(s) > 4 and s[0..4] == "url(" { n.bg_img = el_url(s) } else {
n.bg = ui_color(s)
n.bg_a = cl_alpha
}
} else if key == "alpha" { n.alpha = el_num(s) } else if key == "enabled" { n.enabled = vl_truthy(v) and s != "false" } else if key == "border-color" {
n.border_c = ui_color(s)
n.border_a = cl_alpha
} else if key == "border-width" { n.border = el_len(v) } else if key == "border" { el_border(n, s) } else { return false }
return true
}
# border: 1 #ffcc00, or 1px solid accent
@ -59,7 +70,10 @@ function el_border(n: UiNode, s: string) -> void {
let parts = lss_split(s, 32)
for i in 0 .. len(parts) {
let p = parts[i]
if len(p) > 0 and ((p[0] >= 48 and p[0] <= 57) or p[0] == 46) { n.border = el_unit(p) } else if p == "none" { n.border = 0.0 } else if len(p) > 0 and p != "solid" { n.border_c = ui_color(p) }
if len(p) > 0 and ((p[0] >= 48 and p[0] <= 57) or p[0] == 46) { n.border = el_unit(p) } else if p == "none" { n.border = 0.0 } else if len(p) > 0 and p != "solid" {
n.border_c = ui_color(p)
n.border_a = cl_alpha
}
}
if n.border_c == UI_NONE { n.border_c = n.rfg }
}

View file

@ -0,0 +1,86 @@
# attr_edge.ludic - border-radius, outline (and its width, colour and offset), scrollbar-color
function el_edge(n: UiNode, key: string, v: Val) -> bool {
let s = vl_text(v)
if key == "border-radius" { n.radius = el_len(v) } else if key == "outline" { el_outline(n, s) } else if key == "outline-width" { n.outline_w = el_len(v) } else if key == "outline-offset" { n.outline_off = el_len(v) } else if key == "outline-color" {
n.outline_c = ui_color(s)
n.outline_a = cl_alpha
} else if key == "scrollbar-color" {
let parts = lss_split(s, 32)
if len(parts) > 0 { n.bar_c = ui_color(parts[0]) }
if len(parts) > 1 { n.bar_track = ui_color(parts[1]) }
} else if key == "background-image" { n.bg_img = el_url(s) } else if key == "border-image" { el_border_image(n, s) } else if key == "box-shadow" { el_shadow(n, s) } else if key == "animation" { el_animation(n, s) } else if key == "transition" { n.tr_dur = el_secs(s) } else { return false }
return true
}
# url(path) or url("path") -> path
function el_url(s: string) -> string {
var t = tpl_words(s)
if len(t) > 5 and t[0..4] == "url(" { t = tpl_words(t[4..len(t) - 1]) }
if len(t) >= 2 and (t[0] == 34 or t[0] == 39) { t = t[1..len(t) - 1] }
if t == "none" { return "" }
return t
}
# border-image: url(chip.png) 14 / 12px - the source's corner size, then how wide they are drawn
function el_border_image(n: UiNode, s: string) -> void {
var close = 0
while close < len(s) and s[close] != 41 { close += 1 }
n.bi_src = el_url(s[0..Math.min(close + 1, len(s))])
let rest = lss_split(s[Math.min(close + 1, len(s))..len(s)], 47)
n.bi_slice = el_num(tpl_words(rest[0]))
n.bi_width = ui_px(n.bi_slice)
if len(rest) > 1 { n.bi_width = el_unit(tpl_words(rest[1])) }
}
# box-shadow: 6px 10px 0 rgba(0, 0, 0, 0.45) - x, y, a blur this renderer draws sharp, the colour
function el_shadow(n: UiNode, s: string) -> void {
if tpl_words(s) == "none" {
n.sh_c = -2
return
}
let parts = lss_split(s, 32)
var nums = 0
var col = ""
for i in 0 .. len(parts) {
let p = parts[i]
if len(p) == 0 { continue }
if col == "" and ((p[0] >= 48 and p[0] <= 57) or p[0] == 45 or p[0] == 46) {
if nums == 0 { n.sh_x = el_unit(p) }
if nums == 1 { n.sh_y = el_unit(p) }
nums += 1
} else if col == "" { col = p } else { col = col + " " + p }
}
if col == "" { col = "rgba(0,0,0,0.5)" }
n.sh_c = ui_color(col)
n.sh_a = cl_alpha
}
# outline: 2px solid #ffcc00, or none
function el_outline(n: UiNode, s: string) -> void {
let parts = lss_split(s, 32)
n.outline_w = 0.0
for i in 0 .. len(parts) {
let p = parts[i]
if len(p) > 0 and ((p[0] >= 48 and p[0] <= 57) or p[0] == 46) { n.outline_w = el_unit(p) } else if len(p) > 0 and p != "solid" and p != "none" {
n.outline_c = ui_color(p)
n.outline_a = cl_alpha
}
}
}
# animation: fade 0.25s infinite alternate
function el_animation(n: UiNode, s: string) -> void {
let parts = lss_split(s, 32)
n.anim = ""
for i in 0 .. len(parts) {
let p = parts[i]
if p == "infinite" { n.anim_loop = true } else if p == "alternate" { n.anim_alt = true } else if len(p) > 0 and ((p[0] >= 48 and p[0] <= 57) or p[0] == 46) { n.anim_dur = el_secs(p) } else if len(p) > 0 and n.anim == "" and p != "none" { n.anim = p }
}
}
# the first time in `0.25s` or `250ms`
function el_secs(s: string) -> float {
let parts = lss_split(s, 32)
for i in 0 .. len(parts) {
let p = parts[i]
if len(p) > 1 and p[0] >= 48 and p[0] <= 57 or len(p) > 1 and p[0] == 46 {
if len(p) > 2 and p[len(p) - 2..len(p)] == "ms" { return el_num(p) / 1000.0 }
return el_num(p)
}
}
return 0.0
}

View file

@ -1,26 +1,45 @@
# backend.ludic - the renderer seam
# the renderer. Only rect, text and measure are needed; the rest improve on a default:
# button draws, focuses and answers a press itself; color names a colour (else #rrggbb);
# image draws an <img src>; px scales a template's lengths; scroll_begin answers how far a region is scrolled; read
# reads a template file (else Fs.read_text)
# backend.ludic - the renderer seam: what a renderer draws, and nothing more. Only rect, text and
# measure are needed; the rest improve on a default (a rounded rect falls back to a square one, an
# image to nothing). Input is the runtime's own (Input.*), so no renderer has to supply it.
export property UiBackend {
rect: fn(float, float, float, float, int, float) -> void = null
text: fn(float, float, string, float, int) -> void = null
rect: fn(float, float, float, float, int, float) -> void = null # x y w h rgb alpha
round: fn(float, float, float, float, float, int, float) -> void = null # ... radius rgb alpha
ring: fn(float, float, float, float, float, float, int, float) -> void = null # x y w h radius width rgb alpha
text: fn(float, float, string, float, int, float) -> void = null # x y text size rgb alpha
measure: fn(string, float) -> float = null
pointer_x: fn() -> float = null
pointer_y: fn() -> float = null
pointer_down: fn() -> bool = null
button: fn(float, float, float, float, string, bool) -> bool = null
image: fn(string, float, float, float, float, int, float) -> void = null # src x y w h tint alpha
color: fn(string) -> int = null
px: fn(float) -> float = null
scroll_begin: fn(string, float, float, float, float, float) -> float = null
scroll_end: fn() -> void = null
read: fn(string) -> string = null
image: fn(string, float, float, float, float, int, float) -> void = null # src x y w h tint alpha
nine: fn(string, float, float, float, float, float, float, int, float) -> void = null # src slice x y w h width tint alpha
clip: fn(float, float, float, float) -> void = null
unclip: fn() -> void = null
scale: fn() -> float = null # screen pixels per design pixel
translate: fn(string) -> string = null # the program's language, for every text a template shows
sound: fn(string) -> void = null # a control was used: "click", or its sound="..."
read: fn(string) -> string = null # a template file's text (else Fs.read_text)
now: fn() -> float = null # seconds, for animation (else frames at 60 a second)
input: bool = true # false: take no input from Input.* (a test drives it)
}
var ui_be: UiBackend = null
export function ui_backend(b: UiBackend) -> void { ui_be = b }
function ui_px(v: float) -> float {
if ui_be != null and ui_be.px != null { return ui_be.px(v) }
if ui_be != null and ui_be.scale != null { return v * ui_be.scale() }
return v
}
function ui_tr(s: string) -> string {
if ui_be != null and ui_be.translate != null { return ui_be.translate(s) }
return s
}
function ui_sound(name: string) -> void {
if ui_be != null and ui_be.sound != null { ui_be.sound(name) }
}
# the program's language: every text a template shows goes through `f` before it is measured
export function ui_translator(f: fn(string) -> string) -> void {
if ui_be != null { ui_be.translate = f }
}
# the program's sounds: "click" when a control is used, or the control's own sound="..."
export function ui_sounds(f: fn(string) -> void) -> void {
if ui_be != null { ui_be.sound = f }
}
# the program's clock, in seconds, for animation (a game's own time, which a headless run counts)
export function ui_clock(f: fn() -> float) -> void {
if ui_be != null { ui_be.now = f }
}

View file

@ -14,7 +14,7 @@ function bd_kids(kids: []UiTpl, e0: UiEnv, key: string, out: []UiNode) -> void {
e = bd_let(k, e)
continue
}
if k.tag == "fragment" or (k.tag == "template" and tpl_attr(k, "slot") == null) { bd_group(k, e, ck, out) } else if k.tag == "provide" { bd_provide(k, e, ck, out) } else if k.tag == "template" { continue } else if k.tag == "if" {
if k.tag == "fragment" or k.tag == "#text" or (k.tag == "template" and tpl_attr(k, "slot") == null) { bd_group(k, e, ck, out) } else if k.tag == "provide" { bd_provide(k, e, ck, out) } else if k.tag == "template" { continue } else if k.tag == "if" {
last_if = bd_test(k, e)
if last_if { bd_kids(k.kids, e, ck, out) }
} else if k.tag == "else" {

View file

@ -21,7 +21,7 @@ function bd_group(k: UiTpl, e: UiEnv, key: string, out: []UiNode) -> void {
let t = new UiNode
t.kind = UI_TEXT
t.key = key
t.text = ui_eval_text(k.text, e)
t.text = ui_tr(ui_eval_text(k.text, e))
t.env = e
t.children = new []UiNode
push(out, t)

View file

@ -22,6 +22,8 @@ function cs_node(n: UiNode) -> void {
if n.style != null { cs_rule(n, n.style) }
if n.style2 != null { cs_rule_in(n, n.style2, n.env2) }
for i in 0 .. len(n.akeys) { el_apply(n, n.akeys[i], n.vals[i]) }
an_apply(n)
tr_apply(n)
}
function cs_sheet(n: UiNode, sh: UiSheet) -> void {
for i in 0 .. len(sh.rules) {
@ -39,8 +41,6 @@ function cs_rule_in(n: UiNode, r: UiRule, e: UiEnv) -> void {
if not (len(r.keys[j]) > 2 and r.keys[j][0..2] == "--") { el_attr(n, r.keys[j], ui_eval(r.vals[j], e)) }
}
}
# what every element looks like before anyone styles it, as a browser's own sheet does
const UA_LSS: string = "h1 { font-size: 32 } h2 { font-size: 26 } h3 { font-size: 22 } h4 { font-size: 19 } h5 { font-size: 16 } h6 { font-size: 14 } small { font-size: 13 } button { padding: 5 10 } hr { height: 1; width: fill; background: #808080; margin: 4 0 } scroll { width: fill; height: fill } img { width: 24; height: 24 }"
var ua_sh: UiSheet = null
function ua_sheet() -> UiSheet {
if ua_sh != null { return ua_sh }

View file

@ -0,0 +1,58 @@
# color.ludic - CSS colours: #rgb, #rgba, #rrggbb, #rrggbbaa, rgb(r, g, b), rgba(r, g, b, a), the
# basic names, transparent and none. A colour is 0xRRGGBB; its alpha is left in cl_alpha.
var cl_alpha: float = 1.0
const CL_NAMES: string = " black=000000 white=ffffff red=ff0000 green=008000 lime=00ff00 blue=0000ff yellow=ffff00 cyan=00ffff aqua=00ffff magenta=ff00ff fuchsia=ff00ff gray=808080 grey=808080 silver=c0c0c0 maroon=800000 olive=808000 navy=000080 purple=800080 teal=008080 orange=ffa500 "
function ui_color(name0: string) -> int {
let name = tpl_words(name0)
cl_alpha = 1.0
if name == "none" or name == "" or name == "transparent" { return UI_NONE }
if name[0] == 35 { return cl_hex(name[1..len(name)]) }
if len(name) > 4 and (name[0..4] == "rgb(" or name[0..5] == "rgba(") { return cl_rgb(name) }
let pat = ` {name}=`
let n = len(pat)
for i in 0 .. len(CL_NAMES) - n + 1 {
if CL_NAMES[i..i + n] == pat { return cl_hex(CL_NAMES[i + n..i + n + 6]) }
}
ui_err(`{name}: not a colour - #rrggbb, rgb(), rgba(), a name, or var(--a-colour)`)
return 15658734
}
function cl_digit(c: int) -> int {
if c >= 97 { return c - 87 }
if c >= 65 { return c - 55 }
return c - 48
}
function cl_hex(h: string) -> int {
var c = 0
let n = len(h)
if n == 3 or n == 4 {
for i in 0 .. 3 { c = c * 256 + cl_digit(h[i]) * 17 }
if n == 4 { cl_alpha = float(cl_digit(h[3]) * 17) / 255.0 }
return c
}
for i in 0 .. 3 { c = c * 256 + cl_digit(h[i * 2]) * 16 + cl_digit(h[i * 2 + 1]) }
if n == 8 { cl_alpha = float(cl_digit(h[6]) * 16 + cl_digit(h[7])) / 255.0 }
return c
}
# rgb(217, 165, 74) or rgba(0, 0, 0, 0.55)
function cl_rgb(s: string) -> int {
var open = 0
while open < len(s) and s[open] != 40 { open += 1 }
let parts = lss_split(s[open + 1..len(s) - 1], 44)
var c = 0
for i in 0 .. Math.min(3, len(parts)) { c = c * 256 + Math.clamp(int(el_num(tpl_words(parts[i]))), 0, 255) }
if len(parts) > 3 { cl_alpha = el_num(tpl_words(parts[3])) }
return c
}
# a colour as #rrggbb, for a dump or a test
export function ui_hex(c: int) -> string {
if c < 0 { return "none" }
let digits = "0123456789abcdef"
var out = "#"
var div = 1048576
while div >= 1 {
let d = (c / div) % 16
out = out + digits[d..d + 1]
div = div / 16
}
return out
}

View file

@ -0,0 +1,91 @@
# controls.ludic - what a control does: a button (or anything with on-click) is pressed, a checkbox
# flips, a radio chooses its value, a select steps through its options, a range moves by its step
# (or to where the pointer is on its track), a text field takes what is typed, a key field the next
# key. Each says so with on-change and event.value, after the frame is drawn.
var ct_capture: string = ""
function ct_type(n: UiNode) -> string {
if n.tag == "select" { return "select" }
if n.tag != "input" { return "" }
let t = ui_attr_text(n, "type")
if t == "" { return "text" }
return t
}
function ct_takes_arrows(n: UiNode) -> bool { return ct_type(n) == "range" or ct_type(n) == "select" }
function ct_takes_space(n: UiNode) -> bool { return ct_type(n) == "text" }
function ct_change(n: UiNode, v: Val) -> void {
ui_sound(ct_sound(n))
ui_fire(n, "change", v)
}
function ct_sound(n: UiNode) -> string {
let s = ui_attr_text(n, "sound")
if s == "" { return "click" }
return s
}
function ct_activate(n: UiNode) -> void {
if not n.enabled { return }
let t = ct_type(n)
if t == "checkbox" { ct_change(n, vl_bool(not ui_attr_on(n, "checked"))) } else if t == "radio" { ct_change(n, ui_attr(n, "value")) } else if t == "select" { ct_step(n, 1) } else if t == "key" { ct_capture = n.key } else if t == "" {
ui_sound(ct_sound(n))
ui_fire(n, "press", Value.null())
}
}
function ct_step(n: UiNode, d: int) -> void {
let t = ct_type(n)
if t == "select" and n.opts != null and len(n.opts) > 0 {
let at = (ct_option_at(n, n.opts) + d + len(n.opts)) % len(n.opts)
let ov = ui_attr(n.opts[at], "value")
if Value.kind(ov) == 0 { ct_change(n, Value.int(at)) } else { ct_change(n, ov) }
}
if t == "range" {
var step = Value.as_float(ui_attr(n, "step"))
if step <= 0.0 { step = 1.0 }
ct_range_set(n, Value.as_float(ui_attr(n, "value")) + step * float(d))
}
}
function ct_range_set(n: UiNode, v0: float) -> void {
let lo = Value.as_float(ui_attr(n, "min"))
let hi = Value.as_float(ui_attr(n, "max"))
let v = Math.clamp(v0, lo, Math.max(hi, lo))
if v != Value.as_float(ui_attr(n, "value")) { ui_fire(n, "change", Value.float(v)) }
}
# a range's track pressed or dragged: the value where the pointer is
function ct_press(n: UiNode, x: float, y: float) -> void {
if ct_type(n) != "range" { return }
for i in 0 .. len(n.children) {
let t = n.children[i]
if sel_has(t.classes, "ui-track") and t.cw > 0.0 {
let lo = Value.as_float(ui_attr(n, "min"))
let hi = Value.as_float(ui_attr(n, "max"))
var step = Value.as_float(ui_attr(n, "step"))
if step <= 0.0 { step = 1.0 }
let raw = lo + Math.clamp((x - t.x) / t.cw, 0.0, 1.0) * (hi - lo)
ct_range_set(n, lo + float(int((raw - lo) / step + 0.5)) * step)
}
}
}
function ct_drag(root: UiNode, key: string, x: float, y: float) -> void {
let n = fr_node(root, key)
if n != null { ct_press(n, x, y) }
}
# typing into a focused text field, or the key a key field is waiting for
function ct_typed(n: UiNode) -> void {
let t = ct_type(n)
if t == "key" and ct_capture == n.key and in_now.key != 0 and not in_now.enter {
ct_capture = ""
ct_change(n, Value.int(in_now.key))
return
}
if t != "text" or (in_now.typed == "" and not in_now.backspace) { return }
var s = ui_attr_text(n, "value")
if in_now.backspace and len(s) > 0 { s = s[0..ct_char_start(s)] }
var max = Value.as_int(ui_attr(n, "maxlength"))
if max <= 0 { max = 1000 }
if len(in_now.typed) > 0 and len(s) < max { s = s + in_now.typed[0..Math.min(len(in_now.typed), max - len(s))] }
ui_fire(n, "change", Value.str(s))
}
# where the last UTF-8 character starts, so Backspace takes a whole letter
function ct_char_start(s: string) -> int {
var i = len(s) - 1
while i > 0 and s[i] >= 128 and s[i] < 192 { i -= 1 }
return i
}

View file

@ -0,0 +1,86 @@
# controls_build.ludic - a control's parts, made as plain child boxes a stylesheet can style:
# input[type=checkbox|radio] .ui-label .ui-track > .ui-knob
# input[type=range] .ui-label .ui-track > .ui-fill, .ui-thumb .ui-value
# input[type=text|key] .ui-label .ui-field > .ui-value (+ .ui-caret while focused)
# select .ui-label .ui-prev .ui-value .ui-next (its <option>s hidden)
function ct_part(n: UiNode, cls: string, text: string) -> UiNode {
let p = new UiNode
p.key = `{n.key}/{cls}`
p.tag = "span"
p.env = n.env
p.sheet = n.sheet
p.children = new []UiNode
p.classes = new []string
push(p.classes, cls)
p.akeys = new []string
p.avals = new []string
p.vals = new []Val
if text != "" {
p.kind = UI_TEXT
p.text = text
}
return p
}
function ct_build(n: UiNode) -> void {
n.dir = UI_ROW
n.focusable = true
let t = ui_attr_text(n, "type")
let label = ui_attr_text(n, "label")
if label != "" { push(n.children, ct_part(n, "ui-label", ui_tr(label))) }
if n.tag == "select" {
ct_build_select(n)
return
}
if t == "checkbox" or t == "radio" {
let track = ct_part(n, "ui-track", "")
push(track.children, ct_part(n, "ui-knob", ""))
push(n.children, track)
} else if t == "range" {
ct_build_range(n)
} else {
let field = ct_part(n, "ui-field", "")
push(field.children, ct_part(n, "ui-value", ct_field_text(n)))
if fc_key == n.key and t != "key" { push(field.children, ct_part(n, "ui-caret", "")) }
push(n.children, field)
}
}
function ct_build_range(n: UiNode) -> void {
let lo = Value.as_float(ui_attr(n, "min"))
var hi = Value.as_float(ui_attr(n, "max"))
if hi <= lo { hi = lo + 1.0 }
let v = Value.as_float(ui_attr(n, "value"))
let track = ct_part(n, "ui-track", "")
let fill = ct_part(n, "ui-fill", "")
fill.w_pct = Math.clamp((v - lo) / (hi - lo), 0.0, 1.0) * 100.0
push(track.children, fill)
push(track.children, ct_part(n, "ui-thumb", ""))
push(n.children, track)
push(n.children, ct_part(n, "ui-value", ui_attr_text(n, "value")))
}
function ct_build_select(n: UiNode) -> void {
let opts = n.children
n.children = new []UiNode
let label = ui_attr_text(n, "label")
if label != "" { push(n.children, ct_part(n, "ui-label", ui_tr(label))) }
var shown = ""
let at = ct_option_at(n, opts)
if at >= 0 { shown = opts[at].text }
push(n.children, ct_part(n, "ui-prev", "<"))
push(n.children, ct_part(n, "ui-value", shown))
push(n.children, ct_part(n, "ui-next", ">"))
n.opts = opts
}
# which option the select's value names: an option's value="...", else its place
function ct_option_at(n: UiNode, opts: []UiNode) -> int {
let v = ui_attr_text(n, "value")
for i in 0 .. len(opts) {
let ov = ui_attr_text(opts[i], "value")
if ov == v or (ov == "" and string(i) == v) { return i }
}
return -1
}
function ct_field_text(n: UiNode) -> string {
if ui_attr_text(n, "type") != "key" { return ui_attr_text(n, "value") }
if ct_capture == n.key { return ui_tr("press a key...") }
return Input.key_label(Value.as_int(ui_attr(n, "value")))
}

View file

@ -0,0 +1,24 @@
# dev.ludic - a developer's build: templates and stylesheets read again when they change (once a
# second), template errors drawn over the screen, and LUDIC_UI_DUMP=<screen> printing that screen's
# tree once, with every element's box and computed style
var dv_on: bool = false
var dv_frames: int = 0
var dv_dumped: bool = false
export function ui_dev(on: bool) -> void { dv_on = on }
function dv_before() -> void {
if not dv_on { return }
dv_frames += 1
if dv_frames % 60 == 0 { ui_reload() }
}
function dv_after(screen: string, root: UiNode) -> void {
if not dv_on { return }
if not dv_dumped and Os.has_env("LUDIC_UI_DUMP") and Os.env("LUDIC_UI_DUMP") == screen {
dv_dumped = true
print(ui_dump(root, 0))
}
if len(ui_errs) == 0 or ui_be == null or ui_be.rect == null { return }
let n = Math.min(len(ui_errs), 8)
let line = ui_px(26.0)
ui_be.rect(0.0, 0.0, 4000.0, line * float(n) + ui_px(8.0), 4194304, 0.85)
for i in 0 .. n { ui_be.text(ui_px(12.0), ui_px(6.0) + line * float(i), ui_errs[len(ui_errs) - n + i], ui_px(18.0), 16767449, 1.0) }
}

View file

@ -1,37 +1,94 @@
# draw.ludic - the laid-out nodes through the renderer: background, border, then content
# draw.ludic - the laid-out nodes through the renderer: the background (rounded by border-radius),
# the border, the content, the children in painting order (a scroll box's clipped to it, with its
# bar), then the outline, which is where a focus ring is drawn
var fr_ga: float = 1.0 # the opacity of everything around this element, which it multiplies
function fr_draw(n: UiNode) -> void {
if n.kind == UI_BUTTON {
fr_button(n)
return
}
let outer = fr_ga
fr_ga = outer * n.alpha
fr_draw_in(n)
fr_ga = outer
}
function fr_draw_in(n: UiNode) -> void {
if n.kind == UI_NATIVE {
n.native.draw(n)
return
}
if n.bg >= 0 and ui_be.rect != null { ui_be.rect(n.x, n.y, n.cw, n.ch, n.bg, n.alpha) }
fr_background(n)
fr_border(n)
if n.kind == UI_TEXT { fr_text(n) }
if n.kind == UI_IMAGE and ui_be.image != null { ui_be.image(n.src, n.x + n.pl + n.border, n.y + n.pt + n.border, n.cw - ly_hin(n), n.ch - ly_vin(n), n.rfg, n.alpha) }
var off = 0.0
if n.kind == UI_SCROLL and ui_be.scroll_begin != null { off = ui_be.scroll_begin(n.key, n.x, n.y, n.cw, n.ch, n.content_h) }
if off != 0.0 {
for i in 0 .. len(n.children) { fr_shift(n.children[i], off) }
}
if n.kind == UI_IMAGE and ui_be.image != null { ui_be.image(n.src, n.x + n.pl + n.border, n.y + n.pt + n.border, n.cw - ly_hin(n), n.ch - ly_vin(n), n.rfg, fr_ga * n.rfg_a) }
let clipped = n.kind == UI_SCROLL and ui_be.clip != null
if clipped { fr_clip_push(n.x, n.y, n.cw, n.ch) }
if n.children != null { fr_children(n) }
if n.kind == UI_SCROLL and ui_be.scroll_end != null { ui_be.scroll_end() }
if clipped { fr_clip_pop() }
if n.kind == UI_SCROLL { sc_bar(n) }
if n.outline_w > 0.0 { fr_outline(n) }
}
# the shadow, then the background: a nine-slice tinted by the background colour, or the colour
# (rounded), then a picture over it
function fr_background(n: UiNode) -> void {
if n.sh_c >= 0 { fr_fill(n, n.x + n.sh_x, n.y + n.sh_y, n.sh_c, fr_ga * n.sh_a) }
if n.bg >= 0 or n.bi_src != "" {
var c = n.bg
var a = fr_ga * n.bg_a
if c < 0 {
c = 16777215
a = fr_ga
}
fr_fill(n, n.x, n.y, c, a)
}
if n.bg_img != "" and ui_be.image != null { ui_be.image(n.bg_img, n.x, n.y, n.cw, n.ch, 16777215, fr_ga) }
}
function fr_fill(n: UiNode, x: float, y: float, c: int, a: float) -> void {
if n.bi_src != "" and ui_be.nine != null {
ui_be.nine(n.bi_src, n.bi_slice, x, y, n.cw, n.ch, Math.min(n.bi_width, Math.min(n.cw, n.ch) / 2.0), c, a)
return
}
fr_box(x, y, n.cw, n.ch, n.radius, c, a)
}
function fr_box(x: float, y: float, w: float, h: float, r: float, c: int, a: float) -> void {
if r > 0.0 and ui_be.round != null { ui_be.round(x, y, w, h, r, c, a) } else if ui_be.rect != null { ui_be.rect(x, y, w, h, c, a) }
}
function fr_border(n: UiNode) -> void {
if n.border <= 0.0 or ui_be.rect == null { return }
var c = n.border_c
var ca = fr_ga * n.border_a
if c < 0 {
c = n.rfg
ca = fr_ga * n.rfg_a
}
fr_frame(n.x, n.y, n.cw, n.ch, n.border, c, ca)
}
function fr_frame(x: float, y: float, w: float, h: float, b: float, c: int, a: float) -> void {
ui_be.rect(x, y, w, b, c, a)
ui_be.rect(x, y + h - b, w, b, c, a)
ui_be.rect(x, y + b, b, h - b * 2.0, c, a)
ui_be.rect(x + w - b, y + b, b, h - b * 2.0, c, a)
}
# an outline follows the box's corners, as CSS's does: rounded by border-radius, grown by its offset
function fr_outline(n: UiNode) -> void {
var c = n.outline_c
if c < 0 { c = n.rfg }
let o = n.outline_off + n.outline_w
let a = fr_ga * n.outline_a
if n.radius > 0.0 and ui_be.ring != null {
ui_be.ring(n.x - o, n.y - o, n.cw + o * 2.0, n.ch + o * 2.0, n.radius + o, n.outline_w, c, a)
return
}
fr_frame(n.x - o, n.y - o, n.cw + o * 2.0, n.ch + o * 2.0, n.outline_w, c, a)
}
# a text's lines (one, or the wrapped ones), each placed by text-align, the last cut by an ellipsis
function fr_text(n: UiNode) -> void {
let y = n.y + n.pt + n.border
let a = n.rfg_a * fr_ga
if n.lines == null {
var s = n.text
if n.ellipsis { s = ly_ellipsis(s, n.rsize, n.cw - ly_hin(n)) }
ui_be.text(fr_line_x(n, s), y, s, n.rsize, n.rfg)
ui_be.text(fr_line_x(n, s), y, s, n.rsize, n.rfg, a)
return
}
for i in 0 .. len(n.lines) { ui_be.text(fr_line_x(n, n.lines[i]), y + float(i) * n.rsize * 1.25, n.lines[i], n.rsize, n.rfg) }
for i in 0 .. len(n.lines) { ui_be.text(fr_line_x(n, n.lines[i]), y + float(i) * n.rsize * 1.25, n.lines[i], n.rsize, n.rfg, a) }
}
function fr_text_x(n: UiNode) -> float { return fr_line_x(n, n.text) }
# where a line starts: text-align within the room the box gives it
function fr_line_x(n: UiNode, s: string) -> float {
let x = n.x + n.pl + n.border
@ -40,52 +97,3 @@ function fr_line_x(n: UiNode, s: string) -> float {
if n.talign == UI_CENTER { return x + spare / 2.0 }
return x + spare
}
# the children in painting order: those in the flow first, then positioned ones by z-index
function fr_children(n: UiNode) -> void {
var positioned = 0
for i in 0 .. len(n.children) {
if n.children[i].pos == 0 { fr_draw(n.children[i]) } else { positioned += 1 }
}
if positioned == 0 { return }
var lo = 1000000
var hi = -1000000
for i in 0 .. len(n.children) {
if n.children[i].pos != 0 {
lo = Math.min(lo, n.children[i].z)
hi = Math.max(hi, n.children[i].z)
}
}
for z in lo .. hi + 1 {
for i in 0 .. len(n.children) {
if n.children[i].pos != 0 and n.children[i].z == z { fr_draw(n.children[i]) }
}
}
}
function fr_border(n: UiNode) -> void {
if n.border <= 0.0 or ui_be.rect == null { return }
var c = n.border_c
if c < 0 { c = n.rfg }
let b = n.border
ui_be.rect(n.x, n.y, n.cw, b, c, n.alpha)
ui_be.rect(n.x, n.y + n.ch - b, n.cw, b, c, n.alpha)
ui_be.rect(n.x, n.y, b, n.ch, c, n.alpha)
ui_be.rect(n.x + n.cw - b, n.y, b, n.ch, c, n.alpha)
}
# the renderer's own button when it has one: it draws, focuses and answers the press
function fr_button(n: UiNode) -> void {
if ui_be.button != null {
if ui_be.button(n.x, n.y, n.cw, n.ch, n.text, n.enabled) and n.enabled { ui_fire(n, "press", Value.null()) }
return
}
var bg = n.bg
if bg < 0 { bg = 4210752 }
if ui_be.rect != null { ui_be.rect(n.x, n.y, n.cw, n.ch, bg, n.alpha) }
fr_border(n)
ui_be.text(fr_text_x(n), n.y + n.pt + n.border, n.text, n.rsize, n.rfg)
}
function fr_shift(n: UiNode, off: float) -> void {
n.y = n.y - off
if n.children != null {
for i in 0 .. len(n.children) { fr_shift(n.children[i], off) }
}
}

View file

@ -0,0 +1,48 @@
# draw_order.ludic - painting order, and clipping that nests
var fr_clips: []float = new []float
function fr_clip_push(x: float, y: float, w: float, h: float) -> void {
var x0 = x
var y0 = y
var x1 = x + w
var y1 = y + h
let n = len(fr_clips)
if n >= 4 {
x0 = Math.max(x0, fr_clips[n - 4])
y0 = Math.max(y0, fr_clips[n - 3])
x1 = Math.min(x1, fr_clips[n - 2])
y1 = Math.min(y1, fr_clips[n - 1])
}
push(fr_clips, x0)
push(fr_clips, y0)
push(fr_clips, x1)
push(fr_clips, y1)
ui_be.clip(x0, y0, Math.max(x1 - x0, 0.0), Math.max(y1 - y0, 0.0))
}
function fr_clip_pop() -> void {
var keep = new []float
for i in 0 .. len(fr_clips) - 4 { push(keep, fr_clips[i]) }
fr_clips = keep
let n = len(fr_clips)
if n >= 4 { ui_be.clip(fr_clips[n - 4], fr_clips[n - 3], fr_clips[n - 2] - fr_clips[n - 4], fr_clips[n - 1] - fr_clips[n - 3]) } else if ui_be.unclip != null { ui_be.unclip() }
}
# the children in painting order: those in the flow first, then positioned ones by z-index
function fr_children(n: UiNode) -> void {
var positioned = 0
for i in 0 .. len(n.children) {
if n.children[i].pos == 0 { fr_draw(n.children[i]) } else { positioned += 1 }
}
if positioned == 0 { return }
var lo = 1000000
var hi = -1000000
for i in 0 .. len(n.children) {
if n.children[i].pos != 0 {
lo = Math.min(lo, n.children[i].z)
hi = Math.max(hi, n.children[i].z)
}
}
for z in lo .. hi + 1 {
for i in 0 .. len(n.children) {
if n.children[i].pos != 0 and n.children[i].z == z { fr_draw(n.children[i]) }
}
}
}

View file

@ -23,8 +23,8 @@ function dp_name(n: UiNode) -> string {
function dp_style(n: UiNode) -> string {
var s = ""
if n.size > 0.0 { s = s + ` font {int(n.size)}` }
if n.fg >= 0 { s = s + ` color {n.fg}` }
if n.bg >= 0 { s = s + ` bg {n.bg}` }
if n.fg >= 0 { s = s + ` color {ui_hex(n.fg)}` }
if n.bg >= 0 { s = s + ` bg {ui_hex(n.bg)}` }
if n.alpha < 1.0 { s = s + ` opacity {n.alpha}` }
if n.pt + n.pr + n.pb + n.pl > 0.0 { s = s + ` pad {int(n.pt)} {int(n.pr)} {int(n.pb)} {int(n.pl)}` }
if n.mt + n.mr + n.mb + n.ml > 0.0 { s = s + ` margin {int(n.mt)} {int(n.mr)} {int(n.mb)} {int(n.ml)}` }

View file

@ -25,8 +25,9 @@ function bd_elem(k: UiTpl, e: UiEnv, key: string) -> UiNode {
}
lc_note(key, k.on_keys, k.on_acts, e)
bd_kids(k.kids, e, key, n.children)
if k.text != null { el_text(n, ui_eval_text(k.text, e)) }
if k.text != null { el_text(n, ui_tr(ui_eval_text(k.text, e))) }
el_inline(n)
if k.tag == "input" or k.tag == "select" { ct_build(n) }
return n
}
function el_kind(n: UiNode, tag: string) -> void {
@ -34,7 +35,10 @@ function el_kind(n: UiNode, tag: string) -> void {
if nv != null {
n.kind = UI_NATIVE
n.native = nv
} else if lk_in(EL_TEXTS, tag) { n.kind = UI_TEXT } else if tag == "row" { n.dir = UI_ROW } else if tag == "button" { n.kind = UI_BUTTON } else if tag == "spacer" { n.kind = UI_SPACER } else if tag == "scroll" { n.kind = UI_SCROLL } else if tag == "img" { n.kind = UI_IMAGE } else if tag == "hr" { n.kind = UI_RULE } else if not lk_in(EL_BOXES, tag) { ui_err(`<{tag}>: there is no such element`) }
} else if lk_in(EL_TEXTS, tag) { n.kind = UI_TEXT } else if tag == "row" { n.dir = UI_ROW } else if tag == "button" {
n.dir = UI_ROW
n.focusable = true
} else if tag == "input" or tag == "select" { n.focusable = true } else if tag == "spacer" { n.kind = UI_SPACER } else if tag == "scroll" { n.kind = UI_SCROLL } else if tag == "img" { n.kind = UI_IMAGE } else if tag == "hr" { n.kind = UI_RULE } else if not lk_in(EL_BOXES, tag) { ui_err(`<{tag}>: there is no such element`) }
}
# a box with words in it holds them as a text of its own, as <div>words</div> does
function el_text(n: UiNode, s: string) -> void {
@ -57,27 +61,10 @@ function el_keep(n: UiNode, key: string, v: Val) -> void {
for i in 0 .. len(parts) {
if len(parts[i]) > 0 { push(n.classes, parts[i]) }
}
} else if key == "hidden" { n.hidden = vl_truthy(v) and s != "false" } else if key == "disabled" { n.enabled = not (vl_truthy(v) and s != "false") } else if key == "src" { n.src = s } else {
if key == "enabled" { n.enabled = vl_truthy(v) and s != "false" }
} else if key == "tabindex" { n.focusable = s != "-1" } else if key == "hidden" { n.hidden = el_flag(v) } else if key == "disabled" { n.enabled = not el_flag(v) } else if key == "src" { n.src = s } else {
if key == "enabled" { n.enabled = el_flag(v) }
push(n.akeys, key)
push(n.avals, s)
push(n.vals, v)
}
}
# a colour by the renderer's name for it, else #rrggbb (or #rgb)
function ui_color(name: string) -> int {
if name == "none" or name == "" or name == "transparent" { return UI_NONE }
if name[0] != 35 {
if ui_be != null and ui_be.color != null { return ui_be.color(name) }
return 15658734
}
var c = 0
for i in 1 .. len(name) {
var d = name[i] - 48
if name[i] >= 97 { d = name[i] - 87 }
if name[i] >= 65 and name[i] < 97 { d = name[i] - 55 }
c = c * 16 + d
if len(name) == 4 { c = c * 16 + d }
}
return c
}

View file

@ -0,0 +1,59 @@
# focus.ludic - the keyboard's place: focus moves through the controls in document order (Tab and
# Shift+Tab, or the arrows), Enter and Space activate what has it, and a pointer press focuses what
# it presses. A key that is gone from the screen leaves nothing focused.
var fc_key: string = ""
var fc_visible: bool = false # focus came from the keyboard: :focus-visible, and the ring
var fc_list: []UiNode = new []UiNode
function fc_collect(n: UiNode) -> void {
if n.focusable and n.enabled { push(fc_list, n) }
for i in 0 .. len(n.children) { fc_collect(n.children[i]) }
}
function fc_find() -> int {
for i in 0 .. len(fc_list) {
if fc_list[i].key == fc_key { return i }
}
return -1
}
function fc_move(step: int) -> void {
if len(fc_list) == 0 { return }
var at = fc_find()
if at < 0 {
at = 0
if step < 0 { at = len(fc_list) - 1 }
} else { at = (at + step + len(fc_list)) % len(fc_list) }
fc_key = fc_list[at].key
fc_visible = true
}
# the keys, after the tree is laid out: moving, then acting on what has the focus
function fc_keys(root: UiNode) -> void {
fc_list = new []UiNode
fc_collect(root)
let i = in_now
if fc_find() < 0 { fc_autofocus() }
let at = fc_find()
var n: UiNode = null
if at >= 0 { n = fc_list[at] }
if i.tab and i.shift { fc_move(-1) } else if i.tab { fc_move(1) }
let horizontal = n != null and ct_takes_arrows(n)
if i.up or (i.left and not horizontal) { fc_move(-1) }
if i.down_key or (i.right and not horizontal) { fc_move(1) }
if n == null { return }
if i.enter or (i.space and not ct_takes_space(n)) { ct_activate(n) }
if horizontal and i.left { ct_step(n, -1) }
if horizontal and i.right { ct_step(n, 1) }
ct_typed(n)
}
export function ui_focus(key: string) -> void {
fc_key = key
fc_visible = false
}
# nothing focused (a screen just opened): the control that says autofocus, as HTML's does
function fc_autofocus() -> void {
for i in 0 .. len(fc_list) {
if ui_attr_on(fc_list[i], "autofocus") {
fc_key = fc_list[i].key
fc_visible = false
return
}
}
}

View file

@ -1,49 +0,0 @@
# forms.ludic - <input>, <select> and <textarea> for a renderer that has none of its own: a value
# drawn in a box, a checkbox or radio pressed to change it, a select stepping through its options.
# A game registers its own with ui_native under the same tags and these give way.
var nt_ready: bool = false
function nt_defaults() -> void {
if nt_ready { return }
nt_ready = true
ui_native("input", fn fm_measure, fn fm_draw)
ui_native("select", fn fm_measure, fn fm_draw)
ui_native("textarea", fn fm_measure, fn fm_draw)
}
# what the control shows: a checkbox its tick, a select its chosen option's text, else its value
function fm_shown(n: UiNode) -> string {
let t = ui_attr_text(n, "type")
if t == "checkbox" or t == "radio" {
if ui_attr_on(n, "checked") { return "[x]" }
return "[ ]"
}
if n.tag == "select" {
let v = ui_attr_text(n, "value")
for i in 0 .. len(n.children) {
if ui_attr_text(n.children[i], "value") == v { return n.children[i].text }
}
}
return ui_attr_text(n, "value")
}
function fm_measure(n: UiNode, avail: float) -> void {
n.mw = Math.max(ly_text_w(fm_shown(n), n.rsize), n.rsize * 2.0)
n.mh = n.rsize * 1.25
}
function fm_draw(n: UiNode) -> void {
if ui_be.rect != null { ui_be.rect(n.x, n.y, n.cw, n.ch, 2105376, n.alpha) }
fr_border(n)
ui_be.text(n.x + n.pl + n.border, n.y + n.pt + n.border, fm_shown(n), n.rsize, n.rfg)
}
# the default pointer presses a control: a checkbox flips, a select moves to its next option
function fm_press(n: UiNode) -> void {
let t = ui_attr_text(n, "type")
if t == "checkbox" { ui_fire(n, "change", vl_bool(not ui_attr_on(n, "checked"))) }
if t == "radio" { ui_fire(n, "change", ui_attr(n, "value")) }
if n.tag == "select" and len(n.children) > 0 {
let v = ui_attr_text(n, "value")
var at = 0
for i in 0 .. len(n.children) {
if ui_attr_text(n.children[i], "value") == v { at = (i + 1) % len(n.children) }
}
ui_fire(n, "change", ui_attr(n.children[at], "value"))
}
}

View file

@ -1,16 +1,21 @@
# frame.ludic - a screen shown: built from the view's model, laid out, drawn, and its presses
# run once the frame is drawn, so nothing the view does can change a frame half way through it
var fr_was_down: bool = false
var fr_down_now: bool = false
export function ui_show(screen: string, view: UiView, x: float, y: float, w: float, h: float) -> void {
in_read()
dv_before()
an_frame += 1
ui_viewport(w, h)
let root = ui_nodes(screen, view)
if root == null { return }
ui_place(root, x, y, w, h)
nt_fired_clear()
sc_undo(root)
sc_update(root)
fr_pointer(root)
fc_keys(root)
if ui_be != null and ui_be.text != null { fr_draw(root) }
dv_after(screen, root)
lc_run(screen)
nt_fired_run()
}
@ -56,10 +61,11 @@ export function ui_place(root: UiNode, x: float, y: float, w: float, h: float) -
ly_vp_y = y
root.w = UI_FILL
root.h = UI_FILL
var fg = 15658734
if ui_be != null and ui_be.color != null { fg = ui_be.color("text") }
let fg = 15658734
ly_fg_a = 1.0
ly_measure(root, w, ui_px(16.0), fg)
ly_place(root, x, y, w, h)
sc_apply(root)
}
# a press, as if the pointer had made it
export function ui_press(n: UiNode) -> void {

View file

@ -14,7 +14,11 @@ import "env.ludic"
import "eval.ludic"
import "act.ludic"
import "native.ludic"
import "forms.ludic"
import "controls.ludic"
import "controls_build.ludic"
import "input.ludic"
import "focus.ludic"
import "scroll.ludic"
import "life.ludic"
import "tpl.ludic"
import "tpl_lines.ludic"
@ -23,15 +27,21 @@ import "lib_util.ludic"
import "class.ludic"
import "instance.ludic"
import "reload.ludic"
import "dev.ludic"
import "link.ludic"
import "state.ludic"
import "build.ludic"
import "build_more.ludic"
import "build_class.ludic"
import "elem.ludic"
import "color.ludic"
import "attr.ludic"
import "attr_box.ludic"
import "attr_place.ludic"
import "attr_edge.ludic"
import "ua.ludic"
import "anim.ludic"
import "transition.ludic"
import "units.ludic"
import "style.ludic"
import "style_rule.ludic"
@ -48,4 +58,5 @@ import "frame.ludic"
import "pointer.ludic"
import "screen_class.ludic"
import "draw.ludic"
import "draw_order.ludic"
import "dump.ludic"

View file

@ -0,0 +1,62 @@
# input.ludic - what the player did this frame, read once from the runtime's Input: the pointer, its
# button, the wheel, the keys a UI answers and what was typed. A test or a host can set it instead.
export property UiInput {
x: float = -1.0
y: float = -1.0
down: bool = false
wheel: float = 0.0
tab: bool = false
shift: bool = false
up: bool = false
down_key: bool = false
left: bool = false
right: bool = false
enter: bool = false
space: bool = false
escape: bool = false
backspace: bool = false
typed: string = ""
key: int = 0 # any key pressed this frame, for a key-capture field
}
var in_now: UiInput = new UiInput
var in_was_down: bool = false
var in_given: bool = false
function in_read() -> void {
if in_given {
in_given = false
return
}
in_was_down = in_now.down
let i = new UiInput
if ui_be != null and ui_be.input {
i.x = float(Input.mouse_x())
i.y = float(Input.mouse_y())
i.down = Input.mouse_down(0)
i.wheel = float(Input.wheel())
i.tab = Input.key_pressed(Key.Tab)
i.shift = Input.key_down(Key.Shift)
i.up = Input.key_pressed(Key.Up)
i.down_key = Input.key_pressed(Key.Down)
i.left = Input.key_pressed(Key.Left)
i.right = Input.key_pressed(Key.Right)
i.enter = Input.key_pressed(Key.Enter)
i.space = Input.key_pressed(Key.Space)
i.escape = Input.key_pressed(Key.Escape)
i.backspace = Input.key_pressed(Key.Backspace)
i.typed = Input.text()
i.key = in_any_key()
}
in_now = i
}
function in_any_key() -> int {
for k in 8 .. 167 {
if Input.key_pressed(k) { return k }
}
return 0
}
# this frame's input, given by a test or a host that reads its own
export function ui_input(i: UiInput) -> void {
in_was_down = in_now.down
in_now = i
in_given = true
}

View file

@ -5,13 +5,14 @@ function ly_measure(n: UiNode, avail: float, size: float, fg: int) -> void {
n.rsize = size
if n.size > 0.0 { n.rsize = n.size }
n.rfg = fg
if n.fg != UI_INHERIT { n.rfg = n.fg }
n.rfg_a = ly_fg_a
if n.fg != UI_INHERIT {
n.rfg = n.fg
n.rfg_a = n.fg_a
}
ly_fg_a = n.rfg_a
n.mw = ly_hin(n)
n.mh = ly_vin(n)
if n.kind == UI_BUTTON {
n.mw = n.mw + ly_text_w(n.text, n.rsize)
n.mh = n.mh + n.rsize * 1.25
}
if n.kind == UI_TEXT { ly_measure_text(n, avail) }
if n.kind == UI_NATIVE and n.native.measure != null {
let base_w = n.mw
@ -40,6 +41,7 @@ function ly_measure(n: UiNode, avail: float, size: float, fg: int) -> void {
n.mw = ly_clamp(n.mw, n.min_w, n.max_w)
n.mh = ly_clamp(n.mh, n.min_h, n.max_h)
}
var ly_fg_a: float = 1.0
function ly_hin(n: UiNode) -> float { return n.pl + n.pr + n.border * 2.0 }
function ly_vin(n: UiNode) -> float { return n.pt + n.pb + n.border * 2.0 }
function ly_clamp(v: float, lo: float, hi: float) -> float {
@ -54,6 +56,7 @@ function ly_measure_box(n: UiNode, inner: float) -> void {
var count = 0
for i in 0 .. len(n.children) {
let c = n.children[i]
ly_fg_a = n.rfg_a
ly_measure(c, inner, n.rsize, n.rfg)
if c.pos < 2 {
main = main + ly_main(c, n.dir)
@ -79,6 +82,7 @@ function ly_measure_wrap(n: UiNode, inner: float) -> void {
var count = 0
for i in 0 .. len(n.children) {
let c = n.children[i]
ly_fg_a = n.rfg_a
ly_measure(c, inner, n.rsize, n.rfg)
if c.pos >= 2 { continue }
let cw = ly_main(c, UI_ROW)

View file

@ -1,6 +1,6 @@
# link.ludic - each tag in a file that is not an element names a component: the file's own, an
# unnamed import's exported one, or `as:Name` from the import called `as`
const LK_OWN: string = " row button spacer scroll img hr if else each slot state style component screen import let fragment template provide "
const LK_OWN: string = " row button input select textarea spacer scroll img hr if else each slot state style component screen import let fragment template provide #text "
function lib_link(f: UiFile, t: UiTpl) -> void {
t.sheet = f.sheet
if not lk_element(t.tag) {

View file

@ -22,7 +22,6 @@ export function ui_native(tag: string, measure: fn(UiNode, float) -> void, draw:
push(nt_list, nv)
}
function nt_find(tag: string) -> UiNative {
nt_defaults()
for i in 0 .. len(nt_list) {
if nt_list[i].tag == tag { return nt_list[i] }
}
@ -71,7 +70,15 @@ export function ui_attr(n: UiNode, key: string) -> Val {
return Value.null()
}
export function ui_attr_text(n: UiNode, key: string) -> string { return vl_text(ui_attr(n, key)) }
# a boolean attribute: present with no value is true, as HTML's are (<button autofocus>)
export function ui_attr_on(n: UiNode, key: string) -> bool {
let v = ui_attr(n, key)
if n.akeys == null { return false }
for i in 0 .. len(n.akeys) {
if n.akeys[i] == key { return el_flag(n.vals[i]) }
}
return false
}
function el_flag(v: Val) -> bool {
if Value.kind(v) == 4 and Value.as_str(v) == "" { return true }
return vl_truthy(v) and vl_text(v) != "false"
}

View file

@ -56,8 +56,12 @@ export property UiNode {
lines: []string = null # a wrapped text's lines
var_names: []string = null # its custom properties (--name), which its descendants inherit
var_vals: []string = null
fg: int = -1
bg: int = -2
fg: int = -1 # a colour is 0xRRGGBB with its alpha beside it; -1 inherits
fg_a: float = 1.0
bg: int = -2 # -2: no background
bg_a: float = 1.0
border_a: float = 1.0
rfg_a: float = 1.0
alpha: float = 1.0
enabled: bool = true
act: UiAct = null # on-press, which the pointer and the renderer's button fire
@ -76,5 +80,28 @@ export property UiNode {
rsize: float = 0.0
rfg: int = 0
hovered: bool = false
focusable: bool = false # a control, or anything with a tabindex: the keyboard reaches it
radius: float = 0.0 # border-radius
outline_w: float = 0.0 # outline: drawn outside the box, where a focus ring goes
outline_c: int = -1
outline_a: float = 1.0
outline_off: float = 0.0
scroll_y: float = 0.0 # how far a scroll box is scrolled
bar_c: int = -1 # scrollbar-color: its thumb, and its track
bar_track: int = -2
opts: []UiNode = null # a select's options
bg_img: string = "" # background-image: url(...), stretched over the box
bi_src: string = "" # border-image: a nine-slice drawn instead of the background, tinted by it
bi_slice: float = 0.0
bi_width: float = 0.0
sh_x: float = 0.0 # box-shadow: an offset copy of the box, drawn under it
sh_y: float = 0.0
sh_c: int = -2
sh_a: float = 1.0
anim: string = "" # animation: its @keyframes, how long, looping, back and forth
anim_dur: float = 0.0
anim_loop: bool = false
anim_alt: bool = false
tr_dur: float = 0.0 # transition: how long a change of opacity takes
}

View file

@ -1,44 +1,54 @@
# pointer.ludic - where the pointer is, what it presses
# the pointer: what it is over is :hover next frame (the element and all around it, as in CSS),
# and without a renderer's own button a press on the deepest pressable thing is a press
# pointer.ludic - the pointer: what it is over is :hover (the element and all around it, as in CSS),
# a press on a control focuses it and makes it :active, and letting go over it is a click. The
# control being dragged (a range's thumb, a scrollbar) keeps the pointer until it is let go.
var fr_hover: []string = new []string
var fr_press_key: string = ""
var fr_down_now: bool = false
function fr_was_hovered(key: string) -> bool { return sel_has(fr_hover, key) }
function fr_pointer(root: UiNode) -> void {
if ui_be == null or ui_be.pointer_x == null {
fr_hover = new []string
return
}
let px = ui_be.pointer_x()
let py = ui_be.pointer_y()
let i = in_now
fr_down_now = i.down
let keys = new []string
fr_under(root, px, py, keys)
fr_under(root, i.x, i.y, keys)
fr_hover = keys
if ui_be.pointer_down == null { return }
let down = ui_be.pointer_down()
fr_down_now = down
if ui_be.button != null { return }
let hit = fr_hit(root, px, py)
if hit != null and down and not fr_was_down and hit.enabled {
if hit.kind == UI_NATIVE { fm_press(hit) } else { ui_fire(hit, "press", Value.null()) }
let hit = fr_hit(root, i.x, i.y)
if i.down and not in_was_down {
fr_press_key = ""
if hit != null {
fr_press_key = hit.key
if hit.focusable { ui_focus(hit.key) }
ct_press(hit, i.x, i.y)
}
}
if i.down and fr_press_key != "" { ct_drag(root, fr_press_key, i.x, i.y) }
if not i.down and in_was_down {
if hit != null and hit.key == fr_press_key and hit.enabled { ct_activate(hit) }
fr_press_key = ""
}
fr_was_down = down
}
function fr_under(n: UiNode, px: float, py: float, keys: []string) -> void {
if px < n.x or py < n.y or px >= n.x + n.cw or py >= n.y + n.ch { return }
if n.key != "" { push(keys, n.key) }
for i in 0 .. len(n.children) { fr_under(n.children[i], px, py, keys) }
}
# the deepest control (or pressable element) under the pointer
function fr_hit(n: UiNode, px: float, py: float) -> UiNode {
if px < n.x or py < n.y or px >= n.x + n.cw or py >= n.y + n.ch { return null }
if n.children != null {
var i = len(n.children) - 1
while i >= 0 {
let h = fr_hit(n.children[i], px, py)
if h != null { return h }
i -= 1
}
var i = len(n.children) - 1
while i >= 0 {
let h = fr_hit(n.children[i], px, py)
if h != null { return h }
i -= 1
}
if n.act != null or (n.kind == UI_NATIVE and n.on_names != null and len(n.on_names) > 0) { return n }
if n.focusable or n.act != null { return n }
return null
}
export function ui_hit(root: UiNode, px: float, py: float) -> UiNode { return fr_hit(root, px, py) }
function fr_node(n: UiNode, key: string) -> UiNode {
if n.key == key { return n }
for i in 0 .. len(n.children) {
let f = fr_node(n.children[i], key)
if f != null { return f }
}
return null
}

View file

@ -0,0 +1,79 @@
# render3d.ludic - ludic.ui drawn with ludic.render3d's overlay. A program that draws with render3d
# imports this and calls ui_render3d() once; its templates then draw in the overlay pass, sized in
# design pixels of a 1080-line screen (ui_render3d_scale sets the player's own interface size).
# <img src="path/to/picture.png"> a texture, loaded once
# <img src="icon:tent"> a cell of an atlas the program named with ui_atlas
# border-image: url(button.png) 14 a nine-slice background
module ludic_ui
import "render3d_image.ludic"
var r3u_user: float = 1.0
var r3u_tex_paths: []string = new []string
var r3u_tex_ids: []int = new []int
var r3u_atlas: []string = new []string
var r3u_atlas_tex: []int = new []int
var r3u_atlas_cols: []int = new []int
var r3u_atlas_names: [][]string = new [][]string
export function ui_render3d() -> void {
let b = new UiBackend
b.rect = fn r3u_rect
b.round = fn r3u_round
b.ring = fn r3u_ring
b.text = fn r3u_text
b.measure = fn r3u_measure
b.image = fn r3u_image
b.nine = fn r3u_nine
b.clip = fn r3u_clip
b.unclip = fn r3u_unclip
b.scale = fn r3u_scale
b.now = fn r3u_now
ui_backend(b)
}
# the player's interface size, 1.0 as designed
export function ui_render3d_scale(s: float) -> void { r3u_user = s }
# a grid atlas: `names` are its cells, row by row, `cols` to a row; <img src="prefix:name"> draws one
export function ui_atlas(prefix: string, tex: int, cols: int, names: []string) -> void {
push(r3u_atlas, prefix)
push(r3u_atlas_tex, tex)
push(r3u_atlas_cols, cols)
push(r3u_atlas_names, names)
}
function r3u_now() -> float { return float(Time.now_us() / 1000) / 1000.0 }
function r3u_scale() -> float { return float(gl_h) / 1080.0 * r3u_user }
function r3u_r(c: int) -> float { return float((c / 65536) % 256) / 255.0 }
function r3u_g(c: int) -> float { return float((c / 256) % 256) / 255.0 }
function r3u_b(c: int) -> float { return float(c % 256) / 255.0 }
function r3u_rect(x: float, y: float, w: float, h: float, c: int, a: float) -> void { ov_rect(int(x), int(y), int(w), int(h), r3u_r(c), r3u_g(c), r3u_b(c), a) }
# a rounded rectangle: a cross of two rects and a disc in each corner
function r3u_round(x: float, y: float, w: float, h: float, rad0: float, c: int, a: float) -> void {
let rad = Math.min(rad0, Math.min(w, h) / 2.0)
let r = r3u_r(c)
let g = r3u_g(c)
let b = r3u_b(c)
ov_rect(int(x + rad), int(y), int(w - rad * 2.0), int(h), r, g, b, a)
ov_rect(int(x), int(y + rad), int(rad), int(h - rad * 2.0), r, g, b, a)
ov_rect(int(x + w - rad), int(y + rad), int(rad), int(h - rad * 2.0), r, g, b, a)
ov_disc(x + rad, y + rad, rad, 12, r, g, b, a)
ov_disc(x + w - rad, y + rad, rad, 12, r, g, b, a)
ov_disc(x + rad, y + h - rad, rad, 12, r, g, b, a)
ov_disc(x + w - rad, y + h - rad, rad, 12, r, g, b, a)
}
# a rounded frame: four straight edges and a quarter arc at each corner
function r3u_ring(x: float, y: float, w: float, h: float, rad0: float, t: float, c: int, a: float) -> void {
let rad = Math.min(rad0, Math.min(w, h) / 2.0)
let r = r3u_r(c)
let g = r3u_g(c)
let b = r3u_b(c)
ov_rect(int(x + rad), int(y), int(w - rad * 2.0), int(t), r, g, b, a)
ov_rect(int(x + rad), int(y + h - t), int(w - rad * 2.0), int(t), r, g, b, a)
ov_rect(int(x), int(y + rad), int(t), int(h - rad * 2.0), r, g, b, a)
ov_rect(int(x + w - t), int(y + rad), int(t), int(h - rad * 2.0), r, g, b, a)
let q = 1.5707964
ov_arc(x + rad, y + rad, rad - t / 2.0, t, q * 2.0, q, 8, r, g, b, a)
ov_arc(x + w - rad, y + rad, rad - t / 2.0, t, q * 3.0, q, 8, r, g, b, a)
ov_arc(x + w - rad, y + h - rad, rad - t / 2.0, t, 0.0, q, 8, r, g, b, a)
ov_arc(x + rad, y + h - rad, rad - t / 2.0, t, q, q, 8, r, g, b, a)
}
function r3u_text(x: float, y: float, s: string, size: float, c: int, a: float) -> void { ov_text(int(x), int(y), int(size), s, r3u_r(c), r3u_g(c), r3u_b(c), a) }
function r3u_measure(s: string, size: float) -> float { return float(ov_text_w(int(size), s)) }
function r3u_clip(x: float, y: float, w: float, h: float) -> void { ov_clip(int(x), int(y), int(w), int(h)) }
function r3u_unclip() -> void { ov_unclip() }

View file

@ -0,0 +1,57 @@
# render3d_image.ludic - pictures for the render3d backend: a texture by path (loaded once, linear as
# the overlay wants, clamped at its edges), a cell of a named atlas, and a nine-slice for border-image
module ludic_ui
function r3u_tex(path: string) -> int {
for i in 0 .. len(r3u_tex_paths) {
if r3u_tex_paths[i] == path { return r3u_tex_ids[i] }
}
let t = tex_load(path, false)
if t != 0 {
gpu_tex_bind(GPU_TEX2D, t)
gpu_tex_param(GPU_TEX2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
gpu_tex_param(GPU_TEX2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
}
push(r3u_tex_paths, path)
push(r3u_tex_ids, t)
return t
}
function r3u_image(src: string, x: float, y: float, w: float, h: float, c: int, a: float) -> void {
var colon = -1
for i in 0 .. len(src) {
if colon < 0 and src[i] == 58 { colon = i }
}
if colon > 0 {
let prefix: string = src[0..colon]
let name: string = src[colon + 1..len(src)]
for k in 0 .. len(r3u_atlas) {
if r3u_atlas[k] == prefix { r3u_cell(k, name, x, y, w, h, c, a) }
}
return
}
let t = r3u_tex(src)
if t != 0 { ov_sub(t, int(x), int(y), int(w), int(h), 0.0, 0.0, 1.0, 1.0, r3u_r(c), r3u_g(c), r3u_b(c), a) }
}
function r3u_cell(k: int, name: string, x: float, y: float, w: float, h: float, c: int, a: float) -> void {
let names = r3u_atlas_names[k]
var idx = -1
for i in 0 .. len(names) {
if names[i] == name { idx = i }
}
if idx < 0 {
ui_err(`{r3u_atlas[k]}:{name}: the atlas has no such picture`)
return
}
let cols = r3u_atlas_cols[k]
let cx = idx % cols
let cy = idx / cols
let u0 = float(cx) / float(cols)
let v0 = float(cy) / float(cols)
let s = Math.min(w, h)
ov_sub(r3u_atlas_tex[k], int(x + (w - s) / 2.0), int(y + (h - s) / 2.0), int(s), int(s), u0, v0, u0 + 1.0 / float(cols), v0 + 1.0 / float(cols), r3u_r(c), r3u_g(c), r3u_b(c), a)
}
# border-image: the texture's `slice`-pixel corners kept, the rest stretched, drawn `dst` wide
function r3u_nine(src: string, slice: float, x: float, y: float, w: float, h: float, dst: float, c: int, a: float) -> void {
let t = r3u_tex(src)
if t == 0 { return }
ov_nine(t, tex_width(t), int(slice), int(x), int(y), int(w), int(h), int(dst), r3u_r(c), r3u_g(c), r3u_b(c), a)
}

View file

@ -0,0 +1,84 @@
# scroll.ludic - a box with overflow (or <scroll>) scrolls itself: the wheel over it, a scrollbar
# thumb dragged, and the focused control pulled into view. Its content is shifted by how far it is
# scrolled and clipped to it, and the offset is kept by its key from frame to frame.
var sc_keys: []string = new []string
var sc_offs: []float = new []float
var sc_drag: string = ""
function sc_get(key: string) -> float {
for i in 0 .. len(sc_keys) {
if sc_keys[i] == key { return sc_offs[i] }
}
return 0.0
}
function sc_put(key: string, v: float) -> void {
for i in 0 .. len(sc_keys) {
if sc_keys[i] == key {
sc_offs[i] = v
return
}
}
push(sc_keys, key)
push(sc_offs, v)
}
# every scroll box, outermost first: its offset (from input, when `live`), then its content moved
function sc_update(n: UiNode) -> void { sc_walk(n, true) }
function sc_apply(n: UiNode) -> void { sc_walk(n, false) }
function sc_walk(n: UiNode, live: bool) -> void {
if n.kind == UI_SCROLL {
let room = n.ch - ly_vin(n)
let most = Math.max(n.content_h - ly_vin(n) - room, 0.0)
var off = sc_get(n.key)
if live { off = sc_input(n, off, room, most) }
off = Math.clamp(off, 0.0, most)
sc_put(n.key, off)
n.scroll_y = off
if off != 0.0 {
for k in 0 .. len(n.children) { ly_offset(n.children[k], 0.0, 0.0 - off) }
}
}
for k in 0 .. len(n.children) { sc_walk(n.children[k], live) }
}
# the wheel over it, its thumb dragged, the keyboard's focus pulled into view
function sc_input(n: UiNode, off0: float, room: float, most: float) -> float {
var off = off0
let i = in_now
let over = i.x >= n.x and i.y >= n.y and i.x < n.x + n.cw and i.y < n.y + n.ch
if over and i.wheel != 0.0 { off = off - i.wheel * ui_px(40.0) }
if i.down and not in_was_down and over and i.x >= n.x + n.cw - ui_px(14.0) and most > 0.0 { sc_drag = n.key }
if not i.down { sc_drag = "" }
if sc_drag == n.key and room > 0.0 { off = (i.y - n.y) / room * (most + room) - room / 2.0 }
return sc_into_view(n, off, room)
}
# the focused control, moved by the keyboard, is brought into the box's view
function sc_into_view(n: UiNode, off: float, room: float) -> float {
if not fc_visible or fc_key == "" or not lc_mine(fc_key, n.key) { return off }
let f = fr_node(n, fc_key)
if f == null { return off }
let top = f.y - n.y - n.pt
if top < off { return top - ui_px(8.0) }
if top + f.ch > off + room { return top + f.ch - room + ui_px(8.0) }
return off
}
# the scrollbar: a thumb on a track down the box's right edge, when there is more than it shows
function sc_bar(n: UiNode) -> void {
let room = n.ch - ly_vin(n)
let total = n.content_h - ly_vin(n)
if total <= room or ui_be.rect == null { return }
let w = ui_px(6.0)
let x = n.x + n.cw - w - ui_px(2.0)
var track = n.bar_track
if track == -2 { track = 3355443 }
var thumb = n.bar_c
if thumb < 0 { thumb = 10066329 }
if track >= 0 { ui_be.rect(x, n.y, w, n.ch, track, 0.4 * fr_ga) }
let th = Math.max(n.ch * room / total, ui_px(24.0))
let ty = n.y + (n.ch - th) * n.scroll_y / (total - room)
ui_be.rect(x, ty, w, th, thumb, 0.85 * fr_ga)
}
# ui_place moved every box by its stored offset; the frame takes that back before moving it again
function sc_undo(n: UiNode) -> void {
if n.kind == UI_SCROLL and n.scroll_y != 0.0 {
for k in 0 .. len(n.children) { ly_offset(n.children[k], 0.0, n.scroll_y) }
}
for k in 0 .. len(n.children) { sc_undo(n.children[k]) }
}

View file

@ -64,6 +64,9 @@ function sel_attr_of(n: UiNode, key: string) -> string {
}
function sel_pseudo_ok(p: string, arg: string, n: UiNode) -> bool {
if p == "hover" { return n.hovered }
if p == "focus" { return fc_key != "" and n.key == fc_key }
if p == "focus-visible" { return fc_visible and fc_key != "" and n.key == fc_key }
if p == "focus-within" { return fc_key != "" and lc_mine(fc_key, n.key) }
if p == "disabled" { return not n.enabled }
if p == "enabled" { return n.enabled }
if p == "first-child" { return n.index == 0 }

View file

@ -43,6 +43,11 @@ function lss_block(f: UiFile, src: string, md: UiMedia, err: []string) -> void {
lss_import(f, lss_upto(r, 59))
continue
}
if rd_op(r, "@keyframes") {
let name = lss_upto(r, 123)
an_read(name, lss_upto(r, 125), err)
continue
}
if rd_op(r, "@media") {
let cond = md_parse(lss_upto(r, 123))
lss_block(f, lss_upto(r, 125), cond, err)

View file

@ -40,13 +40,15 @@ function tpl_read(x: Xml, file: string) -> UiTpl {
push(t.vals, tpl_value(v, err))
}
}
for i in 0 .. Xml.child_count(x) { push(t.kids, tpl_read(Xml.child(x, i), file)) }
if tp_mixed(x) { tp_read_mixed(t, x, file, err) } else {
for i in 0 .. Xml.child_count(x) { push(t.kids, tpl_read(Xml.child(x, i), file)) }
}
if t.tag == "style" {
t.raw = Xml.text(x)
return t
}
let words = tpl_words(Xml.text(x))
if len(words) > 0 and len(t.kids) == 0 { t.text = ex_text(words, err) }
if len(words) > 0 and Xml.child_count(x) == 0 { t.text = ex_text(words, err) }
for i in 0 .. len(err) { ui_err(`{t.where}: <{t.tag}> {err[i]}`) }
return t
}

View file

@ -17,3 +17,28 @@ function tp_line(at: int) -> int {
return lo + 1
}
# text beside elements (<button><img/>Resume</button>): each run of it a text of its own, in order
function tp_mixed(x: Xml) -> bool {
if Xml.child_count(x) == 0 or x.tag == "style" { return false }
for i in 0 .. len(x.mixed) {
if x.mixed[i].tag == "#text" { return true }
}
return false
}
function tp_read_mixed(t: UiTpl, x: Xml, file: string, err: []string) -> void {
for i in 0 .. len(x.mixed) {
let m = x.mixed[i]
if m.tag == "#text" {
let r = new UiTpl
r.tag = "#text"
r.where = t.where
r.keys = new []string
r.vals = new []UiExpr
r.on_keys = new []string
r.on_acts = new []UiAct
r.kids = new []UiTpl
r.text = ex_text(tpl_words(m.text), err)
push(t.kids, r)
} else { push(t.kids, tpl_read(m, file)) }
}
}

View file

@ -0,0 +1,31 @@
# transition.ludic - `transition: opacity 0.2s`: when an element's opacity changes from one frame to
# the next it eases to the new value over that time instead of jumping (opacity is the property a
# game fades most; the others arrive as they are)
var tr_keys: []string = new []string
var tr_from: []float = new []float
var tr_to: []float = new []float
var tr_at: []float = new []float
function tr_apply(n: UiNode) -> void {
if n.tr_dur <= 0.0 { return }
let now = an_now()
let want = n.alpha
for i in 0 .. len(tr_keys) {
if tr_keys[i] == n.key {
if tr_to[i] != want {
tr_from[i] = tr_value(i, now, n.tr_dur)
tr_to[i] = want
tr_at[i] = now
}
n.alpha = tr_value(i, now, n.tr_dur)
return
}
}
push(tr_keys, n.key)
push(tr_from, want)
push(tr_to, want)
push(tr_at, now)
}
function tr_value(i: int, now: float, dur: float) -> float {
let t = Math.clamp((now - tr_at[i]) / dur, 0.0, 1.0)
return tr_from[i] + (tr_to[i] - tr_from[i]) * t
}

View file

@ -0,0 +1,4 @@
# ua.ludic - what every element looks like before a program styles it, as a browser's own sheet
# does: headings sized, buttons and controls usable and plain, a focus ring. A theme overrides any
# of it, with the same selectors or with the variables it reads.
const UA_LSS: string = "h1 { font-size: 32 } h2 { font-size: 26 } h3 { font-size: 22 } h4 { font-size: 19 } h5 { font-size: 16 } h6 { font-size: 14 } small { font-size: 13 } hr { height: 1; width: fill; background: #808080; margin: 4 0 } scroll { width: fill; height: fill } img { width: 24; height: 24 } button { align-items: center; justify-content: center; gap: 8; padding: 6 14; background: #3a3f46; border-radius: 4 } button:hover { background: #4a5058 } button:disabled { opacity: 0.4 } :focus-visible { outline: 2px solid #ffcc55; outline-offset: 2 } input, select { align-items: center; gap: 10; padding: 6 10 } .ui-label { flex-grow: 1 } .ui-track { width: 44; height: 22; padding: 3; background: #555a60; border-radius: 11 } .ui-knob { width: 16; height: 16; background: #eeeeee; border-radius: 8 } input:checked .ui-track { justify-content: end; background: #d9a54a } input[type=range] .ui-track { width: 200; height: 8; padding: 0; border-radius: 4 } .ui-fill { height: fill; background: #d9a54a; justify-content: end; align-items: center; border-radius: 4 } .ui-thumb { width: 14; height: 14; background: #eeeeee; border-radius: 7; margin-right: -7 } .ui-value { min-width: 40 } .ui-field { width: 200; padding: 4 8; background: #202428; border: 1px solid #555a60 } .ui-caret { width: 2; height: 1.2em; background: #eeeeee } .ui-prev, .ui-next { color: #aaaaaa }"

View file

@ -22,6 +22,7 @@ property Xml {
avals: []pointer # attribute values (entity-decoded)
kids: []Xml # child elements, in document order
at: int = 0 # where its '<' is in the text, for an error that names a line
mixed: []Xml # its elements and its runs of text ("#text"), in document order
}
function xml_new(tag: pointer) -> Xml {
@ -31,6 +32,7 @@ function xml_new(tag: pointer) -> Xml {
n.akeys = new []pointer
n.avals = new []pointer
n.kids = new []Xml
n.mixed = new []Xml
return n
}
@ -276,18 +278,39 @@ function xp_element(p: XP) -> Xml {
p.i += 3 # past "]]>"
} else {
if xp_skip_misc(p) { } # comment / PI inside content
else { push(node.kids, xp_element(p)) }
else {
let kid = xp_element(p)
push(node.kids, kid)
push(node.mixed, kid)
}
}
} else {
# character data run up to the next '<'
let start = p.i
while p.i < p.n and p.s[p.i] != '<' { p.i += 1 }
node.text += xml_unescape(p.s[start..p.i])
let run = xml_unescape(p.s[start..p.i])
node.text += run
xp_text_run(node, run)
}
}
return node
}
# a run of text among an element's children, kept in order unless it is only white space
function xp_text_run(node: Xml, run: string) -> void {
var blank = true
var i = 0
while i < len(run) {
let c = run[i]
if not (c == ' ' or c == '\t' or c == '\n' or c == '\r') { blank = false }
i += 1
}
if blank { return }
let t = xml_new("#text")
t.text = run
push(node.mixed, t)
}
# parse a whole document -> its root element (or the synthetic empty node).
function xml_parse(s: pointer) -> Xml {
let p = new XP

View file

@ -757,6 +757,8 @@ function cmd_dev_test() -> int {
controller_case("library/ui_reload", "", "ui: templates reloaded note 1 | reloaded 0 then 1 | note again 1", "ui_reload.ludic (L11: a template changed on disk is read again, and its state kept)")
controller_case("library/ui_component", "", "counters|a: 1 (2)|+|reset|b: 10 (20) big|+|reset|outside| b margin 9; b text 16711680; outside -1; button 30 b: 0 (0)", "ui_component.ludic (L11: components as NAME.ludic + .xml + .lss - props, state, fields, events, scoped styles, @import, class passed down)")
reject_case("rejected/component_no_template", "there is no Lonely.xml beside it", "a component needs its template beside it")
controller_case("library/ui_controls", "", "1 false 7 2 Adalove 70 1 false 8 2 Adalove 70; scrolled 40", "ui_controls.ludic (L11: focus and keys, the built-in controls, a range dragged, a box scrolled)")
controller_case("library/ui_anim", "", "0 100 100 | 49 50 100 | 100 100 61", "ui_anim.ludic (L11: @keyframes and animation, looping and alternating, and a transition)")
reject_case("rejected/view_untyped", "view Sums: say what type total is", "a view field that is not a bare global says its type")
reject_case("rejected/view_without_ui", "a view is shown by ludic.ui", "a view needs ludic.ui to show it")