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:
parent
20d011f8e9
commit
1dad96102a
45 changed files with 1386 additions and 218 deletions
62
LANGUAGE.md
62
LANGUAGE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
27
examples/library/ui_anim.ludic
Normal file
27
examples/library/ui_anim.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
71
examples/library/ui_controls.ludic
Normal file
71
examples/library/ui_controls.ludic
Normal 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)}`)
|
||||
}
|
||||
}
|
||||
4
examples/library/ui_controls_parts/Settings.lss
Normal file
4
examples/library/ui_controls_parts/Settings.lss
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.page { width: 400px; gap: 4px }
|
||||
input, select { width: fill; height: 30px }
|
||||
.list { overflow: auto; height: 60px }
|
||||
p { height: 20px }
|
||||
10
examples/library/ui_controls_parts/Settings.ludic
Normal file
10
examples/library/ui_controls_parts/Settings.ludic
Normal 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 }
|
||||
}
|
||||
12
examples/library/ui_controls_parts/Settings.xml
Normal file
12
examples/library/ui_controls_parts/Settings.xml
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
89
packages/ludic.ui/anim.ludic
Normal file
89
packages/ludic.ui/anim.ludic
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
86
packages/ludic.ui/attr_edge.ludic
Normal file
86
packages/ludic.ui/attr_edge.ludic
Normal 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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
58
packages/ludic.ui/color.ludic
Normal file
58
packages/ludic.ui/color.ludic
Normal 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
|
||||
}
|
||||
91
packages/ludic.ui/controls.ludic
Normal file
91
packages/ludic.ui/controls.ludic
Normal 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
|
||||
}
|
||||
86
packages/ludic.ui/controls_build.ludic
Normal file
86
packages/ludic.ui/controls_build.ludic
Normal 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")))
|
||||
}
|
||||
24
packages/ludic.ui/dev.ludic
Normal file
24
packages/ludic.ui/dev.ludic
Normal 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) }
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
packages/ludic.ui/draw_order.ludic
Normal file
48
packages/ludic.ui/draw_order.ludic
Normal 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]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}` }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
59
packages/ludic.ui/focus.ludic
Normal file
59
packages/ludic.ui/focus.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
62
packages/ludic.ui/input.ludic
Normal file
62
packages/ludic.ui/input.ludic
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
79
packages/ludic.ui/render3d.ludic
Normal file
79
packages/ludic.ui/render3d.ludic
Normal 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() }
|
||||
57
packages/ludic.ui/render3d_image.ludic
Normal file
57
packages/ludic.ui/render3d_image.ludic
Normal 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)
|
||||
}
|
||||
84
packages/ludic.ui/scroll.ludic
Normal file
84
packages/ludic.ui/scroll.ludic
Normal 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]) }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
packages/ludic.ui/transition.ludic
Normal file
31
packages/ludic.ui/transition.ludic
Normal 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
|
||||
}
|
||||
4
packages/ludic.ui/ua.ludic
Normal file
4
packages/ludic.ui/ua.ludic
Normal 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 }"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue