diff --git a/.forgejo/workflows/bootstrap.yml b/.forgejo/workflows/bootstrap.yml index 926e4922..a41bb6e5 100644 --- a/.forgejo/workflows/bootstrap.yml +++ b/.forgejo/workflows/bootstrap.yml @@ -35,7 +35,7 @@ jobs: git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}" git log --oneline -1 # See ci.yml for why the Linux build injects the stdio shim via LUDIC_CC. - echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV" + echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm" >> "$GITHUB_ENV" echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV" - name: Bootstrap x from the seed diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index afd5c567..04dd75a3 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: # LUDIC_CC so every clang invocation — the seed bootstrap, `ludic-dev build`, # and each compiled test program — picks it up. Absolute path so it # still resolves if a step changes directory. - echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV" + echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm" >> "$GITHUB_ENV" echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV" - name: Bootstrap the toolchain from the IR seed (clang only) diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml index fd8f5f20..b606bf3e 100644 --- a/.forgejo/workflows/docs.yml +++ b/.forgejo/workflows/docs.yml @@ -63,7 +63,7 @@ jobs: # tiny C-free IR shim supplying the Darwin stdout/stderr globals over # glibc's, injected through LUDIC_CC. docs-gen is a pure CLI (no # windowing), so the C-free bootstrap is all it needs. - export LUDIC_CC="clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" + export LUDIC_CC="clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm" export LUDIC_HOME="$(pwd)" mkdir -p bin clang-16 tools/ci/linux_stdio_shim.ll selfhost/ludicc.seed.ll -o bin/ludicc diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 45e928e8..1e323525 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -49,7 +49,7 @@ jobs: git checkout "$TAG" echo "TAG=$TAG" >> "$GITHUB_ENV" # See ci.yml for why the Linux build injects the stdio shim via LUDIC_CC. - echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll" >> "$GITHUB_ENV" + echo "LUDIC_CC=clang-16 $(pwd)/tools/ci/linux_stdio_shim.ll -lm" >> "$GITHUB_ENV" echo "LUDIC_HOME=$(pwd)" >> "$GITHUB_ENV" - name: The tag, VERSION and CHANGELOG must agree diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..759f9796 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +packages/*/lib/** filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index ed4671a4..ec21dc03 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ tools/editors/vscode/node_modules/ tools/editors/vscode/*.vsix tools/editors/jetbrains/.gradle/ tools/editors/jetbrains/build/ +tools/editors/jetbrains/.kotlin/ # IntelliJ plugin SDK sandbox (tools/editors/jetbrains) .intellijPlatform/ @@ -47,3 +48,15 @@ __pycache__/ # archive lands in the root and a blanket `git add -A` will commit it. *.tar.gz *.tgz + +# The CC0 Poly Haven downloads are fetched, not committed (`ludic-dev fetch-assets` +# reads the manifest that ships with the renderer, packages/ludic.render3d/assets.manifest, +# so a game outside this repository fetches the same set with `ludic assets`). +assets/polyhaven/hdri/ +assets/polyhaven/textures/ +assets/polyhaven/models/ +# `ludic run` beside an example writes its binary into a build/ there +examples/**/build/ + +# a package native/build.sh writes its objects under the package (phase 15) +packages/*/build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d7caa933..33ed5947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,878 @@ change type. Preview the next one with `ludic dev release --dry-run`. A released section may carry a hand-written summary paragraph above its groups (v0.2.0 has one); the generated bullets below it are not edited by hand. +## v0.22.0 — 2026-09-18 + +### Features + +- **Every key on the keyboard is bindable** — the F-row, Home/End/PageUp/PageDown, + Insert/Delete, Caps Lock and the numpad reach a game as codes 132-166. + + Until now the platform gave them no code at all, so a game's rebinding screen could + not take one and nothing said why. Windows asked the active layout what they type and + got nothing back (`w_vk_char` answers 0 for a key with no character); macOS let them + fall through to `charactersIgnoringModifiers`, which reports `NSF1FunctionKey` and its + neighbours at 0xF704 and up - outside the 256-bit held set either way. + + - **The codes are the same on both platforms**: 132-143 are F1-F12, 144-149 are Home, + End, PageUp, PageDown, Insert and Delete, 150 is Caps Lock, 152-161 are the numpad + digits and 162-166 its `*`, `+`, `-`, `.` and `/`. The numpad's Enter is Enter. + - **`Key.F1`, `Key.Home`, `Key.Numpad0`** and the rest fold at compile time like the + other named keys. + - **`Input.key_label`** names them - "F1", "Home", "CapsLock", "Num 0", "Num /" - and + does *not* ask the layout, because a key that types nothing is called the same thing + on every layout. +## v0.21.1 — 2026-09-17 + +### Fixes + +- A windowed game opens under its package's `app name`. `ludic build`, `ludic run` and + `ludic bundle` pass it to the compiler (`ludicc --title `), so `game_title()` - the + window's first title - is the name the player knows rather than the `program` name, which + showed for up to two seconds before the renderer retitled the window. Without an `app name` + it is the `program` name, as before. +- `Crypto.random_bytes`, `Crypto.random_hex`, `Crypto.random_u32` and `Uuid.*` draw from the + system CSPRNG on Windows (`RtlGenRandom`, advapi32). They read `/dev/urandom`, which Windows + does not have, and every byte came back zero. +## v0.21.0 — 2026-09-17 + +### Features + +- **A launcher's runtime** — start a program, step aside while it runs, and download large + files with a progress bar. + + - **`Process.*`** — `Process.spawn(path, args)` starts a program directly (no shell) with this + process's environment and working directory and returns at once; `Process.poll(h)` is `-1` + while it runs, then its exit code (`128 + signal` for a signal death, `137` after + `Process.kill`); `Process.free` lets a handle go. `posix_spawn` on macOS + (`runtime/native/process.ll`); `CreateProcessW` on Windows (`process_win.ll`), with each + argument quoted by the MSVC rules and no console window. Linked only into a program that + uses `Process.*`. + - **`Http.save_to(h, path)`** — before `Http.send`, streams the response body straight into a + file (written at `path` itself, created on send) instead of memory; `Http.text` of such a + request is empty and `Http.body_len` is the bytes written. **`Http.received(h)`** and + **`Http.expected(h)`** (the `Content-Length`, or `-1`) are live while it is pending. macOS + streams through an `NSURLSession` with a delegate built at run time, Windows through the + WinHTTP read loop. Freeing a pending request now cancels it and parks its slot until the + worker has let go, rather than freeing what the worker is still writing. + - **`App.window_hide()` / `App.window_show()`** — take the game's window off the screen and + bring it back without closing it or its GL / Vulkan surface; the run goes on while it is + hidden. No-ops headless and before there is a window. + +### Fixes + +- A game's window takes the title it asks for. The runtime opens a game's window before `main`, + titled with the `program` name, and `Gl.open` / render3d's `gl_open` and `gvk_open` attached + to it without passing their title on, so a `program Valley` that opened "Maroon Lake" showed + "Valley". `win_set_title` (cocoa.ll `setTitle:`, win32.ll `SetWindowTextW`, UTF-8 to UTF-16) + now retitles it, and a second `win_open` on macOS retitles the open window instead of making + another, as it already did on Windows. +## v0.20.0 — 2026-09-17 + +### Features + +- The JetBrains plugin (1.6.0) has a Ludic tool window for the package, like the Gradle one: + - an overview of the package (what it is, entry, version, dependencies, scripts, hooks, app); + - its commands, scripts and hooks, run by double-click, with each command's hooks shown; + - its dependencies against package.lock.ludic: the locked version, whether it is fetched + and linked, the indirect ones, and Fetch, Update, Verify, Vendor, Add and Remove; + - the app preview the `app` lines describe, and the asset roots with missing ones marked. + The bar over package.ludic now appears only when something needs doing (fetch, or no + toolchain), instead of carrying every command. +## v0.19.0 — 2026-09-17 + +### Features + +- `Udp.*`: polled IPv4 datagrams - the transport under a game's own netcode. `Udp.open(port)` + binds a non-blocking socket (port 0 picks one, `Udp.port` says which), `Udp.send` sends one + datagram to an address and port, and `Udp.recv` drains what arrived without ever blocking; + `Udp.from_ip` / `Udp.from_port` name the sender. `Udp.ip` / `Udp.ip_text` convert addresses, + `Udp.resolve` looks a host name up and `Udp.local_ip` is the address a player on the same + network would dial. BSD sockets on macOS (`runtime/native/udp.ll`), Winsock on Windows + (`udp_win.ll`, linked with ws2_32), linked only into a program that uses `Udp.*`. +## v0.18.1 — 2026-09-16 + +### Fixes + +- The release build links libm on Linux, which `float`/`double` code needs (glibc keeps + `sinf`, `sqrtf` and the rest there). 0.18.0's release job stopped on this before + publishing, so 0.18.1 is the first published build with float types, barrel imports, + package scripts and hooks, and the JetBrains 1.5.0 support. +## v0.18.0 — 2026-09-16 + +### Features + +- Run a single test by name: a compiled test program takes the test's exact name as its + first argument (`./tests "adds"`), and `ludic test` gains `--test NAME` to pass it through. + A name that matches no test is reported and fails the run. `ludic test --verbose` (`-v`) + prints every test's `ok`/`FAIL` line, framed by `RUN ` … `PASS|FAIL `, which is + what editor test views parse. +- The JetBrains plugin covers much more of the IDE: + - Run configurations for `ludic run`/`build`/`test` (plus any other `ludic` command), with + gutter run buttons on `program` and on every `test "name"` block. + - Test results appear in the IDE's test tree, with navigation back to each test block. + - `file.ludic:line` locations are clickable in every console. + - Smart indentation on Enter and when typing a closing bracket; quotes pair up. + - A Color Scheme page and a Code Style page. + - Live templates, New Ludic File templates, and a Ludic project in File | New | Project. + - A Tools | Ludic menu, and a warning when the toolchain is missing. + - `package.ludic` is treated as a package manifest: + - a banner offers Fetch, Update, Verify, Run, Test, Build and Bundle, and warns when the lock is + missing or older than the manifest; + - gutter actions on `package` and `require` lines; + - completion and quick documentation for directives and `app` keys; + - its own icon. + - `script` and `hook` lines complete and are validated; each script has a run button, the + package banner has script shortcuts, and Tools | Ludic | Run Script… lists them all. + - The toolchain's and the project's packages are listed under External Libraries. + - The Structure view, parameter info and code-block navigation now come from `ludic-lsp`. + - The plugin is now 1.5.0 and requires IntelliJ Platform 2024.2 or newer and LSP4IJ 0.21. + - Fixed: Enter no longer inserts an extra `}`, and Comment Line no longer writes two spaces. +- `float` and `double`: IEEE floating point with ordinary operators, so renderer math reads + `a * b + c` instead of `f_add(f_mul(a, b), c)`. + - Decimal literals take their type from context (`let s: float = 0.1` is exactly 0.1), and + stay `fixed` elsewhere, so existing programs keep their meaning. + - `int`/`long` promote; `float(x)`, `double(x)`, `int(x)`, `long(x)` and `fixed(x)` convert. + - `floats(n)` / `doubles(n)` buffers; float fields, globals, constants and parameters. + - `Math.*` computes in float when given one; `string`/`print`/interpolation write the + shortest round-tripping decimal; `float_bits` / `float_from_bits` expose the IEEE pattern. + - `@deterministic` code may not use floats (it is now checked). +- `import "camp"` imports a directory through its barrel, `camp/index.ludic` (a fragment + listing the directory's own imports). A directory without one is a clear error. Packages + resolve the same way: `import "ludic.render3d"` reads `ludic.render3d/index.ludic`. +- `ludic-lsp` navigates like the compiler resolves: + - Package imports (`import "ludic.render3d/r3d.ludic"`) resolve from `ludic_modules/` and the + toolchain's `packages/`, so their names hover, complete and jump. Import strings are links. + - `var` locals are tracked (declaration, usages, rename). + - Hover shows the declared or inferred type of locals, parameters and globals. + - Hover and go-to-definition on built-ins, built-in types and namespaces show the reference + page, with a link to the online docs. Release archives now ship `docs/language` for this. + - Go to type declaration (`textDocument/typeDefinition`) is supported. + - Signature help carries per-parameter ranges and follows named arguments. + - Completion inside a call offers the parameter names not given yet, `Namespace.` offers its + members, and results are ranked: locals first, then globals, built-ins and keywords. +- `package.ludic` gains `entry`, scripts and lifecycle hooks: + - `entry "src/game.ludic"` names the program `ludic run`/`build`/`bundle` compile. Without + it, the one file under `src/` that declares a `program` is used. + - `script "dev" "ludic run --headless"` defines a command: `ludic dev` (or `ludic script dev`) + runs it with any extra arguments, and `ludic scripts` lists them. + - `hook before|after "…"` wraps `build`, `run`, `test`, `bundle`, `pack`, `get`, … + or a script. A failing `before` hook stops the command; `after` hooks run only on success. + - Quoted manifest values accept `\"` and `\\` escapes. + +### Fixes + +- **`==` / `!=` on records is identity again** — only two strings compare by content. + + Every pointer-typed comparison used to be lowered to a C-string content compare, + so two distinct records (properties, slices, enums) compared their bytes up to + the first zero byte: `a == b` could be true for different objects that shared a + leading field, depending on layout. References now compare by identity + (`icmp eq ptr`); `string` (and untyped `pointer`/`pointers` text) still compares by content, + and comparing a `string` with a non-string reference is a compile error. +## v0.17.0 — 2026-09-16 + +### Features + +- **Renderer switches stay out of shipped games** — every `R3D_*` environment switch in + `ludic.render3d` (debug views, feature kills, file writers, hardware fakes, the Windows + feature overrides) now goes through one gate: a headless build honours them as before, a + windowed build only when `R3D_DEV` is set to anything but `0`. See `docs/SHIPPING.md`. + +### Fixes + +- **A lake can be the water that mirrors the world** — a reflecting body is clipped to its + ellipse like any other unless it is an unbounded sea, so a map whose reflection belongs to + its lake no longer floods every hollow in the survey with that lake's level; and the + terrain's wet shore, forest and scree gates read the carved lake's line inside its outline + (`u_lake`), the rule the grass already used, so a lake above the sea keeps its wet bank and + no forest is painted down its bed. + + `terrain_lake_carve(false)` keeps a lake's line, outline and shore but skips carving its + bed, for a height map that already carries a shaped one. +## v0.16.2 — 2026-09-15 + +### Fixes + +- **Keys are physical positions on Windows and macOS, whatever the layout** - `Input.key_down('w')` + is the key above S on every keyboard. + + - **Windows** keyed the held set by the character the active layout gave a virtual key, so + a game's WASD belonged to whatever the layout put there: on AZERTY W and A were other + keys, and with an input method on (Chinese, Japanese, Korean) every letter arrived as + VK_PROCESSKEY and no letter key worked at all. The typing block is now read from the + scancode; the arrows, the numpad and the F-keys still go by virtual key, and the frame + key (`Input.key`, typing) keeps the layout's character. + - **macOS** did the same through `charactersIgnoringModifiers`; it now reads `keyCode`. + - **`Input.key_label(key)`** names a key for the player in their own layout - `'w'` shows + as "W" on QWERTY and "Z" on AZERTY - so a game that shows its bindings never shows a + key the player cannot find. Windows reads the layout; headless, macOS and the browser + give the US character. +## v0.16.1 — 2026-09-15 + +### Features + +- **HDR calibration, and two HDR fixes** — the HDR10 picture follows the player's display instead of one fixed curve. + + - **`r3d_hdr_calibrate(peak, paper, black)`** — nits as float bits: the brightest the display shows (100-10000), + where the picture's and the interface's white sit (80-1000, never above the peak) and how far the darkest shade is + lifted (0-5, fading out by paper white). The tonemap's and the overlay's HDR10 variants read them, and the display's + HDR metadata is sent again with the new peak. The defaults are the old constants: 1000, 200 and 0. + - **`ov_hdr_nits(nits)` / `ov_hdr_paper()`** — what the overlay draws next is at that many nits on an HDR10 frame, + for a calibration screen's test patches; it closes the batch so far. No effect on an SDR frame. + - **Fixed: a crash toggling HDR on the Vulkan renderer.** `gpu_caps_probe()` made and destroyed a second Vulkan + instance under NVIDIA Streamline's interposer; the next swapchain rebuild then called through a pointer that + instance had left behind, at address 0. With the Vulkan renderer running, the probe asks its instance instead. + - **Fixed: yellow read as red in HDR.** The overlay drew sRGB values straight into the HDR10 swapchain (it now has an + HDR10 variant, chosen while `gpu_hdr_active()`), and the tonemap extended highlights per channel, which boosted a + bright yellow's red far more than its green; it now brightens the graded colour by one factor. +## v0.16.0 — 2026-09-15 + +### Features + +- **A loading screen can show the renderer's start-up as it happens.** `r3d_open(w, h, title)` opens + the window and the graphics backend with nothing baked, and `r3d_load_count()` / `r3d_load_step(i)` + run the rest - the sky and its light, the terrain, the shadow and screen targets, the scattered cover + and actors, the grass - one step at a time, so a game can draw and present a frame between them. + `r3d_init` is those same calls in a row and is unchanged for everything that uses it. +- **HDR10 output on the Vulkan renderer** — `r3d_hdr(on)` asks for an ST 2084 / BT.2020 swapchain where the + display offers one. + + The tonemap's HDR10 variant keeps the SDR picture up to a 200-nit paper white and rolls highlights on to + 1000 nits; the overlay converts the interface to the same white; HDR metadata is set where the loader has + the command. The screen and LDR images are 10-bit while it is on, and screenshots refuse rather than write + a PQ image as SDR. OpenGL and the Vulkan SDR frame are unchanged. `R3D_HDR` overrides the setting. +- **Mesh-shader grass** — on a card with `VK_EXT_mesh_shader`, `r3d_mesh_grass(on)` draws each grass tile as one + mesh-shader dispatch sized to that tile's blades, and a blade that is culled emits no vertices at all. + + - **`ludic-dev shaders`** — a variant whose first file is `*.mesh` compiles that stage as a Vulkan 1.3 mesh + shader; its SPIR-V keeps the `.vert` name, so the manifest is unchanged. + - **The seam** — `gpu_has_mesh()` and `gpu_draw_mesh_tasks(x, y, z)`; a mesh program's pipeline has no + vertex input and its bindings the mesh stage. The device enables `meshShader` and `maintenance4`. + + Not yet faster: at 4K on an RTX 3070 Ti the grass pass takes 5.0 ms against the chunked path's 1.7, so + `gpu_feature_implemented(GF_MESH_GRASS)` stays false and a game should not offer it as a finished setting. +- **More of the renderer's quality is a switch a game can offer.** + + - `sky_set_quality(width)`: the prefiltered sky light at 256, 512 or 1024 wide (half as tall), + baked again at once. + - `post_set_msaa(samples)`: 1, 2 or 4 samples for the scene on OpenGL, remaking the screen targets in + place (`post_msaa_live()` is false on Vulkan, which has no sample counts yet); `post_free` now + frees the multisampled framebuffer too. + - `STREAM_BUDGET_US` is a variable: the microseconds a frame may spend generating streamed cover. + - `r3d_fog_scale`: a multiplier over the fog density the daylight and the weather set. +- **NVIDIA DLSS super resolution and Reflex on the Vulkan renderer** — through NVIDIA Streamline 2.14.1. + + - **The loader** — on Windows `Vk.open()` takes `sl.interposer.dll` beside the executable in place of + `vulkan-1.dll` when a program asks (`Vk.sl_prefer`), falling back to plain Vulkan without it; the `sl*` + exports and feature functions are reachable from Ludic. + - **render3d** — `r3d_dlss(mode)` upscales the lit HDR frame before bloom and the tonemap, jittered on a + Halton cycle, with preset K in every mode (the default M cost 18 ms a frame at 4K on an RTX 3070 Ti); + `r3d_dlss_render_w/h` give the size to render at. `r3d_reflex(mode)` sleeps each frame and marks + simulation, submit and present. `R3D_DLSS`, `R3D_REFLEX`, `R3D_DLSS_PRESET`, `R3D_SL_LOG` for tests. + - **Commands the interposer lacks** — it exports no `vkSetHdrMetadataEXT`, `vkCmdDrawMeshTasksEXT` or + acceleration-structure command; `Vk.has` checks one and `vkGetDeviceProcAddr` reaches it. +- **Real OS threads behind `Job.*` and `Sync.*`** — `Job.parallel_for` runs work across every core. + + - **`fn name`** — an expression naming a top-level function as a value, for a worker's entry point. + No closures: the data a worker needs is passed in. + - **`Job.parallel_for(count, fn work, ctx)`** — `work(i, ctx)` for every `i` in `[0, count)`, split + into chunks across a worker pool (one thread per core but one, pthreads or Win32) and the calling + thread; returns when all are done. `Job.is_worker()` says whether code is on a pool thread. + - **`Sync.*` is thread-safe** — mutexes are real mutexes, atomics are compare-and-swap, channels are + guarded, and `Sync.cpu_count()` reports the machine's cores. + - **A worker must not change the world** — `spawn` and `despawn` on a pool thread stop the program with + a located message. +- **Texture filtering and shadow resolution as settings a game can change while it runs.** + + - `r3d_set_anisotropy(level)` sets the anisotropic filtering of every mipmapped texture already + loaded, not only of later uploads, on OpenGL and Vulkan. + - `shadow_set_res(size)` remakes the cascades' depth layers at 1024, 2048 or 4096 (`shadow_res` + replaces the `SHADOW_RES` constant); the lighting reads the texel size from the map. +- **`app native ""` in `package.ludic`** — `ludic bundle` on Windows copies that directory's files beside + the executable: native libraries loaded at run time (NVIDIA Streamline's DLLs, say) and their licence texts, + which cannot live in the pack. + +### Fixes + +- **The meadow's grass draws on the Vulkan renderer again** — the chunked grass path uploaded its `vec4` tile + table with `u_fv`, which on Vulkan copies one float per array element, so every tile had no blades. + `u_f4v` uploads arrays of `vec4`. + +### Performance + +- **The sky's light is baked once at start, not twice.** A game that turns the sky at boot sets + `sky_start_yaw` before `r3d_init`, and `sky_load` bakes the image-based light at that yaw; turning to + a yaw the light is already baked at (`sky_set_yaw`) no longer bakes it again. Without it, nothing + changes. +## v0.15.0 — 2026-09-15 + +### Features + +- Vulkan for Ludic, and the start of a second renderer beside OpenGL. + + - **`Vk.*`**: every command of Vulkan 1.0-1.4 and of the extensions a modern renderer is + built around (swapchain, HDR colour spaces, ray query and acceleration structures, opacity + micromaps, mesh shaders, variable rate shading, memory budget, pipeline libraries, NVIDIA + low latency, portability for MoltenVK), with every constant and every struct's size + (`_sizeof`) and field offsets (`_`). Generated from the Vulkan + registry by **`ludic-dev vkgen`** into `runtime/native/vk_api.ludic` and `vk_thunks.ll`; + structs are plain memory filled by name with `Vk.put_i32` / `put_i64` / `put_ptr`. Every + size and offset was checked against the SDK's C headers (3128 facts). A C float is float + bits in an int; 64-bit values and non-dispatchable handles are `long`. + - **The loader is opened at run time**, never linked: `vk_win.ll` (`vulkan-1.dll`) and + `vk_mac.ll` (`libvulkan.1.dylib`, MoltenVK). `Vk.open()` returning 0 means no Vulkan, and + the program carries on. `ludicc` and `ludic build` link both files for any program that + uses `Vk.*`. + - `examples/rendering/vk_probe.ludic` reports what a machine's Vulkan can do; + `vk_compute.ludic` runs a Slang compute shader (`vk_compute.slang`) and reads the picture + back - on an RTX 3070 Ti and on an M4 Pro through MoltenVK, clean under the validation layer. + - **render3d `gpu.ludic`**: the seam between the renderer and a graphics API. Render state + and uniforms go through `gpu_*` / `u_*` and nowhere else (OpenGL frames unchanged); + `R3D_GFX=gl|vk` or `gpu_request` chooses a backend, falling back to OpenGL with a reason; + `gpu_caps_probe()` detects, on Windows, the Vulkan 1.3 floor, ray tracing, mesh shaders, + the NVIDIA RTX generation, Reflex and HDR, for a settings screen to grey out what a machine + cannot use (`R3D_CAPS=rtx50|rtx40|rtx30|amd|intel|none` pretends, for tests). +## v0.14.2 — 2026-09-14 + +### Fixes + +- **`Input.mouse_dx` / `mouse_dy` no longer jump on the first frame or when the cursor mode changes.** + The delta was the position minus the previous frame's, and the previous position started at 0,0, + so the first frame reported the cursor's whole distance from the corner as motion, and switching + between a locked cursor's virtual reticle and the real cursor did the same. A camera that adds + `mouse_dy` to its pitch came up pointing at the ground with nobody touching the mouse. Both frames + now report no motion; every other frame is unchanged, windowed or headless, on macOS and Windows. +## v0.14.1 — 2026-09-13 + +### Fixes + +- **A rim from `outline_model` no longer stays on after the caller stops asking** — the queue + was only emptied by the next `outline_model` call, so a game that highlights what the + crosshair is on left the last highlighted tree outlined for good once the player looked + away. `r3d_frame` now drops a batch nobody reopened this frame (`outline_frame`). +## v0.14.0 — 2026-09-13 + +### Features + +- **Overlay text is UTF-8, and a font atlas can carry any script** — `ludic.render3d`'s `ov_text`, + `ov_text_w` and `ov_text_wrap` decode UTF-8 instead of drawing bytes 32–126, so a game can show + Turkish, German, Polish, Greek, Cyrillic or whatever its atlas holds. + + - `font.json` may list the atlas's code points in `"codes"` (atlas order). An atlas without it is + read as before: consecutive code points from `"first"`, so existing ASCII fonts keep working. + - A code point the atlas lacks draws as `?`; a control character as a space; a malformed or + cut-off sequence as one `?`, so a string sliced mid-character still draws. + - `overlay_font(dir)` loads or swaps the atlas at run time, for a language that brings its own font. +## v0.13.3 — 2026-09-13 + +### Performance + +- **Dense forest renders faster** — a depth prepass for the near tree foliage in `ludic.render3d`. + + A crown of needle cards is many cut-out quads deep, and a shader that can `discard` turns + early depth rejection off, so every card behind the front one ran the full lighting shader. + In a dense stand at 3840x2160 that made the vegetation pass the largest in the frame. The near + tree LODs now write depth first with `depth.frag` (the same alpha coverage test), the terrain + beneath them is rejected before shading, and the lit pass draws them with no `discard` and an equal depth test. `R3D_NOPREPASS=1` restores the old path for comparison. +## v0.13.2 — 2026-09-13 + +### Fixes + +- **`ludic build` links `http.ll` only into a program that uses `Http.*`.** + + The check that added it grepped the IR for `@hs_`, which every program's header declares, + so the HTTP transport and Foundation were linked into everything. That cost nothing visible + on macOS and failed the link on Linux, where `ludic build`, `ludic run` and a new project all + broke in CI. It now looks for a call to `hs_send`. +## v0.13.1 — 2026-09-13 + +### Fixes + +- **The toolchain links on Linux again** — CI builds, tests and publishes releases. + + The asset-pack boot finds the directory beside the executable with `_NSGetExecutablePath`, + which Darwin's libc has and glibc does not, so every Linux link of the compiler seed failed + from the release that added packs onward: CI's build, suite and C-free bootstrap jobs, and + every `publish` run, so no release after v0.7.0 carried a Linux toolchain or was created by + CI at all. `tools/ci/linux_stdio_shim.ll` now supplies it, over `/proc/self/exe`. +## v0.13.0 — 2026-09-13 + +### Features + +- **Windows target** — `ludicc` builds and runs headless programs on Windows, and builds itself there. + + - **`--target `** — a triple naming `windows` selects the Windows runtime; without + the flag the target is the host, read at run time from `OS=Windows_NT`. The IR still + carries no triple, so clang assembles it for the machine it runs on. + - **The libc surface, in IR** — on Windows the header emits `emit_win.ludic`, which defines + the POSIX names the backend calls (`fopen`, `ftell`, `rename`, `opendir`, `mmap`, + `fmemopen`, `uname`, …) over the UCRT and Win32. Three of them fixed silent breakage + rather than link errors: `rename` onto an existing file (every `Fs.write_text` after the + first), a 32-bit `ftell`, and text-mode `fopen` rewriting `\n` as `\r\n`. + - **Known folders** — `Os.save_dir` / `config_dir` are `%APPDATA%\`, `cache_dir` is + `%LOCALAPPDATA%\`, `temp_dir` is `%TEMP%`, all with forward slashes; a bundle's + `home` line points at `%APPDATA%\`. + - **The driver** — finds `C:\Program Files\LLVM\bin\clang.exe` when clang is not on + `%PATH%`, writes `.exe` outputs, speaks cmd.exe for its directory and cleanup commands, + and reads `argv[0]`, `%PATH%` and `$LUDIC_HOME` with either separator. + - **A window** — `win32.ll` is `cocoa.ll`'s contract over user32: the held-key set and frame + key in Ludic codes, the mouse in framebuffer pixels with raw input for cursor mode 2, + cursor hide/lock/confine that lets go when the window loses the foreground, XInput pads + in SDL order with rescaled deadzones, and the software `win_present`. `win32_gl.ll` puts + the WGL context on that window with vsync and borderless full screen; a headless build + links `gl_win_nowin.ll` instead. The process is per-monitor DPI aware. + - **Sound** — `audio_win.ll` is `audio.ll`'s contract over XAudio2: one source voice per + clip, loops, volume, rate and a balance pan through the output matrix, RIFF WAVE in PCM or + float. It reads a clip through the asset pack, so a bundled game's first `Audio.load` + succeeds - AVAudioPlayer takes a filesystem path, which is why macOS cannot. + - **The CLI on Windows** — `ludic build`, `ludic run` and `ludic-dev build` work from a Windows + checkout. Every shell command the CLI issues runs through Git for Windows' bash + (`$LUDIC_BASH` names another), `compile_app` links through `ludicc -o`, and a checkout + bootstraps from the new `selfhost/ludicc.win.seed.ll`, which `ludic-dev reseed` now + writes beside the macOS seed. + - **`ludic bundle` on Windows** — `build//` holding a GUI-subsystem `.exe`, the + `game.lpak` and `packs.index`; an `.ico` beside `app icon` is compiled with `llvm-rc` and + linked in. `ludicc` gains `--gui` (no console behind the window) and `--link `. + - **`Http.*` on Windows** — `http_win.ll` is `http.ll`'s contract over WinHTTP: a request + built on the game thread, the exchange on a worker thread, TLS with the system's + certificate checks, and response headers answered from the kept request handle. + - **The splash and the window icon** — decoded by WIC from the bytes in the pack. The splash + is a topmost borderless window at the artwork's own size in points times the display's + scale; `App.set_icon` sets the title bar and taskbar icon of a build with no icon resource. + - **`Gl.*` on Windows** — `gl_win.ll` makes a WGL 4.1 core context on a hidden window's DC, + and `gl_thunks_win.ll` (generated by `ludic-dev glgen` beside `gl_thunks.ll`) calls + every entry point through a table `@lgl_win_load` fills from `wglGetProcAddress`, falling + back to `opengl32.dll` for the 1.1 functions it will not return. A headless GL program + renders on the GPU there: `gl_triangle` matches the macOS frame to within one level per + channel. + +### Fixes + +- **Backspace fires on macOS** — the Delete key reports `Key.Backspace` (8). + + AppKit gives the key marked delete (kVK_Delete, 51) the character `NSDeleteCharacter`, 127, + which is what both the held-key set and the per-frame key received, so `Key.Backspace` never + matched. `cocoa.ll` maps key code 51 to 8 in both. +- **Held arrow keys register** — `Key.Up`, `Key.Down`, `Key.Left` and `Key.Right` are 128-131. + + They folded to the codes of w, s, a and d, which is what the single per-frame key + (`Input.key`) reports for an arrow. The held-key set has always stored an arrow under + 128-131, so `Input.key_down(Key.Up)` tested the W bit and `Input.move_i`'s arrow half never + moved anything. `Input.key` keeps its WASD alias: a game comparing it with `'w'` still + takes the arrows. +- **`ludic build` links `Http.*`** — a program that uses the HTTP client builds through the CLI. + + `compile_app` linked the OpenGL backend when a program named `@lgl_*` but never + `runtime/native/http.ll` and Foundation for `@hs_*`, so a `Http.*` program failed to link + under `ludic build` / `ludic run` while `ludicc -o` built it. It now links them the same way, + windowed and headless. +## v0.12.1 — 2026-09-13 + +### Features + +- **ludic.render3d: `r3d_fog_base`** — the height the height fog's density is measured from (default 0, the world's y = 0). Two maps sharing one height datum, one far lower than the other, no longer give the lower one many times thicker air. + +### Fixes + +- **ludic.render3d: lakes are ellipses** — a water body other than the reflecting one was drawn as the whole rectangle of its bounds, so the corners between that rectangle and the lake's carved ellipse showed sheets of water over dry ground. Those bodies are now clipped to the ellipse; the reflecting body (the sea) still fills its rectangle. +## v0.12.0 — 2026-09-13 + +### Features + +- **ludic.render3d: replace the world at run time** — a game can swap its terrain, scatter, colliders, actors and water for another map's without restarting. + + - `terrain_reload(dem, emin, emax, base, ox, oz, ortho, half)` releases the current map's height field, survey, photograph and patch bounds and generates another at any `TERRAIN_HALF`; `terrain_unload()` is the release on its own. + - `scatter_clear_all()` (with `stream_clear_all()`), `actor_clear_all()` and `col_reset()` empty the scene for the next map; `mesh_free()` releases a mesh's GL objects. + - **Water bodies** — `water_body_add(level, cx, cz, ex, ez, reflect)` and `water_bodies_clear()` draw several still-water planes at their own levels; the first that reflects gets the planar reflection. `water_init` still means one reflecting plane. + - **Fix:** the terrain's patch quadtree placed patches at a fixed 32 m leaf, so any `TERRAIN_HALF` other than 4096 put patch bounds in the wrong place; leaves now scale with the map. + - **Fix:** `water_init` built a new mesh and compiled a new program on every call. + - `terrain_sea(level)` separates the sea's level (the coast, the strand, the shoreline, forest and scree shading) from the carved lake's, so a lake can sit above the sea. Unset, the sea is the lake's level as before. +## v0.11.1 — 2026-09-12 + +### Fixes + +- **`col_resolve` no longer throws a body across the map when it starts dead centre on a + collider.** The degenerate branch — a point exactly on a circle's axis, where there is + no direction to push it — chose a unit normal `(1, 0)` and then divided it by the + clamped `d = 0.001` anyway, along with the real normals. The push came out a thousand + times too large: a body standing exactly on a 0.5 m trunk was moved about 800 m instead + of the 0.85 m that clears it. + + Off-centre the arithmetic was correct, and off-centre is how anything arrives at a + trunk while walking, so it never showed up in play — only a teleport, a spawn or a + world generator placing something on an existing collider could land on the axis. + + `(ex, ez) / d` is always a unit vector for `d > 0`, because `d` is its own length: there + was never anything to clamp. The normal is now built once, explicitly, and the clamp is + gone. +## v0.11.0 — 2026-09-12 + +### Features + +- **`App.set_icon(path)`** — an icon for a binary that has no bundle to take one from. + + `ludic bundle` builds an `AppIcon.icns` and macOS reads it from the `.app`, so a + shipped game has an icon. `ludic build` produces a bare executable, which has no + bundle, no `CFBundleIconFile` and therefore no icon at all — macOS draws the generic + green "exec" tile in the Dock. That is the build a developer runs all day and the one + they see every session, so "my game has no icon" is true long before it ships. + + ```ludic + App.set_icon("assets/app/icon.png") + ``` + + The path resolves through the pack first and the filesystem second, like every other + asset, so the same call works packed and unpacked — it does not repeat `Audio.load`'s + trick of taking a filesystem path only. An image that cannot be found or decoded + leaves the current icon alone rather than clearing it, calling it in a bundled app is + a harmless no-op, and a headless build compiles it away to nothing. +## v0.10.1 — 2026-09-11 + +### Fixes + +- **`Os.save_dir` / `Os.config_dir` / `Os.cache_dir` follow the platform.** They were the + macOS layout everywhere, so a game built on Linux wrote its saves to + `~/Library/Application Support` — a directory that means nothing there. Naming the + right directory is the entire reason a program calls these instead of building a path. + + | | macOS | Linux | + | --- | --- | --- | + | `save_dir` | `~/Library/Application Support/` | `$XDG_DATA_HOME` or `~/.local/share/` | + | `config_dir` | `~/Library/Application Support/` | `$XDG_CONFIG_HOME` or `~/.config/` | + | `cache_dir` | `~/Library/Caches/` | `$XDG_CACHE_HOME` or `~/.cache/` | + + An XDG variable that is set but empty falls back to the default, as the spec requires. + macOS is unchanged to the byte — `save_dir` and `config_dir` stay the same directory + there, because Apple's home for a config file that is not an `NSUserDefaults` plist is + Application Support too, and a shipped game's settings must not move out from under it. + On Linux XDG separates the two and so does this. + + One consequence worth stating plainly: a game already shipped on Linux was writing to + the old macOS-shaped path, and this moves it. Those files are not migrated for you — + they are wherever `~/Library/Application Support/` ended up on that machine, and a + game that has Linux players should move them once on startup. +## v0.10.0 — 2026-09-11 + +### Features + +- **`.packignore`** — keep build artefacts out of the shipped asset pack. + + A pack root is packed wholesale, so everything a model or texture pipeline leaves + beside its output ships too: preview renders, bake intermediates, the `.blend` a + `.gltf` came from. Nothing errors and nothing looks wrong — the app is just bigger + than the game. The only way out was naming every file in `package.ludic`, a list + that goes stale the day someone adds a texture. + + Put a `.packignore` beside the assets instead. The rules are **gitignore's**, down + to the parts people rely on without thinking about them: patterns anchored by a + slash or floating without one, `preview/` for directories only, `*` and `?` stopping + at a separator where `**` crosses it, `[a-z]` classes, `!` re-includes with the last + line winning, a deeper file beating a shallower one, and no re-including out of an + ignored directory. + + `ludic pack` reports what it left out, and `--no-ignore` packs everything so you can + see what a rule costs. `ludic bundle` gathers the same way. `.packignore` itself is + never packed, and the file only governs your own roots — a package's resources, such + as the renderer's shaders, are added afterwards and cannot be excluded by accident. +## v0.9.1 — 2026-09-11 + +### Fixes + +- **`outline_model`'s batch survives the whole frame.** A frame is drawn by more than one + pass — a shadow map, a water reflection, the scene — and `actor_draw` runs in each of + them. An Actor's rim survives that because it is a field on the actor; the queue did not, + because the first pass to run emptied it, so a queued outline was drawn into whichever + target happened to come first and was gone by the time the scene was drawn. Nothing + appeared, with every uniform, matrix and mesh correct. + + A flush now *closes* the batch rather than clearing it, and the next `outline_model` + opens a new one: every pass in a frame sees the same requests, and a caller still needs + no frame hook. +## v0.9.0 — 2026-09-11 + +### Features + +- **`Audio.play_at(id, gain:, pitch:, pan:)`** — fire a one-shot with its own gain, pitch + and stereo position, leaving the master settings alone. + + `Audio.volume` and `Audio.pitch` are global: they are there so a player can turn the game + down, and a game that used them to place a sound in the world would be fighting its own + options screen. This is the per-voice version, and it is what distance attenuation is made + of — a 3D game works out how far away a sound is and which side it is on, and says so. + `gain` rides on top of the master volume, so the options screen still wins; `pan` runs + -1 (hard left) to 1 (hard right). + + A loaded sound is still one player, so firing the same handle again restarts it rather + than layering a second copy. Load a handle per variant when several need to overlap. +- **`ludic.render3d`: `outline_model(model, mat, width, r, g, b)`** — a rim around something + that is not an Actor. + + An Actor has had an `outline` since the outline pass landed, and everything else in a + scene had nothing: the pass walked the actor list and stopped. Instanced scatter was the + gap that mattered, because a game that highlights whatever the crosshair is on could + highlight every object in the world *except* the twenty thousand most common ones — the + trees. + + `outline_model` queues a model at a transform from anywhere in the frame and the outline + pass flushes it alongside the actors', which is what gets the depth test right: the rim has + to be drawn after the scene it is tested against, and a caller does not control pass order. + A layer whose vertex shader moves its instances — wind, or standing them on the drawn + terrain — should be handed the transform that shader arrives at. +- **`ludic.render3d`: `terrain_coast(cx, cz, margin, fall)`** — the other way to make an + island, and the one a real survey wants: the sea goes around the survey's **own edge** + rather than being cut out of the middle of it. + + `terrain_island` measures a radius out from a centre, which suits a made-up map and + drowns most of a real one — an 8 km mountain survey loses two thirds of itself to make an + island of the rest. `terrain_coast` measures inward from the boundary instead: everything + the data covers stays land, the outer `margin` metres go under water, and the `fall` metres + inside that are scaled down into it. The band is wobbled by low-frequency noise, so what + comes out is headlands and bays rather than the square the data arrived in. + + Both modes also gained a **strand**. Scaling alone hands a 500 m mountainside a 40-degree + plunge into the sea, which is a cliff coast and nothing else; the last few metres of height + either side of the water line are now compressed, which stretches them out horizontally + into beach and shallows. Inland lakes are untouched — they have their own bed. +## v0.8.0 — 2026-09-10 + +### Features + +- **A boot splash**, customised by the game. `app splash` in `package.ludic` names + the artwork and `app splash_bg` the colour behind it; the runtime raises a + borderless window from a constructor that runs before `main`, reading the image + out of the asset pack, so it is on screen while the process is still starting + rather than after the slow part it exists to cover. Nothing hides it + automatically - only the game knows when its first real frame is ready, so the + game calls `App.splash_hide()`. +- **Asset packs.** `ludic pack` writes every asset a game opens into one `.lpak` + beside the binary, and the runtime mounts it before `main`. Nothing about how a + game is written changes: the pack is spliced in at `file_open`, the one place + every asset comes through, so `gltf_load`, `tex_load`, `Audio.load`, + `Fs.read_text` and the renderer's own shader loads all find it without knowing + it exists. `Fs.exists` and `Fs.size` consult the packs too. Entries are stored + rather than compressed and the pack is `mmap`'d, so 165 MB of terrain costs one + syscall at startup and pages in only what is touched. Several packs can be + mounted in order, and a later one shadows an earlier one - which is how a patch + replaces individual files without rewriting the base pack. See `docs/SHIPPING.md`. +- **`ludic bundle`** turns a built game into a real macOS `.app`: `Info.plist` and + `PkgInfo` from the manifest, an `.icns` built from one source PNG at every size + macOS asks for, the asset pack in `Contents/Resources`, and an ad-hoc signature + so it launches on Apple silicon. Everything comes from `app` lines in + `package.ludic`, so the command takes no arguments. A bundled game is also moved + to `~/Library/Application Support/` at startup, because Finder starts a + `.app` with its working directory at `/` where no save could ever be written. +- **`ludic.render3d`: `terrain_island(cx, cz, r, fall)`** keeps land out to `r` and then + scales the terrain down into the water over `fall` metres. Scaling rather than blending + to a fixed bed is what makes the coastline come out of the terrain already there: low + ground becomes beach and shallows, high ground becomes cliff. `r = 0` leaves the survey + untouched. +- **`ludic.render3d`: a rim outline on an actor.** An actor gains `outline` (metres of + rim) and `ocol` (its colour). The pass draws the model again with its vertices pushed + along their normals and its front faces culled, so only the far side of the swollen + shell survives - a silhouette exactly `outline` wide, depth-tested against the scene, so + anything standing in front of the actor hides its rim too. +- **`ludic.render3d`: the moon has a phase.** One number, 0 new .. 0.5 full .. 1 new + again, decides the disc's terminator, how much of its light reaches the ground, and + where in the sky it rides - a full moon rises as the sun sets, a new moon travels with + the sun and is never seen. A new moon is now a properly dark night, which is what makes + a carried light worth having. + +### Fixes + +- **`ludic.render3d`: water read the window's size, not the frame it drew into.** The + water shader's `u_screen` and the reflection target were sized from the drawable, but + `gl_FragCoord` there runs over the scene target. They match only at a render scale of 1; + below that the refraction and depth reads landed in the wrong corner of the frame and the + lake showed a squashed copy of it instead of its own bed. +## v0.7.0 — 2026-09-10 + +### Features + +- **A game can use the renderer from outside this repository.** `ludic.render3d` reads two + things from disk at run time — its GLSL, and the scanned CC0 materials — and both were + found only by a path relative to the working directory, so the renderer worked in a + Ludic checkout and nowhere else. A game living in its own repository now needs to copy + neither. + + **The shaders come from the package**, wherever the package is. They belong to + `ludic.render3d` and ship with it, so the renderer looks for them beside the project + first (a Ludic checkout, where they are under `packages/`) and then under the install + root, `$LUDIC_HOME/packages/ludic.render3d` — the same place the compiler already + resolves `import "ludic.render3d/r3d.ludic"` from. Nothing to vendor, and no version of + the shaders that can drift from the version of the code that compiles them. + + **`ludic assets [--force]`** fetches the scanned materials and the HDRI sky into + `assets/polyhaven/` of whatever project you run it in. The list of what to fetch is the + renderer's own — the renderer decides which materials it wants — so it moved out of the + repository's `assets/` and into the package as + `packages/ludic.render3d/assets.manifest`, where it ships with the toolchain. A game + does not keep its own copy of that list and so cannot fall out of step with the + renderer's material set. `ludic dev fetch-assets` is the same command from a checkout. + + **A URL-shaped module built its binary into directories.** `project_name` took + everything after the last dot of the manifest's module path, which for a package + identified the way the package manager identifies them — + `git.host.io/user/name` — is inside the *host*: the build wrote + `build/io/user/name` instead of `build/name`. The last path segment comes first now, and + a dot inside that segment still separates namespace from package, so `ludic.render3d` + still builds as `render3d`. + + **The camping game has moved out** to its own repository — + [Maroon Lake](https://git.workshopsoft.io/workshopsoft/maroon-lake) — taking + `examples/rendering/valley.ludic`, `hiker.ludic`, `camp/`, and 175 MB of survey data and + scanned kit with it. It was here as a demo of the renderer and became a game, and an + engine repository should not be carrying a game's assets. It is now the first consumer + of everything above, which is the point: what the renderer needs a game to be able to do, + it can now do from outside. `examples/rendering/smooth.ludic` stays as the renderer's + example in this tree. +- **OpenGL for Ludic, and a 3D renderer on it.** `Gl.*` binds the whole OpenGL 4.1 + core API — every `gl*` entry point of the platform `gl3.h` as `Gl.(…)` + with every `GL_*` constant, generated by `ludic-dev glgen` with per-call ABI thunks + (`runtime/native/gl_thunks.ll`; float/double parameters take `fixed`). Windowed + builds get an `NSOpenGLContext` on the existing window at Retina resolution + (`cocoa.ll`); headless builds render into an offscreen CGL context, so a program + that uses `Gl.*` renders and screenshots identically under the test harness. + `Gl.open / swap / screenshot / program / vao / floats …` cover the glue, and the + IEEE-float helpers (`f_add`, `mem_put_f32`, …) let Q16.16 programs fill real + float vertex and uniform data. `Gl.*` links `gl.ll + gl_thunks.ll + OpenGL.framework` + only when used; every other build is byte-identical. + + The `ludic.render3d` package (`packages/ludic.render3d`) is a physically based + renderer written on `Gl.*`: HDRI sky with image-based lighting (irradiance, GGX + prefiltered, split-sum BRDF, sun extracted from the map), GPU-generated terrain + with scanned PBR materials (stochastic anti-tiling, triplanar rock, slope/altitude + splatting), cascaded shadow maps with PCF and world-unit biasing, a glTF loader + for scanned models, instanced vegetation with baked impostors, procedural grass + and lupines with wind and translucency, 4x MSAA with alpha-to-coverage, SSAO, + still water, cloud shadows, aerial perspective, an HDR pipeline with bloom, + auto-exposure, ACES tonemapping, grading, sharpening and grain. See + `examples/rendering/smooth.ludic`, and the Maroon Lake game (git.workshopsoft.io/workshopsoft/maroon-lake) for a + game built on it. The renderer's CC0 materials are fetched with `ludic assets`. + + **16-bit PNGs**: the renderer's texture loader keeps 16-bit samples (normal / + displacement maps) and uploads them as `RGB16` / `R16`. + +### Fixes + +- **Resizing the window (or entering fullscreen) no longer empties the world.** It left + `gl error 1286` — `GL_INVALID_FRAMEBUFFER_OPERATION` — on every frame from there on, with + the terrain, the trees, the grass and the water gone and only the sky drawn. + + The sun-visibility pass added in `changes/terrain-perf.md` borrows the depth buffer the + frame is about to be drawn with, so that rasterising it doubles as a depth prepass. It + was storing that borrowed texture in its `Target`, and a `Target` deletes whatever its + `depth` names when it is freed. On the first resize the sequence was: `post_free` deletes + the frame's depth texture, `post_init` immediately makes the replacement — and GL hands + back the name that was just freed — and then the visibility target, rebuilt for the new + size, deleted that name believing it was its own. The scene framebuffer lost its depth + attachment. What is left is a colour-only framebuffer, which is *complete*, so drawing + carried on with no depth test at all: the sky is a fullscreen quad drawn last, and with + nothing left to fail against it painted over the entire valley. The 1286s came from the + passes whose own attachment now named a texture that no longer existed. + + A borrowed attachment is never written into the target now, and the frame's depth is + attached afresh at the start of each pass — it is a different texture every time the + screen-sized buffers are rebuilt, and one `glFramebufferTexture2D` per pass is cheaper + than any scheme for noticing that it changed. + + `R3D_RESIZE_AT=` rebuilds every screen-sized buffer from that frame on, cycling + through four drawable sizes every few frames. A window cannot be resized in a headless + run, so this is the only way to reach the path; it reproduced the fault in one frame and + now runs twenty resizes, with the fly camera and with the game, without an error. +- An upgrade keeps the package store. The installer replaces the whole install + root, and `ludic add` caches packages in `~/.ludic/store` — so re-running the + one-liner deleted every package a project had fetched. The store is carried + across now; everything else in the root belongs to the toolchain and is replaced. + +### Performance + +- **Ground cover stops re-growing itself.** Walking a streamed world hitched, and the + hitch got worse the longer you played. Measured in the Maroon Lake game, with a new hitch + report rather than guessed at. + + **The chunk cache had a cliff, not a slope.** A stream cached 4096 chunks and then + stopped remembering: past that the chunk was generated, used for one frame and thrown + away, so every ring walk regenerated it, for the rest of the session. It arrives after + enough of the map has been walked — six evictions' worth over seven kilometres, so an + ordinary session reaches it — and it is the point where cover starts visibly re-growing + as you turn. `stream_evict` now drops the half of the cache nobody has asked for in the + longest time (chunks carry the walk that last wanted them) and rebuilds the index over + what is left. Over a 7 km traversal: generation total **9073 ms → 2230 ms**, the worst + single frame's generation **11.4 ms → 3.1 ms**, median frame 10.8 → 9.0 ms. With a cache + deliberately sized to saturate early, the same run goes from 2748 frames generating to + 1548, and from a 13.3 ms median to 9.2. `R3D_NOEVICT` restores the old behaviour for + comparison, `R3D_STREAM_CAP=` sets the cache size. + + The other half of that hitch was in the game's own cover generator, and went with it to + the Maroon Lake game's repository: its candidates were paying for a second noise field, four + height samples and a path distance before the drift field that rules out most of the + meadow — 2341 µs → 518 µs per chunk, bit-identical output. Worth repeating in any + generator: a `stream_fill` is called for tens of thousands of candidates per chunk, so + the order of its tests is most of its cost. + + **The hitch report** (`R3D_PROF=1`) is what found both. It prints the slowest frames of + the run with what was in each: CPU versus GPU wait, cover generated, instance bytes + uploaded, the game's own tick, and the renderer phase that took longest. Alongside it, + per-chunk generation cost by stream and band, a census of what the caches hold, and a + stutter figure — the frame time a run spent beyond 1.2x its own median — because a mean + cannot show a hitch and a maximum is one unlucky frame. + + It also found two content bugs in the game it was measured on, which is the report doing + its job: a cover stream whose placement rule never fires still pays full generation cost, + and the census makes that visible — 3364 cached chunks holding zero instances. +- **The ground costs half what it did.** Measured in the Maroon Lake game, the terrain was 10.3 ms of + a 22.2 ms frame; it is now 5.4 ms of 16.4 ms — 45 fps to 61 fps at 1080p, with the + frame otherwise unchanged (every viewpoint tested stays above 54 dB PSNR against the + old renderer, with no channel differing by more than 7/255). + + **Measure by frame time, not by the pass timers.** `R3D_PROF`'s per-pass + `GL_TIME_ELAPSED` queries cannot be trusted on this driver: with the ground's shading + work removed the terrain query fell from 10.5 ms to 1.3 ms while the frame time did + not move at all. Every number above and below is a median real frame time, taken by + switching one thing off (`prof_ft_report`); the pass timers are still printed, and are + still useful for spotting a pass that appears out of nowhere, but they cannot size one. + `R3D_NOTERRAIN` skips the ground, `R3D_TNEARONLY` / `R3D_TFARONLY` draw every patch + with one tier's program, and `R3D_RES=x` renders at another size — the three + switches that say whether a cost is the ground, which tier it is in, and whether it is + pixels at all. + + **Each detail tier is its own program.** `terrain.frag` holds a detailed near tier and + a cheap far one and chose between them per pixel, so every pixel of the valley walls + was compiled — and scheduled — for a near path it never ran. CDLOD selection now knows + which tiers a patch can contain: one that never comes within the split draws with + `FAR_ONLY`, one wholly inside it with `NEAR_ONLY`, and only the ring of patches that + straddle the band needs the program that holds both and cross-fades. Pixel-identical, + and it makes the tiers separately measurable: the near tier costs 7.5 ms over a whole + frame, the far tier 2.3 ms. + + **The sun visibility is its own pass** (`tersun.frag`). The same CDLOD patches are + rasterised once into a screen-sized R8 buffer that holds nothing but each ground + pixel's sun visibility, and `terrain.frag` fetches it by fragment coordinate. The pass + costs 0.27 ms, shares the frame's depth buffer so it doubles as a depth prepass, and + takes the cascade read out of the shader that covers the screen. It picks its tier — + filtered PCF near, a single tap far — over the same cross-faded band the ground uses, + so the boundary is not a contour you can find on the hillside. + + **Nothing is sampled for a weight of zero.** The ground sampled all four of its + materials for every pixel and then blended three of them at zero: a meadow pixel took + nine taps of triplanar rock, a cliff pixel nine taps of stochastic grass, and every + pixel in the valley took the snow tile and the four noise fields behind the lake's + shore wash — a wash that is a hairline along one shore within 120 m of the camera. The + survey photograph's classification now runs first, because it is what decides which + materials are present; each material block sits behind its own weight; the ridge field + that ragged the snow line is skipped 160 m below it, where it cannot change anything; + and inside the stochastic blend a cell's rotation, offset and rotated gradients are + computed inside its own test, so a cell whose sharpened weight rounds away costs + nothing. All of it exact where the weight is zero, and it is most of the win. + + Material sampling is what remains (2.8 ms of the 5.4): scanned 2K tiles taken at 16x + anisotropy on ground seen at a grazing angle. `R3D_ANISO=` sets the filter (the + default is unchanged at 16; 4 is worth 1.0 ms and 1 is worth 1.7 ms). + + The shadow pass is 1.2 ms of the frame and has nothing to give: re-using the far + cascades between frames — their windows are snapped to a 14 m and a 64 m grid — is + worth 0.15 ms standing still and nothing while walking, so it is not in the tree. ## v0.6.1 — 2026-09-05 ### Fixes diff --git a/COMPILING.md b/COMPILING.md index 2fd42133..8f03d7d3 100644 --- a/COMPILING.md +++ b/COMPILING.md @@ -25,7 +25,7 @@ > ```bash > # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/ludic > mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc -> bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev +> bin/ludicc --unsafe --globals tools/ludic-cli/dev.ludic -o bin/ludic-dev > bin/ludic-dev build # the whole toolchain into bin/ > # (ludicc, ludic, ludic-fmt, ludic-lsp) > bin/ludicc examples/games/snake.ludic -o bin/snake # the compiler, directly @@ -73,6 +73,9 @@ point at a different LLVM toolchain if you have one. | a windowed native executable | `ludicc game.ludic -o build/game` | | a headless executable | `ludicc game.ludic --headless -o build/game` | | the IR, to read | `ludicc src.ludic --emit-llvm -o src.ll` | +| the schema an editor reads (records, registries and their entries, consts) | `ludicc src.ludic --emit-schema schema.json` | +| every error, as a JSON array on stdout | `ludicc src.ludic --check --diagnostics=json` | +| the same, with an unsaved buffer on stdin standing for one of its files | `ludicc src.ludic --check --diagnostics=json --stdin-file lib/a.ludic < buf` | | a shared library † | `ludicc lib.ludic --shared -o build/liblib.dylib` | | a game that runs in a browser † | `ludicc game.ludic --target wasm32-unknown-unknown -o build/web/game.wasm` | | an object file † | `ludicc src.ludic -c -o src.o` | @@ -350,6 +353,7 @@ The self-hosted `ludicc`/`ludic` (built with `bin/ludic-dev build-cli`) accept: --windowed force a windowed (Cocoa) build --headless force a headless build (stdin input, out.ppm output) --emit-llvm stop at LLVM IR — write it and exit, no clang + --check every check a build makes (types, modules, uses, layers, ports, binds); write nothing --fmt lex + parse only; exit 0 if it parses, 1 on a parse error (the check-docs gate; canonical formatting not yet restored) --save-temps keep the intermediate .ll diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1c8128a..c6d0c971 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ From a clean checkout, one line lifts the toolchain off the seed: ```bash mkdir -p bin && clang selfhost/ludicc.seed.ll -o bin/ludicc -bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev +bin/ludicc --unsafe --globals tools/ludic-cli/dev.ludic -o bin/ludic-dev ``` That gives you `bin/ludic-dev`, the contributor tool: it replaces every diff --git a/LANGUAGE.md b/LANGUAGE.md index 5933f8b9..eff6abe7 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -45,7 +45,11 @@ program ChronoRift { ``` `import "emberdepths/*.ludic"` imports every `.ludic` file of a directory, in name -order — a game lists its modules once. An imported file is a **fragment**: bare +order — a game lists its modules once. `import "camp"` names a directory through its +**barrel**, `camp/index.ludic`: a fragment that lists the directory's own imports +(relative to itself), in the order it wants them. A directory with no `index.ludic` +is an error that says so. Packages resolve the same way: `import "ludic.render3d"` +reads `ludic.render3d/index.ludic` from `ludic_modules/` or the toolchain. An imported file is a **fragment**: bare declarations, no `program` wrapper. Its declarations are spliced into the importing program. Imports may appear inside the `program` block or before it, they may nest (a fragment may import fragments), @@ -58,6 +62,1395 @@ parse: chronorift/world.ludic:1: error: expected expression ``` +All of a program's files share one namespace, so **a name is defined once**: two functions, two +`var`s or `const`s (or an `enum` and a `const`), or two `property` / `event` records with one name +are an error that names both places. Declarations the runtime splices in are its own and are not +checked against each other. +The same holds inside a function: a `let` or `var` declares its name once per block (another +block, a loop variable or a parameter may reuse it), and a function with a result type must +`return` one on every path - running off the end of its body is an error, not a zero. + +### Modules (`module`, `export`, `friend module`) + +One namespace is not the same as one room. A barrel that says `module bank` makes its +directory a **module**: the barrel, every file it imports, and every file those import - until +one says `module` of its own - belong to `bank`. Inside a module every name is visible as +before. From anywhere else, a module's `function`, `var`, `const`, `property` or `event` is +reachable only if its declaration says `export`: + +```ludic +# doc-check: skip — a module spans files +# bank/index.ludic +module bank +import "ledger.ludic" + +# bank/ledger.ludic +export state Bank { balance: int = 0 } # its fields are bank's to change: only bank's functions do +export event Deposited { amount: int } +function add(b: mut Bank, n: int) -> void { b.balance += n } # private: only module bank sees it +export function deposit(b: mut Bank, n: int) -> void { + add(b, n) + emit Deposited(amount: n) +} +``` + +A program that imports `bank` may call `deposit` and listen to `Deposited`; calling `add` is + +``` +visible.ludic:5: error: add is private to module bank; mark it 'export' where it is declared (bank/ledger.ludic) +``` + +A file in no module - the program's own file, the runtime - is public, and a package found +through `ludic_modules` or the toolchain keeps its own module rather than its importer's. A +program that says `friend module lab` sees every module's private names: that is for a test +harness, which has to reach inside what it tests. `friend module lab of fishing, data` narrows +that to the modules named: `lab` sees their private names, and every other module's exports only. `export` is a keyword before a declaration +and is not the `@export` annotation, which names a C symbol. + +**A module's private names are its own.** A function, `var` or `const` a module does not export +may share its spelling with a name in another module, in a file in no module, or in the engine's +runtime: `shop` and `weather` may each have a private `seed`, and each module's code reaches its +own (a local of the same name still shadows it). There is no need to prefix a package's privates. +Exported names are one namespace across the program, so two modules that both `export function +seed` are still refused (`function 'seed' is defined twice`). Where two names meet, the private +one is compiled under its module's name (`seed$shop`), which is the spelling a message about it may +show. + +The same holds for a `property` and an `event`: `fishing` and `hunting` may each have a private +`Catch` record and a private `Landed` event with different fields, and each module's types, `new`, +`emit` and `@On` reach its own (compiled as `Catch__fishing`). Two exported ones of one spelling are +refused (`'Catch' is defined twice`, `event 'Landed' is defined twice`). When one of the two is a package's export the message +says so (`'UiNode' is defined twice (...): ludic_ui exports it, and exported names are one namespace - +rename this one, or declare it without export inside a module of your own`); a package exports only +what a program uses, so ludic.ui's `UiAct` is its own and a game's may take the name. Three kinds of record stay +one namespace, because other code names them by spelling: a generic record, a property that is an +entity's component (a `model` names it), and the record a component or a view generates. + +To move an existing codebase onto modules, build it once with `LUDIC_VIS_REPORT=1`: every +reference that would be refused is printed as `vis: :: . used from +` and the build goes on, so a script can add the `export`s the program already relies on. + +### What a module may reach (`uses`) + +`export` says what a module offers; `uses` says what a module takes. A module line may name the +only other modules its files reach: + +```ludic +# doc-check: skip — a module spans files +# fishing/index.ludic +module fishing uses base, data +``` + +From then on a reference from `fishing` into any other module is refused, even to a name that +module exports: + +``` +fishing/land.ludic:4: error: fishing uses items.inv_add (items/index.ludic:3): add 'uses items' to fishing's module line, or take it through a port +``` + +- A module with no `uses` clause keeps the rule before it - anything exported - so the rule can be + switched on one module at a time. Two module lines for one module add their lists together. +- A package's module counts like any other: a mechanic that says `uses ludic_base` and calls + into `ludic_inventory`, `ludic_ui` or the renderer is refused. A package with no `module` line + of its own (`ludic.render3d`) is named for its directory - `ludic_render3d` - for this rule; its + names stay public to the export rule. Only the engine's own runtime - `Value`, `Json`, + `Random` and the rest, which is in no module and no package - needs no naming. +- A file in no module (the program's root) is unaffected either way. +- `module ludic_base uses` with nothing after it is a module that reaches no other module at all. +- A friend of a module (`friend module lab`, or `friend module lab of fishing`) is not held to its + `uses` for that module. +- The declared graph may not go round: `module a uses b` beside `module b uses a` is refused + (`the modules' uses go round in a circle: a -> b -> a`) - one of them takes the other through a + port instead. +- **Layers.** `module flow in layer app uses base, items` puts `flow` in layer `app`. The modules of + one layer use each other freely, without naming each other, and may go round - a game's app + modules (the flow, the menus, the HUD) reach each other by design. Everything outside the layer is + still held to the module's `uses`, and a layered module with no `uses` may reach nothing outside + its layer. A cycle is allowed only inside a layer: `items uses hud` with `hud` in layer `app` + using `items` back is `items -> layer app -> items`, refused. A module is in one layer. +- `LUDIC_VIS_REPORT=1` lists these too, as `uses: :: . used from + (module )`, and builds. + +### Ports (`port`, `bind`) + +A module that needs something from outside itself - the time, a save, a sound - declares a +**port** instead of naming (and `uses`-ing) the module that answers. A port is a record of +function values; a member with no default is required: + +```ludic +# doc-check: skip — a module spans files +# clock/index.ludic +module clock uses units +export port Clock { + now: fn() -> int + day: fn() -> int = fn first_day +} +export function hour_of_day() -> int { return Clock.now() - (Clock.day() - 1) * HOURS } + +# app/index.ludic - where the program is put together +module app uses clock +bind Clock { now: fn game_hours } +function game_hours() -> int { return g_hours } +``` + +A member that takes nothing can be bound to a variable instead: `bind Purse { money: g_money }` for a +`money: fn() -> int` writes the getter (`bind_Purse_money`, returning `g_money` as it is at each call) +in the bind's own file, so the one-line wrapper function is not needed. A member that takes something +is refused a variable (`bind Purse: price is a fn(int)->int, and only a member that takes nothing can +be bound to a variable`). + +Calls go through the port by name, `Clock.now()`. `clock` never names `app`, so it needs no +`uses app`; the binder must be able to see the port - it is `export`ed, and a binder that says +`uses` names the port's module. What the bind names (`fn game_hours`) is checked from the bind's +own file. The compiler refuses: + +``` +app.ludic:5: error: port Clock is used here but never bound: the program has to say 'bind Clock { ... }' once, where it is put together +app.ludic:9: error: bind Clock leaves out now, which has no default +app.ludic:11: error: port Clock is bound twice (first at app.ludic:10) +app.ludic:9: error: port Clock has no member later +``` + +and a member of the wrong type is a type error like any other (`field now of Clock_port wants a +fn()->int and this is a fn(int)->float`). A port nobody uses may stay unbound, and so may a port +whose every member has a default: unbound, it answers with its defaults - which is how a +package's fallbacks ("unbound: this machine runs the world") are written. + +### State: no function writes a global (`state`, `mut`) + +A function that changes a module variable it was not given is a hidden coupling: nothing in its +signature says what it touches, and it cannot run without the whole program around it. So a +module-level `var` is refused, and a module's changing data is a **state** record instead: + +```ludic +# doc-check: skip — a fragment +state Hiker { + hips: int = -1 + spine: int = -1 +} + +function hiker_bind(h: mut Hiker, sk: Skin) -> void { + h.hips = skin_joint(sk, "hips") + h.spine = skin_joint(sk, "spine") +} +function hips_of(h: Hiker) -> int { return h.hips } +``` + +- **One instance, which no code names.** The program holds exactly one of each `state`, made + before any code runs. It reaches code only as a parameter: `h: mut Hiker` may change it, `h: Hiker` + may only read it. So a function's signature is everything it reads and writes, and a test hands + it a plain value. +- **Read-only is checked where it is written.** Through a read-only state the compiler refuses an + assignment whose target starts at it (`h.hips = 1`, `h.list[i] = x`), a `push` onto something in + it, and passing it where a `mut` one is wanted (`bump changes Tally (c: mut Tally), and c is + read-only here`). A reference read out of it into a local (`let l = h.list`, `let r = h.rows[0]`) + is read-only too, so a write through that is refused the same way; a value read out (`let n = + h.count`) is a copy and the local's own. `machine h.mode` writes its store on every `become`. + `mut` is for a state parameter only. +- **The runtime supplies it at the entry points** - the only code nothing in the program calls: + - a body that declares it: `entry (h: mut Hiker) { ... }`, `handler Draw(h: Hiker) phase Render + { ... }`, `@On(Ping) handler Heard(h: mut Hiker) { ... }`, `@OnSpawn(M) handler Made(h: mut + Hiker) { ... }`, a scene's `on enter (h: mut Hiker) { ... }`, `test "name" (h: mut Hiker) { ... }`; + - a retained `ui` block, which names a state's instance by the state's name: `font: Menu.title_font`; + - a component, whose header names its states: `component Tally (score: mut Score) { ... }`; + - a function value: `fn tick` of `function tick(h: mut Hiker, t: Tick)` is `tick` with its + leading states supplied, a `fn(Tick) -> void` - so a system's functions, a port's bind and any + callback a package calls are entry points without saying so; + - a port member bound to a state's field, `bind Purse { money: Wallet.cash }`; + - a call the compiler writes: a namespace method's target (`Weapon.def(...)` of `function + weapon_def(w: mut Weapons, ...)`), an engine system, a runtime built-in. + Every other call passes its states explicitly. +- **Everything else module-level is immutable all the way down.** `let LIMITS: []int = [1, 2]`, + a `const`, a registry: an assignment or a `push` that starts at one is refused, and so is one + through a local that holds part of it (`let r = LIMITS; push(r, 3)`). +- **Tests get fresh states.** Each test block starts from states made new, in its own process under + `ludic test` and in the runner run directly. +- The toolchain's own programs (the compiler, the CLI) are not part of this yet: they build with + `ludicc --globals`, which lets a module-level `var` through. + +The errors: + +``` +counter.ludic:5: error: this assignment: c is read-only here (c: Tally); take it as c: mut Tally to change it +counter.ludic:3: error: a module-level var is refused: a module's changing data is its state (state Name { ... }), passed to the functions that use it - or, if it never changes, a let +counter.ludic:5: error: this assignment: LIMITS is module-level and immutable all the way down; changing data belongs in a state, passed as a mut parameter +counter.ludic:3: error: x: mut int - mut is for a state parameter, and int is not a state +``` + +**`ludic migrate state [file|dir...] [--prune] [--runtime] [--dry-run]` moves programs there.** It compiles +each program and, from the compiler's own view of every name: + +1. a var nothing writes, holding a value (an int, a string, an enum...), becomes a module-level + `let` where it stands; +2. each module's other vars become one state, `state State { ... }`, where the first of + them was - a module's by its name (`module fishing`: `FishingState`), a directory with no module + line by its path (`ludic.render3d`: `Render3dState`), the program's own file by the program's + name (`program SceneDemo`: `SceneDemoState`); the fields keep their comments; +3. every reference to one is rewritten to `_st.` (`scene_demo_st.counter`), and in a + `ui` block to `.`; +4. each function's states - those it touches, and those of everything it calls, to a fixed point - + become its leading parameters, `mut` where it or something it calls writes; a state a run before + declared read-only becomes `mut` where it is now changed; +5. each call passes them on, each entry point declares them (after any it declares already), and + each component's header declares what all its members need. + +Give it every program at once - a directory stands for the test programs under it - and it merges +their plans before it edits anything: programs that share a package agree about it, a module two of +them see different files of is one state, and a path reached as `../../packages/x` is the same file +as `packages/x`. A program's own file keeps a state of its own (named for the program) whatever module it says, and +a `friend module`'s other files go by their directory, since each program declares that module for +itself. A package's var nothing in the given programs writes stays changing data (a game may set +`r3d_dem_path`); only a private one of a package's module becomes a `let`. A name the program uses +that a package migrated earlier moved into its state (`cam_pos`, now `Render3dState`'s) is rewritten +through that state, and a read of the runtime's own var from outside it through the runtime function +that answers it (`gl_w` is `gl_width()`). A program's own module named like a package (a game's `module fishing` beside +`ludic.fishing`) gets a state of its own, `FishingAppState` / `fishing_app_st`, never the package's. +It writes only under the programs and directories it is given (and `runtime/` with `--runtime`): +when the programs need a change in a file anywhere else - an installed package, a module imported +from a neighbouring directory - it says which and changes nothing. It prints what it cannot decide +(a var read in another global's initializer, a reference in generated code), for a person to finish. +A later run finds the states an earlier one made and adds to them. `--runtime` moves the runtime's +own vars too. gpp's packages and examples were moved with one command: + +``` +ludic migrate state packages packages/ludic.lab/example/plate.ludic +migrate: 1804 vars into 126 states, 64 into lets; 23498 edits in 460 files +``` + +**`--prune` takes out what a function no longer uses:** each state parameter that neither the +function nor anything it calls uses, and the argument that fills it at every call. An argument for a +parameter the callee no longer has goes too - a package's verb that dropped a state leaves its callers +passing one too many, and `ludic migrate state --prune ` puts them right. A reducer keeps its +state, and a state declared after a plain parameter (`home_keep(r: Records, save_st: mut Save)`) is +kept where it is. When ludic.base's queues began keeping their own counts, every package verb lost its +`base_st` this way (`wallet_earn(wallet_st, n)`, not `wallet_earn(base_st, wallet_st, n)`): + +``` +ludic migrate state --prune packages packages/ludic.lab/example/plate.ludic +migrate: 0 vars into 0 states, 0 into lets; 1889 edits in 132 files +``` + +### Actions and reducers (`action`, `reducer`, `dispatch`) + +Threading states makes a function's signature say what it touches, and it shows where one function +does everything: an input handler that reads the keys and then changes the world itself takes every +state the world has. An action separates the two. The input says WHAT happened; each module decides +what that means for its own state, and nothing else: + +```ludic +program Pack { + state Bag { + items: []int = new []int + weight: int = 0 + } + state Log { lines: []string = new []string } + action PickUp { item: int, kg: int = 1 } # what happened: a typed record + + reducer Bag on PickUp(b: mut Bag, a: PickUp) { # in the module that owns Bag + push(b.items, a.item) + b.weight += a.kg + } + reducer Log on PickUp(l: mut Log, a: PickUp) { push(l.lines, `picked {a.item}`) } + + handler Keys phase Input { + if Input.key() == 'e' { dispatch PickUp { item: 7 } } # a translator: keys to actions + } +} +``` + +- **`action Name { fields }`** is a record, with defaults like any. `export action` for other + modules to dispatch it. +- **`reducer State on Action(s: mut State, a: Action) { ... }`** WRITES exactly its state, and takes + the action last. Between the two it may declare states it only READS - `reducer Wallet on Buy(w: + mut Wallet, s: Shop, a: Buy)` - supplied like its own, for what is only known inside the drain (a + price another reducer just set, the map in play, where the save lives). A second state to write + is refused (`reducer Pack on Buy: a reducer writes one state, and w is a mut Wallet - read it (w: + Wallet), or dispatch an action Wallet's own reducer takes`), and so is a call inside it to a + function that writes another. What the dispatcher knows rides in the action. Several reducers may + handle one action, one per state; a reducer is not called by name. +- **`dispatch Action { fields }`** queues the action, from anywhere: a handler, a function, a + listener, a reducer. The queue is the runtime's, supplied like an entry point's state, so + dispatching needs no state parameter. +- **When the queue is drained:** at the end of every phase of the frame loop (so what the `Input` + phase dispatches is reduced before `Update`); after every phase of the ludic.base system runner + (`core_tick_all`); when ludic.ui has run a frame's presses (`ui_show`, `ui_press`), so a button's + action is reduced before the host presents the frame, not a frame later; and wherever the program + calls `drain_actions()` (an `entry` program, a test, a loop of its own - and a host that runs UI + presses some other way calls it before it presents). Draining runs the actions in the order they were dispatched, and each action's + reducers in the order of their states' names - never the order of imports - so the same actions + make the same changes on every machine and in a replay. +- **An action a reducer dispatches** is queued behind the rest and reduced in the same drain, never + re-entrantly. A queue still growing after 64 rounds of that stops the program, naming the action: + `actions: Ping is still being dispatched after 64 rounds of reducers - a reducer dispatches what + dispatches it`. +- **Events stay** for what changes no state - a sound, a notice, telemetry - and `@On` listeners run + as the event is emitted. Actions are for changes. + +**A reducer on a row** (27.3). Many of a kind are rows of a ludic.base `Table` (Things, animals, +vehicles), and an action may name ONE of them: its field marked `@Target` holds the row's handle, +and a reducer written `in` the table runs for that row alone. + +```ludic +import "ludic.base" +program Herds { + numbers float + property Deer { + @Column x: float = 0.0 # a table column mirrors it + fear: int = 0 + } + state Herd { deer: Table = null } + state Noise { loud: int = 2 } + action Spook { @Target who: int = -1, by: int = 1 } + action Bolt { @Target who: int = -1, dx: float = 0.0 } + + reducer Deer in Herd.deer on Spook(r: mut Row, n: Noise, a: Spook) { + r.rec.fear += a.by * n.loud + if r.rec.fear > 3 { dispatch Bolt { who: r.h, dx: 5.0 } } + } + reducer Deer in Herd.deer on Bolt(r: mut Row, a: Bolt) { deer_move(r, r.rec.x + a.dx) } + + @RowVerb function deer_move(r: mut Row, x: float) -> void { + r.rec.x = x + tb_set_f(r.tb, 0, r.row, x) # the column and its indexes, with the field + } + entry (h: mut Herd) { + let tb: Table = table_new(1, 0) + h.deer = tb + dispatch Spook { who: tb_add(tb, new Deer), by: 2 } + drain_actions() + } +} +``` + +- **`reducer Record in State.table on Action(r: mut Row, reads..., a: Action)`** - the + table is a field of the state (a path through its records is fine: `WildState.w.tab`), of type + `Table`, and only the module that owns the state declares a row reducer on it. The row + comes first and `mut`, the action last, and between them states it only READS, as any reducer's. +- **`@Target`** marks the action's one field holding the handle (an `int`, what `tb_add` returned). + One to an action: several rows are the dispatcher's loop, one `dispatch` per handle. +- **The drain resolves the handle** (`tb_row`) and hands the reducer a `Row` (ludic.base: + `tb`, `row`, `h`, `rec`) it keeps, one per row reducer and filled in place, so a targeted action + allocates nothing. A handle whose row is gone - the animal removed since the dispatch - runs + nothing, which is not an error; `LUDIC_ACTIONS_LOG=1` prints a line for it. Row reducers take + their place among the action's reducers by their state's name, then the table's. +- **The row goes no further.** Inside a row reducer `r.rec` and `r.h` are all it reaches: `r.tb` + and `r.row` are refused, the view is not assigned, stored, copied or handed to anything but a + **`@RowVerb`** - a function of the record's own module that takes the row first - and a field + marked **`@Column`** (one a table column mirrors: a position, a kind, whether it is alive) is not + written through `r.rec`: `reducer Deer in Herd.deer on Push: Deer.x is mirrored by a column of the + table (@Column) - write it through a @RowVerb, which keeps the column and its indexes with it`. + What `things_verify` catches at run time is a compile error inside a row reducer. +- **Dice** a row reducer rolls come from the row's own `Rng` or its owner's, never `Random.*`: it + runs in the drain, outside its owner's tick, and the world's stream is co-op's shared order. + +**A machine as data** (27.1). A row's state - an animal idling, fleeing, drinking - is an enum field +of its record, and the machine that moves it is a registry marked `@Machine(Record.field)`: one row +a transition, which the compiler turns into the reducers and the tick. The table is the whole +machine, and a studio edits it as a graph. + +```ludic +import "ludic.base" +program Moods { + enum Mood { Calm, Wary, Fled } + property Deer { + mood: Mood = Mood.Calm # the start: the field's default + fear: int = 0 + } + state Herd { deer: Table = null } + action Spook { @Target who: int = -1 } + property DeerStep { + from: Mood = Mood.Calm + to: Mood = Mood.Calm + on: string = "" # an action's name, or "" for a transition the tick asks + guard: fn(Row, Herd) -> bool = null + enter: fn(Row, Herd) -> void = null + } + @Machine(Deer.mood) registry DeerSteps of DeerStep + def DeerSteps startled { from: Mood.Calm, to: Mood.Wary, on: "Spook", enter: fn deer_startle } + def DeerSteps bolts { from: Mood.Wary, to: Mood.Fled, on: "Spook", guard: fn deer_afraid } + def DeerSteps settles { from: Mood.Wary, to: Mood.Calm, guard: fn deer_settled } + def DeerSteps home { from: Mood.Fled, to: Mood.Calm } + + function deer_afraid(r: Row, h: Herd) -> bool { return r.rec.fear > 1 } + function deer_settled(r: Row, h: Herd) -> bool { return r.rec.fear == 0 } + function deer_startle(r: mut Row, h: Herd) -> void { r.rec.fear += 1 } + entry (h: mut Herd, m: mut DeerStepsMachine) { + h.deer = table_new(0, 0) + dispatch Spook { who: tb_add(h.deer, new Deer) } + drain_actions() + deer_steps_tick(m, h) + } +} +``` + +- **The registry's record** has `from` and `to` (variants of the field's enum), `on: string` (an + action's name, `""` for a transition the tick asks), and may have `guard: fn(Row, reads...) + -> bool` and `enter: fn(Row, reads...) -> void`, each taking the row first and then the + states it reads. Its rows may live in an `.lres` (`from "deer_steps.lres"`), where a value is + written as in code: `bolts { from: Mood.Wary, to: Mood.Fled, on: "Spook", guard: fn deer_afraid }`. + The states are the enum's variants; the record is held in one state's `Table`, and the + registry is that state's module's. +- **What the compiler writes.** For each action an `on` names (it must have a `@Target`), a row + reducer: the row's current state, the first transition from it on that action - in the table's + order - whose guard passes (no guard passes always), the field set, then `enter`. When a row + leaves a state on a guard alone, `state DeerStepsMachine` (the tick's kept row view) and + `deer_steps_tick(m: mut DeerStepsMachine, s: mut Herd, reads...)`, which takes every row of the + table through the same first match, one transition a row a tick; the program calls it from its + system. A program's own row reducer on the same action runs before the machine's, so an enter + that needs the action's payload finds it on the row. Nothing is allocated: the row views are kept. +- **The table is the whole machine.** Its field is written by nothing else - an assignment or a + `machine` block's `become` anywhere else is refused: `this assignment: Deer.mood is the machine + DeerSteps's (@Machine(Deer.mood)) - it changes only by a transition in its table`. A new row takes + its state in its `new` (a save's load does too). A guard and an enter are functions of the + record's module and keep a row reducer's rules - the row reaches `r.rec` and `r.h`, goes only to a + `@RowVerb` or another of the machine's functions, and a `@Column` field is not written; a guard + asks and writes nothing through its row. +- **The graph is checked**, each an error at its row: a state never reached from the start; a state + with no way out; an `on` naming no action, or an action naming no row; a transition from a state + to itself with no guard; and two ways out of one state on one trigger behind an unguarded first + (`settles and stays both leave Wary on Spook, and settles has no guard - stays could never be + taken`). +- `ludic schema`'s code section lists each machine (`machines`: `registry`, `record`, `field`, + `enum`, `table`, `start`, `states`, `actions`, `tick`, `module`, `at`), and `ludic deps` names its + reducers `reducer Deer in Herd.deer on Spook (machine DeerSteps)`. The `machine` block stays for + a machine that is only code. + +`ludic deps` reports the widest function - the most states any function or entry point of the +program's own takes - and `--check` holds it as a ratchet like its other numbers +(`widest_function 12` in the baseline file). A function value's states are supplied where it is called, so a +step list - `let STEPS = [fn a, fn b]` walked by a function that takes nothing - hides what it +touches; `widest_reach` is the most states any function can come to, through its calls, the `fn f` +it writes and the globals holding fn values it reads (a registry of systems), and the report says how +many of them it does not take itself. `ludic deps --widest N` lists the N functions that take the +most states, each with what it reaches; `--reach N` lists them by reach. + +### Types are checked before anything is emitted + +Between the parse and the emitter a checker walks every function, the entry, the tests, the +globals' initializers and every `@On` listener, and refuses a program whose types do not agree - +all of its mix-ups at once, each at its own line: + +``` +trip.ludic:12: error: metres wants an int and this is a float +trip.ludic:14: error: area takes 2 argument(s) and this call gives 1 +trip.ludic:20: error: + of a string and an int: text joins text only - write string(x) for a number +3 type error(s) +``` + +What it holds apart: `int`, `float`, `fixed` and `bool` (a float or a fixed into an int is +`int(x)`; a float and a fixed never meet but by a literal, which takes whichever kind its slot is); +text and numbers (`string(n)` or a template); one record type and another; slices of different +elements; functions of different types. A call gives exactly as many arguments as there are +parameters, a `return` gives the declared result, and `push` gives the slice's own element. A +function names each parameter once (`add names two parameters n`). A type written in a parameter, a +result or a field names a declared type (or one of the declaration's type parameters): a misspelling +is refused there (`kind_of's parameter f: there is no type CharFact`), not later as a member access +that makes no sense. + +`pointer` is untyped, as `void *` is in C: it goes wherever a reference is wanted and takes any +reference, and a `[]pointer` any slice of references. Restricting what a raw pointer may reach is +`unsafe`'s job, not the checker's. Bits cross between kinds by name - `as_int(x)` / `as_fixed(n)` +reinterpret a word, `float_bits(f)` / `float_from_bits(i)` a float's - never by a slot's type. + +A name the checker cannot type (an engine namespace's arguments, a query's bindings) agrees with +everything, so it only ever reports what it can prove; the emitter keeps its own checks behind +it. `LUDIC_CHECK_REPORT=1` lists every mix-up by category and fails nothing, which is how an +existing program is measured before it has to pass. + +### Generic records and functions + +A record or a function can take type parameters, written after its name: + +```ludic +program Pools { + property Pool { + items: []T = null + n: int = 0 + } + function pool_new() -> Pool { + let p = new Pool + p.items = new []T + return p + } + function pool_add(p: Pool, x: T) -> void { + push(p.items, x) + p.n += 1 + } + function map(xs: []T, f: fn(T) -> U) -> []U { + let out = new []U + var i = 0 + while i < len(xs) { + push(out, f(xs[i])) + i += 1 + } + return out + } + entry { + let names: Pool = pool_new() + pool_add(names, "Crater Lake") + print(names.n) + } +} +``` + +A type names an instance with its arguments - `Pool`, `Pair`, +`Pool>`, `[]Pool` - and two instances of one generic are two types. A call's +type arguments are worked out from its arguments (`pool_add(names, "x")` is `pool_add` at +`string`), a literal deciding only what nothing else did; a call with nothing to say it, like +`pool_new()`, takes them from the slot its result is written into - a `let` with a declared type, +an assignment, an argument, a `return`. Where neither decides, the call is refused and says which +parameter it could not tell. + +Generics are compiled by instantiation: each instance the program uses is an ordinary record or +function, made and checked once, so it costs exactly what writing it out by hand would. A generic +nothing instantiates is not compiled at all. + +### Namespaces declared in Ludic (`alias`) + +A namespace method can be a name for a function. Inside a `namespace` block, + +```ludic +program Trails { + namespace Trail { + export alias length(from, to) = trail_distance + alias km = trail_km + } + function trail_distance(a: int, b: int) -> int { return b - a } + function trail_km(metres: int) -> int { return metres / 1000 } + entry { print(Trail.length(to: 12, from: 2) + Trail.km(metres: 5000)) } +} +``` + +makes `Trail.length(...)` a call to `trail_distance`: the list after the method is the labels a call +may name its arguments by, in the target's parameter order, and without one the target's own +parameter names are the labels (`alias show() = present` takes none). The call is the target's - +same arguments, same checks, same code - so a namespace costs nothing over calling the function. + +This is how the engine declares its own namespaces: `Http`, `Udp`, `Process`, `Json`, `Value`, +`Screen`, `Input`, `Audio`, `World`, `Tiled` and the rest are `alias` blocks in +`runtime/native/namespaces.ludic`, not branches in the compiler, and a package owns an API the same +way in its own files. What is still built into the compiler is the namespaces that compute inline - +`Math`, `Text`, `List`, `Vector`, `Color`, `Time`, `Date` - and the few methods that choose their +target by an argument's type (`Audio.play` of a handle or a name). + +A function of the program's own that is named like the target of one of the engine's namespace +methods would take that method's calls - `Random.range` is `rng_range`, so a package's +`rng_range(a, b, c)` would receive every `Random.range(1, 6)`. Where the program calls that method, +the function is refused (`rng_range is the engine's Random.range, which this program calls (...), +and every such call would reach this function instead; choose another name`), as a function named +like a compiler built-in (`run`, `exit`) or like one of the engine runtime's own functions is. So +is a type - a property, record, enum, event or state - named like one the runtime declares in a file +the program uses: `property PadButton` beside the runtime's `enum PadButton` used to compile and +then fail in clang, where the two layouts met (`PadButton is the runtime's enum +(runtime/native/input.ludic); choose another name for this property`). + +### Registries (`registry`, `def`) + +A table of records that code used to fill with calls in an init function is declared instead: + +```ludic +program Camp { + property Furnishing { + key: string = "" + name: string = "" + cost: int = 0 + } + registry Furnishings of Furnishing as HF + def Furnishings chair { name: "Camp chair", cost: 60 } + def Furnishings crate { name: "Crate", cost: 30 } + entry { print(Furnishings[HF_CRATE].cost + HF_COUNT) } +} +``` + +`registry NAME of RECORD [as PREFIX]` is a global `[]RECORD`, and every `def NAME key { ... }` is one +entry of it - in any file, collected in source order, and in the table before any code runs. Each +entry gets an index constant, `PREFIX_KEY` (the prefix defaults to the registry's name in capitals), +in declaration order, and the registry a count, `PREFIX_COUNT`. When the record has a `key: string` +field it is filled with the entry's key, and `name_find(key)` (the registry's name in lower case) +returns its index or -1. A def's fields are checked against the record like any record literal, a +key is declared once, and `export registry` exports the table, its constants and its lookup. + +Because the index is the order of the defs, a table stored by position - a save, a setting - keeps +the rule it always had: add an entry at the end, never between two. The order is the order the +compiler reads them in, and an `import` is read where it stands: a file's imported defs come before +the defs written after the import line. + +A registry belongs to its module, and a `def` written in another module is refused - unless the +registry says `open`: + +```ludic +# doc-check: skip — a module spans files +# core/index.ludic +module core +export property System { key: string = "", run: fn() -> int = null } +export open registry Systems of System as SY +def Systems clock { run: fn clock_run } + +# weather/index.ludic +module weather uses core +def Systems weather { run: fn weather_run } # weather_run may stay private to weather +``` + +A def into an open registry goes through visibility like any other reference: the registry must be +exported, and a module that says `uses` names the registry's module. What the entry itself names +(`fn weather_run`) is seen from the def's own module. The errors: + +``` +game.ludic:4: error: def Tools saw: registry Tools is not open to other modules; declare it 'open registry Tools' in module kit, or write the def there +game.ludic:4: error: Tools is private to module kit; mark it 'export' where it is declared (kit/index.ludic) +``` + +**The index order of an open registry** is the declaring module's own entries first, in the order +they are read, then every other module's: the modules in the order of their names, each one's +entries in the order they are read. `SY_CLOCK` is 0 however the program imports things, and an +entry from `alpha` comes before one from `zeta` even when `zeta` is imported first - so a table +saved by position keeps its meaning when a barrel's imports are reordered. The rule for a saved +table is still to append: a new entry goes at the end of its own module's list, and a new module +whose name sorts before an existing one moves that one's entries along. A registry whose defs are +all in its own module (or in no module) keeps exactly the order it always had. + +### Resource files (`registry ... from`) + +A registry's entries can live in a data file instead of the source: + +```ludic +# doc-check: skip — the file it names is beside the example +registry Tools of Tool as TL from "data/tools.lres" +``` + +``` +# tools.lres +axe { + name: "Axe" + weight: 1.5 + uses: [{ verb: "Chop", minutes: 20 }, { verb: "Split", minutes: 10 }] +} +lantern { name: "Lantern", weight: 0.75, uses: [] } +``` + +The file is a list of entries, each a key and a record, with `#` comments. It is read when the +program is compiled: the registry's record is its schema, so every entry is checked as a `def` is +- a field the record does not have, a string where it wants a number, a key twice - and the error +names the line in the resource file. A field whose type is a record takes a bare `{ ... }`, and one +typed as a list of records takes `[{ ... }, ...]`; the schema supplies the type. Values are Ludic +expressions in the registry's module, so an entry refers to another table's entry by its constant. +The entries are compiled in: nothing is parsed at start-up, and a build that succeeds has checked +every resource it uses. The path is the project's (where the build runs), else beside the file that +declares the registry. + + +A module that extends an open registry can bring its entries from a resource file of its own: + +```ludic +# doc-check: skip — the file it names is beside the example +import "crafting" # module crafting: export open registry Recipes of Recipe as RC +def Recipes from "recipes.lres" # the game's recipes, after crafting's own +``` + +`def REGISTRY from "file.lres"` reads the file as `registry ... from` does - the project's path, else +beside the file that says it - and checks every entry against the registry's record, an error +naming the resource file's own line (`bad_recipes.lres:3: error: field minutes of Recipe wants an +int and this is a string`). Its entries are defs of the module that wrote the line, so the registry +must be open (and exported) to it, and they take that module's place in the order: the declaring +module's entries, then each other module's by module name, and within a file, file order. + +### Map-scoped tables (`@PerMap`, `@Chunked`) + +A registry can hold what is on a map rather than what is in the game: its rows are not compiled in, +they are read when a map loads, from that map's own directory. + +```ludic +# doc-check: skip — its rows are files under each map's directory +property PropRow { + key: string = "" # the entry's key, as every registry record + @Ref(Models) model: int = 0 # a literal, or any constant by name: MDL_TENT2 + x: float = 0.0 + @Unit("deg") yaw: float = 0.0 + @Ref(Spots) home: string = "" # a row of another map table, by its key +} +@PerMap @ByKey registry Props of PropRow from "props.lres" +@PerMap @Chunked(64) registry Instances of InstRow from "instances/{cx}_{cz}.lres" +``` + +The maps are the directories under the maps root, which `package.ludic` names (`maps "assets/maps"`, +the default; taken relative to the package's directory): `assets/maps/maroon/props.lres`, +`assets/maps/maroon/instances/12_-3.lres`. `from` is relative to the map's directory; in a +`@Chunked(n)` registry, `{cx}` and `{cz}` are the chunk's integer coordinates, `floor(x / n)` and +`floor(z / n)`, written as decimals (`-3`), and they go in the file's own name, not a directory above +it. The files are ordinary `.lres` entries (`key { field: value, ... }`, see Resource files), read +through the same open every asset takes, so a mounted pack serves them and a dev run reads the +directory. A map's row may hold an `int`, a `float`, a `bool`, a `string`, a nested record, a list of +any of those (not a list of lists or of `fn` values), or a `fn` value written `fn name` (resolved +among the functions of the field's type the registry's module can name). An `int` field takes a +literal or any `int` constant by name, a `float` field any number or number constant; the program +carries its constants by name for that, when a `@PerMap` registry exists. The record's first field +is `key: string`, filled from the entry's key. Rows are in file order. + +A `@PerMap` registry has no `as PREFIX` and no `_KEY` constants - its keys are not known when the +game compiles - takes no `def`, and is never `open`. The compiler writes, in the declaring module and +exported with the registry, a `state` named after it and its verbs, prefixed with the registry's name +in snake case (`GroundLayers` -> `ground_layers_`): + +```ludic +# doc-check: skip — what the compiler writes for the two registries above +state Props { rows: []PropRow, map: string, err: string, ... } +props_load(st: mut Props, map: string) -> bool # //props.lres; false + st.err if missing or wrong +props_clear(st: mut Props) -> void +props_find(st: Props, key: string) -> int # the row's index, or -1 +props_path(st: Props, rel: string) -> string # "//", for an asset a row names + +property InstancesChunk { cx: int, cz: int, on: bool, rows: []InstRow, ... } +state Instances { chunks: []InstancesChunk, map: string, err: string, ... } +instances_in(st: mut Instances, map: string, cx: int, cz: int) -> int # its slot; no file is an empty chunk, a wrong one -1 + st.err +instances_out(st: mut Instances, cx: int, cz: int) -> void +instances_slot(st: Instances, cx: int, cz: int) -> int # -1 when that chunk is not in +instances_find(st: Instances, slot: int, key: string) -> int # a row of that slot, or -1 +instances_clear(st: mut Instances) -> void +instances_path(st: Instances, rel: string) -> string +``` + +Read `props.rows[i]` and `len(props.rows)`, `st.chunks[s].rows`. An error is `"file:line:col: what"` +(`maps/alpha/props.lres:2:18: PropRow has no field colour`, `unknown constant MDL_TENT3`), and a +failed load leaves the table empty, never half filled. `_find` is a hash over the row keys, rebuilt +in place on every load: O(1) and allocation-free. `_in` for a chunk that is already in hands back its +slot; `_in` with another map than the one the chunks came from puts every chunk out first. `_path` +makes one string: it is for load time, not a frame. A row's id for co-op is `(map, key)` for a +whole-map table and `(cx, cz, key)` for a chunked one - both stable, because they are data. + +**The table owns its rows, and everything in them.** Every row record, every list inside a row and +every record in such a list is pooled: a reload of the map, or a chunk slot refilled, resets and +refills them in place - the rows list and each row's lists are emptied and refilled, each record set +back to its record's defaults (a template made once) - and a record is made only when a load needs +more of its type than any load before it. Nothing is allocated past that high water, so a frame may +call `instances_in`. Never keep a row, a row's list or a chunk's rows across a load or an `_out`: keep +the key or the index, or copy the numbers. A string field, and a whole-map table's key, is interned +and safe to keep. A CHUNKED table's keys are unique across its map (`t0` .. `t91842`: a row's id is +`(map, key)`, and a tree moved into another chunk keeps it; `ludicc --check` refuses a key written in +two chunk files, naming both), so they are NOT interned - interning every key a player walks past would +fill the bounded intern table and keep them all: a slot's keys live in the slot's own buffers, +rewritten when the slot is refilled. Keep such a key past `_out` with `intern(row.key)`. A record may hold a record of its +own type only through a list. The reader +(the runtime's `lres.ludic`) keeps its own buffers the same way: the file's bytes in one that grows +only for a bigger file, its tree as parallel lists reused from file to file. + +`@Ref(T)` where `T` is a `@PerMap` registry goes on a `string` field holding the row's key (an `int` +field is refused: "use a string key"); its schema attribute says `"scope": "map"`. + +**Every map is checked with the program.** `ludicc --check` (and `ludic build --check`) reads every +directory under the maps root as a map and checks, with the compiler's own resource parser, each +registry's file in it (each file a chunked pattern matches) against the record: the field exists, +its value has the field's type, a constant it names exists, `fn name` names a function of the +field's type, `@OneOf` and `@Range` hold, an `@Ref` into a game registry is in range, and an `@Ref` +into a `@PerMap` registry names a row of that table in the same map (in any of its chunks; `""` is +none). A key is written once in a file. Errors carry the map file's line and column, through the +diagnostics as any other (`--diagnostics=json`); `--no-maps` leaves the maps alone, and no maps +root is nothing to check. `ludic build --check` reads them only for the package's `entry` (the game): +a partial program - a unit test, a molecule, a bake's runner - lacks the game's constants and cannot +judge them, so they are left alone there unless `--maps` asks. `LUDIC_PERMAP_SRC=` appends what the compiler wrote for each table. + +### Editor attributes and the schema (`@Ref`, `@Range`, ..., `ludic schema`) + +A field can say what an editor of the data should offer for it, and a registry how its entries may +change, on the same `@` a field's `@max(64)` is written with - one or several, on the field's line or +the lines above it: + +```ludic +# doc-check: skip — the registries it names are declared elsewhere +property Tool { + @Ref(Vendors) seller: int = 0 # an index into that registry: its entries are offered + @OneOf(GR_) grade: int = 0 # one of the constants whose names start GR_ + @OneOf(GR_GOLD, GR_SILVER) medal: int = 0 # or one of these constants + @Range(0, 20.5) @Unit("kg") weight: float = 1.0 + @Asset("gltf") model: string = "" # a file of that kind (any string) + @Asset("png", map) density: string = "" # a path under EACH map's directory (assets/maps//...) + @Color tint: int = 0 + @Node(model) grip: string = "" # a node inside the glTF that `model` names + @Clip(model) swing: string = "" # a clip inside it + @Material(model) finish: string = "" # a material inside it + @OneOf("box", "hull") shape: string = "" # a string field: one of these words + @Color @Tint(TSLOT_SHELL) shell: int = 0 # a colour for that tint slot + @Derived reach: float = 0.0 # worked out at boot: anything written is overwritten + @Text @Multiline blurb: string = "" # read by the player (so translated), and prose + @Key bind: int = 0 # a key code +} +@AppendOnly @ByKey +registry Tools of Tool as TL from "data/tools.lres" +``` + +`@Unit` takes one of the canonical ASCII spellings - `m`, `m/s`, `m/s2`, `s`, `min`, `h`, `d`, `deg`, +`rad`, `rad/s`, `kg`, `N`, `N.m`, `%`, `px` - so one word means one unit to an editor and a converter: +the angle is `"deg"`, never `"°"`. Any other spelling is a warning naming the canonical one where +there is an obvious one (`"°"`, `"degrees"` -> `deg`, `"sec"` -> `s`, `"m/s^2"` -> `m/s2`, `"Nm"` -> +`N.m`), else listing them; the schema carries the list as `"units"`. + +They change nothing the program does. A target that no part of the program declares - the registry +an `@Ref` names, the constant of an `@Tint` or a listed `@OneOf` - is a warning, and the schema marks +the attribute `"unresolved": true`: a package can name the game's registry without importing it. +Everything else is an error, every one reported: a target that exists but is another kind +(`@Ref(Vendor)` on a record); `@OneOf` of the wrong kind for its field - on a string field the +arguments are words (`@OneOf("box", "hull")`) and every registry row's value must be one of them, on +any other field a prefix ending in `_` or constants; and `@Node(f)`, `@Clip(f)` or `@Material(f)` +naming anything but a field `f` of the same record that is `@Asset("gltf")`, or an `@Ref` to a +registry whose record has exactly one `@Asset("gltf")` field (the model is then the row's). `@Asset(kind, map)` names a file under each map's +directory, not the game's root: `ludicc --check` looks for it in every map - a @PerMap row's in its own +map, a game-wide row's in all of them - and refuses a map that lacks it, unless the field says +`@Asset(kind, map, optional)`; the schema marks it `"scope": "map"`. A plain `@Asset(kind)` is not +looked for. + +`ludicc app.ludic --emit-schema out.json` (or `ludic schema [file] [-o out.json]`) writes what the +compiler resolved, once the types are checked and every open registry has its entries, as one JSON +object with `"schema_version": 1`: + +- `records` - every `property`, `state` and `event`: its module, file, line and column, its doc + comment (the comment lines above it, else the one ending its line), and its fields, each with its + type as text, its default as written (or null), its doc, its place and its attributes + (`[{"name": "Range", "args": [0, 20.5]}]`); +- `registries` - every registry: its record, prefix (null for a `@PerMap` one), resource file, + whether it is open, its `"scope"` (`"map"` for `@PerMap`, else `"game"`), its `"chunk"` size (or + null) and, for a map-scoped one, the `"maps"` root; its own attributes, and its entries in their final index order (`{"key": "axe", "constant": "TL_AXE", + "index": 0, "file": ..., "line": ..., "col": ..., "fields": [{"name", "value", "file", "line", + "col"}]}`), with `contributors`: which resource file or file of `def`s brought which keys in; +- `consts` - every const: its type, its value as written, its module and doc; +- `functions` - every function a `fn` value can name, exported or not: its module, return type, + `params` (the arguments a caller passes), `states` (what the runtime supplies), `signature`, and + `fn_type` - the type a `fn` field sees, the states stripped, spelled as a field's type is + (`fn(NpcPerson,float)->bool`), so matching a function to a field is comparing two strings. +- `components` - every UI component (see Components): its module, place, doc, `xml` and `lss` (the + template's and the stylesheet's paths, `lss` null when it has none); `props` and `state`, the + instance's own fields in the order written, each with its type, its default as written (or null), + its place and doc (a prop's `attributes` are `[]`: a component's members take none); `states_read`, + the program's states its header names, which the runtime supplies and the template never sees; + `derived`, the fields worked out each frame, with their types (an inferred one resolved); + `functions` (`{"name", "params": [["i", "int"]], "result"}`) and `events` (its `on` handlers, + `{"name", "params"}`) as the template calls them, the header's states and the instance stripped; + and `natives`, the registered native tags its template uses as elements; +- `natives` - every `ui_native` / `ui_native_input` call whose tag is a string literal: + `{"tag", "via", "handler", "module", "file", "line", "col"}`, `via` the function called and + `handler` the `fn` value it passes last (else null). A tag worked out at run time cannot be known + and is left out. +- `units` - `@Unit`'s canonical spellings. +- `lang` - the text keys and the languages (see Text keys), or null without a `lang` line. +- `code` - the program's code map, so an editor needs no scan of its own. Every place is + `"file:line:col"` (`"at"`); what the runtime declares and what the compiler writes itself are left + out. It holds: + - `modules` - `{"name", "package", "layer", "uses", "uses_at", "friend", "files"}`: `uses` null + without a `uses` line, `friend` null, or `{"of": null}` for a friend of every module and + `{"of": [...]}` for `friend module lab of a, b`; `files` its source files and the resource files + read into its registries; + - `states` (`{"name", "module", "at"}`), `actions` (`{"name", "module", "at", "target"}`, + `target` its `@Target` field or null), `reducers` (`{"state", "action", "module", "at"}`, a + state's), `row_reducers` (`{"record", "table", "state", "action", "target", "predicted", "net", + "module", "at"}`: `table` the path as written, `State.field`; `predicted` false and `net` null + until `@Predicted` and `@Net` reach reducers), `row_verbs` (`{"name", "record", "module", "at"}`) + and `dispatch` (every `dispatch`: `{"action", "module", "at"}`); + - `events` - `{"name", "module", "at", "cancellable", "net"}` (`net` `"toserver"` for + `@ToServer`, `"toclients"` for `@ToClients`, else null), its `emits` (every `emit`'s place) and + its `listeners` (the `@On` handlers: `{"handler", "module", "at"}`); + - `ports` - `{"name", "module", "at", "bound", "members"}`, each member `{"name", "type", "default", + "required"}` (its `fn` type as a field's is spelled, its default as written); and `binds` - one + row per member a `bind` gives: `{"port", "member", "fn", "value", "module", "at"}`, `fn` the + function a `fn name` names (null for a variable bound to a member that takes nothing) and + `value` as written; + - `handlers` - `{"name", "module", "at", "phase", "hook", "target", "public", "net", "queries", + "scene", "layer"}`: `phase` null for a hook, `hook` null or `"On"`, `"OnSpawn"`, `"OnDespawn"`, + `"OnAttach"`, `"OnDetach"`, `"OnEnable"`, `"OnDisable"`, `"OnStart"`, `"OnQuit"` with `target` + the event, model or property it names; `net` `"server"` (`@Server`), `"predicted"` + (`@Predicted`) or null; `queries` null or `{"these": [{"property", "filter"}], "on"}` (a + filter as written, or null); a scene's handler has its name as written and its `scene` and + `layer`; + - `models` (`{"name", "module", "at", "owned", "properties": [{"name", "sync"}]}`), `prefabs` + (`{"name", "model", "module", "at", "components": [{"property", "fields": [{"name", + "value"}]}]}`) and `scenes` (`{"name", "module", "at", "start", "public", "shows", "lasts", + "then", "loads", "enter", "exit", "layers"}`: `lasts` as written, `then` the scene it goes on to, + `loads` the scene a `loads then` one goes on to, `enter` / `exit` whether it has the block); + - `fn_refs` - every `fn name` written, in code or in a resource file: `{"fn", "module", "at", + "slot_kind", "slot", "entry"}`, the slot it fills: `"registry"` (`"Registry.field"`, `entry` the + row's key), `"port"` (a bind's `"Port.member"`), `"port_default"`, `"default"` (a record + field's default, `"Type.field"`), `"record"` (a `new` or a spawn's `"Type.field"`), `"event"` + (an `emit`'s `"Event.field"`), `"arg"` (`"callee(i)"`, the i-th argument written), `"assign"` + (`s.tick = fn f`: `"s.tick"`), `"let"` (the name), or `"value"` with a null slot. + + Named lists are sorted by name (then file and line), sites by place. + +Each list is sorted by name (then file and line), a registry's entries are in index order, and the +paths are the ones the compiler read, so two runs over the same source write the same file. The +engine's runtime is left out. + +`Build.schema_hash()` is a `long`: FNV-1a 64 of exactly those bytes, for the program being built, +worked out once and only when the program names it - so a tool talking to a running build can tell +whether it was compiled from the data in front of it. It is 0 in a release (`ludicc --release`, +which `ludic bundle` passes). + +### Text keys (`Key`, `k"..."`, `lang`) + +A text the player reads is named by a KEY, and the key's English is a language file like any +other's. `package.ludic` says where the languages are and which is the source: + +``` +lang "assets/lang" en # the directory, and the source language: assets/lang/en.po +``` + +In code a key is a literal of the builtin type `Key` - `k"module.purpose"`, or `kn"..."` for a key +whose text has plural forms - written against its quote (`k "x"` is a name and a string, as ever): + +```ludic +# doc-check: skip — tr / trf / trn are ludic.i18n's +let title = tr(k"pause.resume") # tr(key: Key) -> string +let day = trf(k"hud.day", txt_num(n)) # the English's holes {1}..{4}, in order +let got = trn(kn"catch.count", n, fish) # {1} is the count, the rest follow +``` + +A `Key` is not a `string`, and a `string` is not a `Key`: giving one where the other is wanted is an +error either way, and so is `+` on a key. Keys compare with `==` and `!=`, and are fields, parameters, +results, list elements and registry values like any other type (`null` is none). At run time a key is +its text after a marker byte - byte 1, or 2 for `kn"..."` - so `k"pause.resume"` is the string +`"\x01pause.resume"`: the runtime's `tr` is a cast, and a translator knows a key from English. +`string(key)` gives that marked text, for a runtime that needs it; a program itself makes text with +`tr`, and a key in a template literal's hole (`` `at {k}` ``) is an error - write `trf(k"...", ...)`. +`trn` takes a plural key and `tr` / `trf` refuse one, with or without a `lang` line. (A program that +declares its own type named `Key` keeps it; the builtin is then out of reach.) + +**A package's own words** are keys too. ludic.ui's key field says "Right click" as `ui_tk(ui_st, +k"ui.right_click", "Right click")`: translated through the program's translator when one is bound, the +plain text otherwise (a program with no languages). A program with a `lang` line that imports ludic.ui +therefore carries `ui.right_click`, `ui.middle_click`, `ui.left_click` and `ui.press_key` in its +source `.po`, and the check says so if it does not. + +**The data.** A registry's `@Text` field is text by derivation: its key is +`..` - the registry's name in snake case (`Items` -> `items`, `GearKinds` -> +`gear_kinds`), a list field adding `.` and a nested record `.` - or, with +`@TextKey("steps")` on the registry, `steps..`. A `@PerMap` table's is +`maps....`. When the field's type is `Key` and a row of a compiled registry +gives it no value, the compiler fills in the derived key (marked, as a literal is), so code writes +`tr(Items[i].name)` and the `.lres` never spells a key. The same holds below the row: a nested +record's field adds `.` and a list item `.` (`disruptions..arrive.steps.0.text`), and a +`@Text []Key` a row leaves out takes `<...>.0`, `.1`, ... for as many as the source `.po` has; `field: +null` is no text, and is not checked; a row may still name one, `name: +k"items.lamp.name"`, in a resource file or a map's file alike. + +```ludic +# doc-check: skip — the data file is the game's +property Item { + key: string = "" + @Text name: Key = null # items..name, filled in when the row gives none +} +registry Items of Item as IT from "data/items.lres" +@TextKey("steps") registry StepKinds of StepKind from "steps.lres" # steps.. +``` + +**The templates.** A component's text is a key too: `{t('pause.resume')}`, `title="{t('pause.close')}"`, +`{t('hud.day', day)}`; `t(expr)` takes a key worked out at run time (a `Key` field reaches a template +as its marked text). Words outside an element with `translate="no"` are English still waiting for a +key. + +**The checks.** With a `lang` line and its source `.po` there, the compiler holds every key to it, +each diagnostic at its `file:line:col`: + +- a `k"..."` literal, a template's `t('...')` literal, or a key a data row names or derives that the + source `.po` does not have is an **error**; so is a `kn"..."` whose entry has no `msgid_plural`; +- a call of `trf` or `trn` with a key literal, and a template's `t('key', ...)`, gives as many values + after the key as the English's highest hole `{n}` (`trn`'s count is `{1}`; trailing `""` literals + are padding and not counted), or it is a **warning**; +- **English left** - a template's words outside `translate="no"`, a text attribute's words (`title`, + `label`, `hint`, `text`, `caption`, and any other whose value is not a keyword), a quoted choice + inside a hole that reads as words, and a `@Text` row whose value is still English - is an + **error** (a warning until the migration was done; `ludic deps` still counts it as `english_left`); +- one English under several keys (a split) wants a `#.` description on each, for a translator to + tell them apart: a **warning** at the `.po`'s line. + +With no `lang` line nothing is checked and nothing is said (a package test uses key literals freely); a +`lang` line whose source `.po` is missing checks nothing, and the first key literal says so once. +The source `.po` is gettext's: `msgid` (the key), `msgid_plural` and `msgstr[n]`, `#,` flags +(`fuzzy`), `#.` descriptions, `msgctxt` (read and ignored: the key is the context), strings over several lines; `#~` +entries are obsolete. `ludic schema` carries a `"lang"` object (null without a `lang` line): every +key used with its kind (`code`, `template`, `data`), its sites, its English, whether it is a plural, +and its description; `unused` (the source's keys used nowhere), `undescribed`, `split`, and per other +language in the directory its `missing`, `fuzzy` and `extra` keys. + +### Default parameters, and calls that name what they change + +A parameter can have a default, and a call leaves out what it does not change - the last ones when +it passes arguments by position, or any it does not name. A call can pass its first arguments by +position and the rest by name: + +```ludic +program Boxes { + numbers float + function box(label: string, w: float = 300.0, pad: float = 8.0, bold: bool = false) -> string { + return `{label} {w} {pad} {bold}` + } + entry { + print(box("a")) # every default + print(box("b", 120.0)) # the first two by position + print(box("c", bold: true)) # the subject by position, a prop by name + } +} +``` + +A default is an expression written with the function and evaluated for each call that leaves it +out. A call that leaves out a parameter with no default, names one the function does not have, or +puts a positional argument after a named one is refused with that said. + +### Components and templates (`ludic.ui`) + +A UI is components, and a component is three files side by side: +- `Name.ludic` declares what it takes, keeps and does; +- `Name.xml` is its HTML-shaped template; +- `Name.lss` holds its CSS-shaped styles, which apply to its own elements only. + +```ludic +# doc-check: skip — a fragment of a program that imports ludic.ui +component Counter { + prop label: string # its parent passes it: + prop step: int = 1 # with a default + state count: int = 0 # the instance's own, kept while it is on screen + doubled: int = count * 2 # worked out every frame + function big() -> bool { return count > 3 } # callable from the template + on bump() { count += step } # an event: on-click="bump()" +} +``` + +- **Props and state** are plain names in the component's code; each mounted instance is a record + of them. A prop or state is an `int`, a `float`, a `bool`, a `string` or a `Val`. A field is + anything a template can read, and records and lists become objects and lists. +- **The template** has one root (use `` for several). A component is used by its name as + a tag. `set count = 0` in an action sets its state, and `emit close` runs what its parent passed + as `on-close`. `class`, `style` and `id` on a component's tag land on its root element, styled by + the parent's sheet as well as its own; when that root is itself a component, every user in turn + passes theirs down (the outermost's `id` wins). The rules of all those sheets that match the root + are weighed together by specificity, as one sheet's are, and a user's rule wins a tie. +- **A component's names.** An event may be called anything, `set` included (`on set(v: int)`, + pressed as `set(4)`; `set x = ...` is still the action). A `string` prop given a number reads it + as text (`label="{3}"` is `"3"`). Two components of one name are an error that names both files. +- **Compiled in.** The compiler reads the template and the styles from beside the declaration and + inlines each `@import` (a path starting with `/` is from the project's root), so a missing + template fails the build and nothing has to ship beside the program. `ui_reload()` reads the + files again when they change on disk and keeps every instance's state. +- **A screen is a component** with no props: `ui_show("Counter", null, x, y, w, h)`, or + `ui_nodes("Counter", null)` for a test. +- **States in the header.** A component that reads the program's data names the states it reads + and changes after its name, as an entry point does: + + ```ludic + # doc-check: skip — a fragment of a program that imports ludic.ui + component Tally (score: mut Score, look: Look) { + total: int = score.points + function big() -> bool { return total > look.big } + on add(n: int) { score.points += n } + } + ``` + + Every getter, default, function and event takes them before the instance; a member's call to + another passes them on; ludic.ui's calls into the component are given the instances; and the + template never sees them - `on-click="add(5)"` passes only `5`. A header state without `mut` is + read-only in every member. + +An older, lighter bridge remains: `view Name { ... }` gives a whole template file of ``s +and ``s (loaded with `ui_load`) one model and one call, and the rest of this section +applies to both: + +```xml + + + + + The purse: ${purse} + + + + + + Nothing bought yet + {owned} bought + + +``` + +- **Elements.** A template is HTML-shaped: + - `div`, `section`, `header`, `footer`, `nav`, `main`, `article`, `aside`, `ul`, `ol`, `li` and + `form` are boxes laid out in a column; `row` is one laid out in a row. + - `p`, `span`, `label`, `h1`-`h6`, `strong`, `em`, `small`, `b`, `i` and `a` are text; words + inside a box become a text of their own. + - `button`, `img src`, `hr` and `spacer`; `scroll` is a column that scrolls. + + A default stylesheet, like a browser's, sizes the headings and pads the buttons. +- **Attributes.** `id`, `class` (which may be bound: `class="{picked ? 'on' : ''} row"`), + `style="padding: 4px; color: gold"`, `hidden`, `disabled`, `onclick` or `on-click` (also + `on-press`), and any attribute a property is named after (`width="300"`). Any other attribute is + kept for `[attr=value]` selectors, as HTML's are. +- **The box model and flex.** Sizes are border boxes. + - `padding` and `margin` take one to four lengths, or one side by name (`padding-left`). + - `border` is `2px solid #ffcc00`, or `border-width` and `border-color`. + - A length is `12`, `12px`, `50%`, `fit`/`auto`, `fill` or `calc()`. `width: 0` and `height: 0` + are 0, not unset. + - `calc()` takes `+`, `-`, `*` and `/` and brackets over `px`, `em`, `rem`, `vw`, `vh`, plain + numbers and `var()`; in a `width`, `height`, `top`, `right`, `bottom` or `left` it may also hold + a percentage of the room or of the containing block (`calc(50% - 10px)`). `min()`, `max()` and + `clamp(least, want, most)` pick among lengths, inside `calc()` or on their own + (`width: min(300px, 50vw)`); a percentage cannot be compared there. + - `top`, `right`, `bottom` and `left` take a percentage of the containing block: its width for + left and right, its height for top and bottom. + - `flex-grow` (or `flex`) shares out the spare room along `flex-direction`, and `fill` is a share + of 1. `justify-content` takes `flex-start`/`start`, `center`, `end`, `space-between`, + `space-around` or `space-evenly`. + - `align-items` and `align-self` take `start`, `center`, `end` or `stretch`. `flex-wrap: wrap` + breaks a row into lines, and `min-`/`max-width`/`-height` bound it. + - `flex-shrink` gives up room when a line's children want more than it has, each in proportion + to its shrink times its size, and none below its min size, a fixed size or its content. A scroll + box shrinks (and scrolls) and a box in a column shrinks as far as the scroll boxes in it let it, + so a list in a column takes the room its siblings leave with no height of its own. `flex: 1 0` + is the grow and the shrink. `order` rearranges a box's children without touching the tree. + - `gap`, `text-align`, `display: none`, `background(-color)`, `color`, `opacity` and `font-size` + are CSS's. + + Every property also has a short name (`w`, `h`, `pad`, `bg`, `size`, `grow`, `align`, + `justify`, `self`, `dir`, `wrap`, `alpha`). Rounded corners, font weight and a few more are + things the renderer does not draw, and the runtime says so rather than ignoring them. A colour + is the renderer's name for one, `#rrggbb` or `#rgb`. +- **Stylesheets.** Rules go in a `

a

b

c

" + function at(ui_st: mut UiState) -> string { + ui_show(ui_st, "a", view_anim(), 0.0, 0.0, 200.0, 200.0) + let r: UiNode = ui_nodes(ui_st, "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_anim_st: mut UiAnimState, ui_st: mut UiState) { + ui_load_text(ui_st, PAGE, "a.xml") + var out = at(ui_st) + for i in 0 .. 29 { at(ui_st) } + out = out + " | " + at(ui_st) + ui_anim_st.dim = true + for i in 0 .. 29 { at(ui_st) } + out = out + " | " + at(ui_st) + print(out) + } +} diff --git a/examples/library/ui_box.ludic b/examples/library/ui_box.ludic new file mode 100644 index 00000000..2f8f29ff --- /dev/null +++ b/examples/library/ui_box.ludic @@ -0,0 +1,19 @@ +# ui_box.ludic — what a host reads from outside the interface: ui_scale(), the scale it is drawn at +# (the renderer's), and ui_box("id"), where an element was laid out when its screen was last shown, +# in screen pixels (null for one that is not there) - to frame a 3D view beside a panel. +import "ludic.ui" +program UiBox { + numbers float + const PAGE: string = "

title

" + function scale() -> float { return 1.5 } + entry (ui_st: mut UiState) { + let b = new UiBackend + b.scale = fn scale + ui_backend(ui_st, b) + ui_load_text(ui_st, PAGE, "b.xml") + ui_show(ui_st, "b", null, 20.0, 30.0, 800.0, 600.0) + let p = ui_box(ui_st, "panel") + let none = ui_box(ui_st, "nothing") + print(`scale {ui_scale(ui_st)} | panel {int(p.x)},{int(p.y)} {int(p.w)}x{int(p.h)} | missing {none == null}`) + } +} diff --git a/examples/library/ui_boxes.ludic b/examples/library/ui_boxes.ludic new file mode 100644 index 00000000..7242d520 --- /dev/null +++ b/examples/library/ui_boxes.ludic @@ -0,0 +1,25 @@ +# ui_boxes.ludic — ludic.ui's flex and sizes: a scroll box in a column shrinks to the room its +# siblings leave (flex-shrink) and scrolls what it cannot show; text in a row wraps in the room left +# after the icon beside it; width and height 0 are 0; calc() mixes a percentage with lengths; order +# rearranges a row; and a range's track is a row, its fill as tall as the track. +import "ludic.ui" +import "ui_boxes_parts/Boxes.ludic" +program UiBoxes { + numbers float + entry (ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + ui_show(ui_st, "Boxes", null, 0.0, 0.0, 400.0, 600.0) + let root: UiNode = ui_nodes(ui_st, "Boxes", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 600.0) + let page = root.children[0] + let list = page.children[0].children[1] + let line = page.children[1] + let text = line.children[1] + let zero = page.children[2] + let half = page.children[3] + let ord = page.children[4] + let track = page.children[5].children[0] + let fill = track.children[0] + print(`list {int(list.ch)} of {int(list.content_h)} | text at {int(text.x)} {int(text.cw)} wide, {len(text.lines)} lines, row {int(line.ch)} | zero {int(zero.cw)}x{int(zero.ch)} | half {int(half.cw)}x{int(half.ch)} pad {int(half.pt)} {int(half.pr)} | order {ord.children[0].text}{ord.children[1].text}{ord.children[2].text} at {int(ord.children[2].x)} | fill {int(fill.cw)}x{int(fill.ch)} of {int(track.cw)}x{int(track.ch)}`) + } +} diff --git a/examples/library/ui_boxes_parts/Boxes.lss b/examples/library/ui_boxes_parts/Boxes.lss new file mode 100644 index 00000000..30bfda09 --- /dev/null +++ b/examples/library/ui_boxes_parts/Boxes.lss @@ -0,0 +1,14 @@ +.page { width: 300px; gap: 4px } +.panel { height: 200px; gap: 10px } +.bar { height: 30px } +.list { overflow: auto } +.item { height: 20px } +.line { width: 200px; gap: 8px } +.line img { width: 24px; height: 24px } +.zero { width: 0; height: 0 } +.half { --gap: 10px; width: calc(50% - var(--gap)); height: calc(2 * var(--gap) + 1em); padding: calc(1em / 4) 2px } +.ordered { gap: 0 } +.a { order: 1 } +.b { order: 2 } +.c { order: 3 } +.ui-track { height: 12px; padding: 2px } diff --git a/examples/library/ui_boxes_parts/Boxes.ludic b/examples/library/ui_boxes_parts/Boxes.ludic new file mode 100644 index 00000000..e48029a5 --- /dev/null +++ b/examples/library/ui_boxes_parts/Boxes.ludic @@ -0,0 +1,5 @@ +# Boxes.ludic - a panel with a list that takes the room its header and footer leave, a line of text +# beside an icon, zero sizes, calc(), order, and a range's track +component Boxes { + state rows: int = 10 +} diff --git a/examples/library/ui_boxes_parts/Boxes.xml b/examples/library/ui_boxes_parts/Boxes.xml new file mode 100644 index 00000000..1f25e02c --- /dev/null +++ b/examples/library/ui_boxes_parts/Boxes.xml @@ -0,0 +1,12 @@ +
+
+

Head

+

{r}

+

Foot

+
+

A line long enough that it has to wrap beside the icon

+

hidden

+
+ cab + +
diff --git a/examples/library/ui_calc.ludic b/examples/library/ui_calc.ludic new file mode 100644 index 00000000..7a4eea33 --- /dev/null +++ b/examples/library/ui_calc.ludic @@ -0,0 +1,20 @@ +# ui_calc.ludic — min(), max() and clamp() (on their own and inside calc()), and top, right, bottom +# and left as percentages and calc() of the containing block, for absolute and relative elements. +import "ludic.ui" +program UiCalc { + numbers float + const STYLE: string = "" + const BODY: string = "
" + entry (ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + ui_load_text(ui_st, "" + STYLE + BODY + "", "c.xml") + ui_show(ui_st, "c", null, 0.0, 0.0, 400.0, 600.0) + let r: UiNode = ui_nodes(ui_st, "c", null) + ui_place(ui_st, r, 0.0, 0.0, 400.0, 600.0) + let box = r.children[4] + let p = box.children[0] + let q = box.children[1] + let rel = box.children[2] + print(`min {int(r.children[0].cw)} max {int(r.children[1].cw)} clamp {int(r.children[2].cw)} {int(r.children[3].ch)} | p {int(p.x - box.x)},{int(p.y - box.y)} q {int(q.x - box.x)},{int(q.y - box.y)} r {int(rel.x - box.x)}`) + } +} diff --git a/examples/library/ui_component.ludic b/examples/library/ui_component.ludic new file mode 100644 index 00000000..cac2a70b --- /dev/null +++ b/examples/library/ui_component.ludic @@ -0,0 +1,37 @@ +# ui_component.ludic — components as files: Counter.ludic declares props, state, a field, a function +# and an event; Counter.xml is its template; Counter.lss its scoped styles (which @import a theme). +# Two counters keep their own state, a parent's class reaches the child's root, and the child's +# `p { color }` does not leak into the parent. +import "ludic.ui" +import "ui_component_parts/Counter.ludic" +import "ui_component_parts/App.ludic" +program UiComponent { + numbers float + function texts(n: UiNode, out: []string) -> void { + if n.text != "" { push(out, n.text) } + for i in 0 .. len(n.children) { texts(n.children[i], out) } + } + function frame(ui_st: mut UiState) -> UiNode { + let root: UiNode = ui_nodes(ui_st, "App", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root + } + entry (ui_st: mut UiState) { + var root = frame(ui_st) + let b = root.children[0].children[2] + ui_press(ui_st, b.children[1]) + ui_press(ui_st, b.children[1]) + ui_press(ui_st, root.children[0].children[1].children[1]) + root = frame(ui_st) + let out = new []string + texts(root, out) + var joined = "" + for i in 0 .. len(out) { joined = joined + out[i] + "|" } + print(joined) + let b2 = root.children[0].children[2] + print(`b margin {int(b2.mt)}; b text {b2.children[0].fg}; outside {root.children[0].children[3].fg}; button {int(b2.children[1].ch)}`) + ui_press(ui_st, b2.children[2]) + root = frame(ui_st) + print(root.children[0].children[2].children[0].text) + } +} diff --git a/examples/library/ui_component_parts/App.lss b/examples/library/ui_component_parts/App.lss new file mode 100644 index 00000000..29c72305 --- /dev/null +++ b/examples/library/ui_component_parts/App.lss @@ -0,0 +1 @@ +.wide { margin: 9px } diff --git a/examples/library/ui_component_parts/App.ludic b/examples/library/ui_component_parts/App.ludic new file mode 100644 index 00000000..788d54e7 --- /dev/null +++ b/examples/library/ui_component_parts/App.ludic @@ -0,0 +1,4 @@ +# App.ludic — a screen is a component too: this one has no props, only what it shows +component App { + title: string = "counters" +} diff --git a/examples/library/ui_component_parts/App.xml b/examples/library/ui_component_parts/App.xml new file mode 100644 index 00000000..afd44365 --- /dev/null +++ b/examples/library/ui_component_parts/App.xml @@ -0,0 +1,6 @@ +
+

{title}

+ + +

outside

+
diff --git a/examples/library/ui_component_parts/Counter.lss b/examples/library/ui_component_parts/Counter.lss new file mode 100644 index 00000000..15174c36 --- /dev/null +++ b/examples/library/ui_component_parts/Counter.lss @@ -0,0 +1,3 @@ +@import "theme.lss"; +.counter { padding: 4px } +p { color: #ff0000 } diff --git a/examples/library/ui_component_parts/Counter.ludic b/examples/library/ui_component_parts/Counter.ludic new file mode 100644 index 00000000..8a677ed8 --- /dev/null +++ b/examples/library/ui_component_parts/Counter.ludic @@ -0,0 +1,9 @@ +# Counter.ludic — a component: what it takes (props), keeps (state), works out and does +component Counter { + prop label: string + prop step: int = 1 # how much one press adds + state count: int = 0 + doubled: int = count * 2 + function big() -> bool { return count > 3 } + on bump() { count += step } +} diff --git a/examples/library/ui_component_parts/Counter.xml b/examples/library/ui_component_parts/Counter.xml new file mode 100644 index 00000000..617de5aa --- /dev/null +++ b/examples/library/ui_component_parts/Counter.xml @@ -0,0 +1,5 @@ +
+

{label}: {count} ({doubled}){big() ? ' big' : ''}

+ + +
diff --git a/examples/library/ui_component_parts/theme.lss b/examples/library/ui_component_parts/theme.lss new file mode 100644 index 00000000..c7297243 --- /dev/null +++ b/examples/library/ui_component_parts/theme.lss @@ -0,0 +1,2 @@ +/* theme.lss - shared by whoever imports it */ +button { height: 30px } diff --git a/examples/library/ui_controls.ludic b/examples/library/ui_controls.ludic new file mode 100644 index 00000000..7e4508a7 --- /dev/null +++ b/examples/library/ui_controls.ludic @@ -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(ui_st: mut UiState, i: UiInput) -> UiNode { + ui_input(ui_st, i) + ui_show(ui_st, "Settings", null, 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "Settings", null) + ui_place(ui_st, 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_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + var root = frame(ui_st, new UiInput) + root = frame(ui_st, press(true, false, false, false)) + root = frame(ui_st, press(false, false, false, true)) + root = frame(ui_st, press(false, true, false, false)) + root = frame(ui_st, press(false, false, false, true)) + root = frame(ui_st, press(false, true, false, false)) + root = frame(ui_st, press(false, false, true, false)) + root = frame(ui_st, press(false, false, true, false)) + root = frame(ui_st, press(false, true, false, false)) + root = frame(ui_st, press(false, false, true, false)) + root = frame(ui_st, press(false, true, false, false)) + let typed = new UiInput + typed.typed = "lovelace" + root = frame(ui_st, typed) + let back = new UiInput + back.backspace = true + root = frame(ui_st, back) + root = frame(ui_st, press(false, true, false, false)) + root = frame(ui_st, press(false, false, false, true)) + let k = new UiInput + k.key = 70 + root = frame(ui_st, 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(ui_st, at) + root = frame(ui_st, 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(ui_st, w) + root = frame(ui_st, new UiInput) + print(`{state(root)}; scrolled {int(root.children[0].children[6].scroll_y)}`) + } +} diff --git a/examples/library/ui_controls_parts/Settings.lss b/examples/library/ui_controls_parts/Settings.lss new file mode 100644 index 00000000..cb956a07 --- /dev/null +++ b/examples/library/ui_controls_parts/Settings.lss @@ -0,0 +1,4 @@ +.page { width: 400px; gap: 4px } +input, select { width: fill; height: 30px } +.list { overflow: auto; height: 60px } +p { height: 20px } diff --git a/examples/library/ui_controls_parts/Settings.ludic b/examples/library/ui_controls_parts/Settings.ludic new file mode 100644 index 00000000..63542b85 --- /dev/null +++ b/examples/library/ui_controls_parts/Settings.ludic @@ -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 } +} diff --git a/examples/library/ui_controls_parts/Settings.xml b/examples/library/ui_controls_parts/Settings.xml new file mode 100644 index 00000000..61cde68f --- /dev/null +++ b/examples/library/ui_controls_parts/Settings.xml @@ -0,0 +1,12 @@ +
+ + + + + + +

1

2

3

4

5

6

7

8

+

{pressed} {music} {volume} {quality} {name} {jump}

+
diff --git a/examples/library/ui_css.ludic b/examples/library/ui_css.ludic new file mode 100644 index 00000000..90a3feac --- /dev/null +++ b/examples/library/ui_css.ludic @@ -0,0 +1,26 @@ +# ui_css.ludic — ludic.ui's CSS without a renderer: custom properties and var(), absolute, fixed and +# relative positioning, em, rem and vw, @media, text that wraps, sibling combinators, :nth-child +# formulas, :checked, and overflow making a box scroll. +import "ludic.ui" +program UiCss { + numbers float + view Css { + wide = true + } + const LOOK: string = "screen { --gap: 6px; --accent: #ff8800 }\n.panel { position: relative; width: 300px; height: 200px; padding: 10px }\n.badge { position: absolute; right: 5px; bottom: 5px; width: 40px; height: 20px; background: var(--accent) }\n.corner { position: fixed; left: 0; top: 0; width: 10vw; height: 2rem }\n.nudge { position: relative; left: 7px; top: 3px }\n.big { font-size: 2em }\n.missing { color: var(--nope, #00ff00) }\n@media (max-width: 500px) { .wide-only { display: none } }\n@media (min-width: 500px) { .narrow-only { display: none } }\nli + li { margin-top: var(--gap) }\nh3 ~ p { color: var(--accent) }\nli:nth-child(2n+1) { color: #0000ff }\ninput:checked { opacity: 0.25 }\n.list { overflow: auto; height: 50px }\n.para { width: 120px }\n" + const PAGE: string = "\n\n\n
!

moved

big

\n
\n

wide

narrow

\n
  • a
  • b
  • c
\n

h

after

fallback

\n \n

1

2

3

4

\n

a sentence long enough that it has to wrap onto several lines

\n\n\n" + entry (ui_st: mut UiState) { + ui_load_text(ui_st, LOOK, "ui/look.lss") + ui_load_text(ui_st, PAGE, "ui/css.xml") + ui_viewport(ui_st, 400.0, 300.0) + let root: UiNode = ui_nodes(ui_st, "css", view_css()) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 300.0) + let panel = root.children[0] + let badge = panel.children[0] + let ul = root.children[3] + let para = root.children[len(root.children) - 1] + print(`badge at {int(badge.x)},{int(badge.y)} bg {badge.bg}; nudged {int(panel.children[1].x)},{int(panel.children[1].y)}; big {int(panel.children[2].rsize)}; corner {int(root.children[1].x)},{int(root.children[1].y)} {int(root.children[1].cw)}x{int(root.children[1].ch)}`) + print(`media keeps {root.children[2].text}; li gaps {int(ul.children[1].mt)} {int(ul.children[0].mt)}; li colours {ul.children[0].fg} {ul.children[1].fg} {ul.children[2].fg}; after {root.children[5].fg}; fallback {root.children[6].fg}`) + print(`checked opacity {root.children[7].alpha}; list scrolls {root.children[8].kind == UI_SCROLL} {int(root.children[8].ch)} of {int(root.children[8].content_h)}; para lines {len(para.lines)}`) + } +} diff --git a/examples/library/ui_ease.ludic b/examples/library/ui_ease.ludic new file mode 100644 index 00000000..4da174f1 --- /dev/null +++ b/examples/library/ui_ease.ludic @@ -0,0 +1,57 @@ +# ui_ease.ludic — easing never depends on the frame rate: a transition and an animation stepped at +# 60, 120, 180 and 240 Hz on the program's clock are at the same value half way (kept in float, never +# cut to whole pixels) and land exactly on their targets when their time is up. +import "ludic.ui" +program UiEase { + numbers float + state UiEaseState { + dim: bool = false + now: float = 0.0 + } + view Ease (ui_ease_st: UiEaseState) { + dim = ui_ease_st.dim + } + const STYLE: string = "" + const BODY: string = "

a

b

c

" + function clock(ui_ease_st: UiEaseState) -> float { return ui_ease_st.now } + function nodes(ui_st: mut UiState, name: string) -> UiNode { + ui_show(ui_st, name, view_ease(), 0.0, 0.0, 200.0, 200.0) + return ui_nodes(ui_st, name, view_ease()) + } + # one rate, on its own screen: the slide from its first frame, the fade from half a second in + function at_rate(ui_ease_st: mut UiEaseState, ui_st: mut UiState, hz: int) -> string { + let name = `r{hz}` + ui_ease_st.dim = false + var mid = "" + var slid = false + var landed = false + var over = false + for k in 0 .. hz + 1 { + ui_ease_st.now = float(k) / float(hz) + if k == hz / 2 { ui_ease_st.dim = true } + let r = nodes(ui_st, name) + let go = r.children[0] + let soft = r.children[1] + let want = r.children[2].alpha + if k == hz / 10 { mid = `{int(Math.round(go.left * 100.0))} ` } + if k == hz / 2 + hz / 10 { mid = mid + `{int(Math.round(soft.alpha * 1000.0))}` } + if k == hz / 4 { slid = go.left == 37.0 } + if k == hz / 2 + hz / 4 { landed = soft.alpha == want } + if k > hz / 2 + hz / 4 and soft.alpha != want { over = true } + } + return `{hz}: {mid} slid {slid} landed {landed} left {over}` + } + entry (ui_ease_st: mut UiEaseState, ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + ui_clock(ui_st, fn clock) + var page = "" + STYLE + for i in 0 .. 4 { + let hz = 60 * (i + 1) + page = page + `` + BODY + } + ui_load_text(ui_st, page + "", "e.xml") + var out = "" + for i in 0 .. 4 { out = out + at_rate(ui_ease_st, ui_st, 60 * (i + 1)) + "; " } + print(out) + } +} diff --git a/examples/library/ui_emit_click.ludic b/examples/library/ui_emit_click.ludic new file mode 100644 index 00000000..8454d736 --- /dev/null +++ b/examples/library/ui_emit_click.ludic @@ -0,0 +1,40 @@ +# ui_emit_click.ludic - a component whose button says `emit click` is answered by its user's +# on-click, by the mouse or a press: on-click is kept as on-press, and the emit once looked for +# "click" and found nothing (the pack's items and the wardrobe's picks did not respond) +import "ludic.ui" +import "ui_emit_click_parts/Cell.ludic" +import "ui_emit_click_parts/Pick.ludic" +program UiEmitClick { + numbers float + function frame(ui_st: mut UiState, x: float, y: float, down: bool) -> UiNode { + let i = new UiInput + i.x = x + i.y = y + i.down = down + ui_input(ui_st, i) + ui_show(ui_st, "Pick", null, 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "Pick", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root + } + function first_text(n: UiNode) -> string { + if n.text != "" { return n.text } + for i in 0 .. len(n.children) { let t = first_text(n.children[i]); if t != "" { return t } } + return "" + } + entry (ui_st: mut UiState) { + var root = frame(ui_st, -1.0, -1.0, false) + root = frame(ui_st, -1.0, -1.0, false) + let b = root.children[0].children[2].children[0] + let x = b.x + b.cw / 2.0 + let y = b.y + b.ch / 2.0 + root = frame(ui_st, x, y, false) + root = frame(ui_st, x, y, true) + root = frame(ui_st, x, y, false) + root = frame(ui_st, x, y, false) + let clicked = first_text(root) + ui_press(ui_st, root.children[0].children[1].children[0]) + root = frame(ui_st, x, y, false) + print(`{clicked} | pressed: {first_text(root)}`) + } +} diff --git a/examples/library/ui_emit_click_parts/Cell.lss b/examples/library/ui_emit_click_parts/Cell.lss new file mode 100644 index 00000000..b8c03a5a --- /dev/null +++ b/examples/library/ui_emit_click_parts/Cell.lss @@ -0,0 +1 @@ +.cell { width: 100px; height: 40px } diff --git a/examples/library/ui_emit_click_parts/Cell.ludic b/examples/library/ui_emit_click_parts/Cell.ludic new file mode 100644 index 00000000..0ed005d7 --- /dev/null +++ b/examples/library/ui_emit_click_parts/Cell.ludic @@ -0,0 +1,3 @@ +component Cell { + prop caption: string = "" +} diff --git a/examples/library/ui_emit_click_parts/Cell.xml b/examples/library/ui_emit_click_parts/Cell.xml new file mode 100644 index 00000000..a88076ab --- /dev/null +++ b/examples/library/ui_emit_click_parts/Cell.xml @@ -0,0 +1,3 @@ +
+ +
diff --git a/examples/library/ui_emit_click_parts/Pick.lss b/examples/library/ui_emit_click_parts/Pick.lss new file mode 100644 index 00000000..1de0ef03 --- /dev/null +++ b/examples/library/ui_emit_click_parts/Pick.lss @@ -0,0 +1 @@ +div { width: 400px } diff --git a/examples/library/ui_emit_click_parts/Pick.ludic b/examples/library/ui_emit_click_parts/Pick.ludic new file mode 100644 index 00000000..917bb014 --- /dev/null +++ b/examples/library/ui_emit_click_parts/Pick.ludic @@ -0,0 +1,4 @@ +component Pick { + state got: int = -1 + on pick(i: int) { got = i } +} diff --git a/examples/library/ui_emit_click_parts/Pick.xml b/examples/library/ui_emit_click_parts/Pick.xml new file mode 100644 index 00000000..aeefa2b6 --- /dev/null +++ b/examples/library/ui_emit_click_parts/Pick.xml @@ -0,0 +1,5 @@ +
+

got {got}

+ + +
diff --git a/examples/library/ui_fill.ludic b/examples/library/ui_fill.ludic new file mode 100644 index 00000000..c7214bb1 --- /dev/null +++ b/examples/library/ui_fill.ludic @@ -0,0 +1,41 @@ +# ui_fill.ludic — linear-gradient backgrounds (a direction or an angle, stops placed or spread) drawn +# as whole-pixel bands; object-fit contain and cover for a picture of its own size; aspect-ratio +# making a height from a width, and a native's width from its height. +import "ludic.ui" +program UiFill { + numbers float + state UiFillState { + rects: []string = new []string + pics: []string = new []string + clips: int = 0 + } + const STYLE: string = "" + const BODY: string = "
" + function rec_rect(ui_fill_st: mut UiFillState, x: float, y: float, w: float, h: float, c: int, a: float) -> void { push(ui_fill_st.rects, `{int(x)},{int(y)} {int(w)}x{int(h)} {ui_hex(c)}`) } + function rec_image(ui_fill_st: mut UiFillState, src: string, x: float, y: float, w: float, h: float, c: int, a: float) -> void { push(ui_fill_st.pics, `{int(x)},{int(y)} {int(w)}x{int(h)}`) } + function rec_clip(ui_fill_st: mut UiFillState, x: float, y: float, w: float, h: float) -> void { ui_fill_st.clips += 1 } + function no_clip() -> void { } + function no_text(x: float, y: float, s: string, size: float, c: int, a: float) -> void { } + function nat_w(src: string) -> float { return 200.0 } + function nat_h(src: string) -> float { return 100.0 } + function knob_measure(n: UiNode, avail: float) -> void { } + function knob_draw(n: UiNode) -> void { } + entry (ui_fill_st: UiFillState, ui_st: mut UiState) { + let b = new UiBackend + b.rect = fn rec_rect + b.text = fn no_text + b.image = fn rec_image + b.image_w = fn nat_w + b.image_h = fn nat_h + b.clip = fn rec_clip + b.unclip = fn no_clip + ui_backend(ui_st, b) + ui_native(ui_st, "knob", fn knob_measure, fn knob_draw) + ui_load_text(ui_st, "" + STYLE + BODY + "", "f.xml") + ui_show(ui_st, "f", null, 0.0, 0.0, 400.0, 600.0) + let r: UiNode = ui_nodes(ui_st, "f", null) + ui_place(ui_st, r, 0.0, 0.0, 400.0, 600.0) + let n = len(ui_fill_st.rects) + print(`{ui_fill_st.rects[0]} .. {ui_fill_st.rects[49]} | {ui_fill_st.rects[50]} .. {ui_fill_st.rects[59]} .. {ui_fill_st.rects[69]} of {n} | {ui_fill_st.pics[0]} | {ui_fill_st.pics[1]} clipped {ui_fill_st.clips} | ratio {int(r.children[4].ch)} knob {int(r.children[5].children[0].cw)}`) + } +} diff --git a/examples/library/ui_first_show.ludic b/examples/library/ui_first_show.ludic new file mode 100644 index 00000000..c3aeb648 --- /dev/null +++ b/examples/library/ui_first_show.ludic @@ -0,0 +1,27 @@ +# ui_first_show.ludic - a component shown for the first time once the fence is judging (a prompt's key +# hint mid-walk) makes its instance's props and model: declared, once per instance, and the failing +# fence lets it through; hidden and shown again, the instance is reused and nothing more is made: +# bad 0 shown 3 +import "ludic.ui" +import "ui_first_show_parts/Cell.ludic" +import "ui_first_show_parts/Host.ludic" +program UiFirstShow { + numbers float + function frame(ui_st: mut UiState) -> UiNode { + let root: UiNode = ui_nodes(ui_st, "Host", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root + } + entry (ui_st: mut UiState) { + var shown = 0 + for f in 0 .. 1400 { + let root = frame(ui_st) + if f == 700 or f == 900 or f == 1000 or f == 1100 or f == 1200 { + ui_press(ui_st, root.children[0].children[0]) + if f != 900 and f != 1100 { shown += 1 } + } + Mem.frame() + } + print(`bad {Mem.bad_frames()} shown {shown}`) + } +} diff --git a/examples/library/ui_first_show_parts/Cell.lss b/examples/library/ui_first_show_parts/Cell.lss new file mode 100644 index 00000000..b8c03a5a --- /dev/null +++ b/examples/library/ui_first_show_parts/Cell.lss @@ -0,0 +1 @@ +.cell { width: 100px; height: 40px } diff --git a/examples/library/ui_first_show_parts/Cell.ludic b/examples/library/ui_first_show_parts/Cell.ludic new file mode 100644 index 00000000..0ed005d7 --- /dev/null +++ b/examples/library/ui_first_show_parts/Cell.ludic @@ -0,0 +1,3 @@ +component Cell { + prop caption: string = "" +} diff --git a/examples/library/ui_first_show_parts/Cell.xml b/examples/library/ui_first_show_parts/Cell.xml new file mode 100644 index 00000000..a88076ab --- /dev/null +++ b/examples/library/ui_first_show_parts/Cell.xml @@ -0,0 +1,3 @@ +
+ +
diff --git a/examples/library/ui_first_show_parts/Host.lss b/examples/library/ui_first_show_parts/Host.lss new file mode 100644 index 00000000..1bac0ccf --- /dev/null +++ b/examples/library/ui_first_show_parts/Host.lss @@ -0,0 +1 @@ +.t { width: 60px; height: 30px } diff --git a/examples/library/ui_first_show_parts/Host.ludic b/examples/library/ui_first_show_parts/Host.ludic new file mode 100644 index 00000000..a8e1b5ba --- /dev/null +++ b/examples/library/ui_first_show_parts/Host.ludic @@ -0,0 +1,4 @@ +component Host { + state on: bool = false + on flip() { on = not on } +} diff --git a/examples/library/ui_first_show_parts/Host.xml b/examples/library/ui_first_show_parts/Host.xml new file mode 100644 index 00000000..29140558 --- /dev/null +++ b/examples/library/ui_first_show_parts/Host.xml @@ -0,0 +1,4 @@ +
+ + +
diff --git a/examples/library/ui_flex.ludic b/examples/library/ui_flex.ludic new file mode 100644 index 00000000..50255f51 --- /dev/null +++ b/examples/library/ui_flex.ludic @@ -0,0 +1,25 @@ +# ui_flex.ludic — ludic.ui's flex layout and stylesheets without a renderer: grow shares the spare +# room, justify spreads it, a row wraps, align stretches; a stylesheet file (which @imports another) +# styles whoever imports it, a more specific rule wins, a bound class switches one on, and an attribute beats all. +import "ludic.ui" +program UiFlex { + numbers float + let picked: int = 1 + view Tags { + picked = picked + } + const BASE: string = "/* base.lss - every text small */\ntext { size: 10 }\n" + const THEME: string = "/* theme.lss - the base, and a colour for what is on */\n@import \"base.lss\";\n.on { color: #ff0000 }\n" + const PAGE: string = "\n\n\n\n \n ab\n \n lmr\n \n t{i}\n \n
x\n\n\n" + entry (ui_st: mut UiState) { + ui_load_text(ui_st, BASE, "ui/base.lss") + ui_load_text(ui_st, THEME, "ui/theme.lss") + ui_load_text(ui_st, PAGE, "ui/flex.xml") + let root: UiNode = ui_nodes(ui_st, "flex", view_tags()) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 300.0) + let bar = root.children[0] + let wrap = root.children[2] + let tall = root.children[3] + print(`grow {int(bar.children[0].cw)}:{int(bar.children[1].cw)}; between at {int(root.children[1].children[1].x)}; wrap lines at {int(wrap.children[0].y)}, {int(wrap.children[3].y)}; red {wrap.children[1].fg}; stretched {int(tall.children[0].ch)}; own size {int(tall.children[1].rsize)}`) + } +} diff --git a/examples/library/ui_focus_tip.ludic b/examples/library/ui_focus_tip.ludic new file mode 100644 index 00000000..126839ce --- /dev/null +++ b/examples/library/ui_focus_tip.ludic @@ -0,0 +1,41 @@ +# ui_focus_tip.ludic — a title shows for the keyboard's focus as it does for the pointer: after the +# focus has rested half a second on an element with one, under that element; moving the pointer +# hands the tooltip back to the pointer. +import "ludic.ui" +program UiFocusTip { + numbers float + state UiFocusTipState { + said: []string = new []string + now: float = 0.0 + } + const PAGE: string = "" + function rec_text(ui_focus_tip_st: mut UiFocusTipState, x: float, y: float, s: string, size: float, c: int, a: float) -> void { push(ui_focus_tip_st.said, `{s} {int(x)},{int(y)}`) } + function clock(ui_focus_tip_st: UiFocusTipState) -> float { return ui_focus_tip_st.now } + function frame(ui_focus_tip_st: mut UiFocusTipState, ui_st: mut UiState, i: UiInput, secs: float) -> string { + ui_focus_tip_st.now = ui_focus_tip_st.now + secs + ui_focus_tip_st.said = new []string + ui_input(ui_st, i) + ui_show(ui_st, "f", null, 0.0, 0.0, 400.0, 400.0) + return ui_focus_tip_st.said[len(ui_focus_tip_st.said) - 1] + } + entry (ui_focus_tip_st: mut UiFocusTipState, ui_st: mut UiState) { + let b = new UiBackend + b.text = fn rec_text + ui_backend(ui_st, b) + ui_clock(ui_st, fn clock) + ui_load_text(ui_st, PAGE, "f.xml") + frame(ui_focus_tip_st, ui_st, new UiInput, 0.0) + let tab = new UiInput + tab.tab = true + frame(ui_focus_tip_st, ui_st, tab, 0.0) + let early = frame(ui_focus_tip_st, ui_st, new UiInput, 0.2) + let shown = frame(ui_focus_tip_st, ui_st, new UiInput, 0.4) + frame(ui_focus_tip_st, ui_st, tab, 0.0) + let next = frame(ui_focus_tip_st, ui_st, new UiInput, 0.6) + let moved = new UiInput + moved.x = 390.0 + moved.y = 390.0 + let gone = frame(ui_focus_tip_st, ui_st, moved, 0.6) + print(`{early} | {shown} | {next} | {gone}`) + } +} diff --git a/examples/library/ui_gamepad.ludic b/examples/library/ui_gamepad.ludic new file mode 100644 index 00000000..b6c257a4 --- /dev/null +++ b/examples/library/ui_gamepad.ludic @@ -0,0 +1,54 @@ +# ui_gamepad.ludic — the pad and held keys, on the program's clock: a direction held presses once, +# again after 0.42 s and every 0.11 s after that, at any frame rate; A presses what has the focus, +# left and right step a range and a select, and B closes a popover. +import "ludic.ui" +import "ui_gamepad_parts/Pad.ludic" +program UiGamepad { + numbers float + state UiGamepadState { + now: float = 0.0 + } + function clock(ui_gamepad_st: UiGamepadState) -> float { return ui_gamepad_st.now } + function frame(ui_st: mut UiState, i: UiInput) -> string { + ui_input(ui_st, i) + ui_show(ui_st, "Pad", null, 0.0, 0.0, 400.0, 600.0) + let page = ui_nodes(ui_st, "Pad", null).children[0] + return page.children[len(page.children) - 1].text + } + # `what` held for `secs` at `hz`, then let go + function hold(ui_gamepad_st: mut UiGamepadState, ui_st: mut UiState, what: string, secs: float, hz: int) -> string { + let frames = int(secs * float(hz) + 0.5) + for k in 0 .. frames + 1 { + ui_gamepad_st.now = ui_gamepad_st.now + 1.0 / float(hz) + let i = new UiInput + i.held_up = what == "up" + i.held_down = what == "down" + i.held_left = what == "left" + i.held_right = what == "right" + i.pad_a = what == "a" + i.pad_b = what == "b" + frame(ui_st, i) + } + ui_gamepad_st.now = ui_gamepad_st.now + 1.0 / float(hz) + return frame(ui_st, new UiInput) + } + function tap(ui_gamepad_st: mut UiGamepadState, ui_st: mut UiState, what: string) -> string { return hold(ui_gamepad_st, ui_st, what, 0.0, 60) } + entry (ui_gamepad_st: mut UiGamepadState, ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + ui_clock(ui_st, fn clock) + frame(ui_st, new UiInput) + hold(ui_gamepad_st, ui_st, "down", 1.0, 240) + let fast = tap(ui_gamepad_st, ui_st, "a") + hold(ui_gamepad_st, ui_st, "down", 1.0, 60) + hold(ui_gamepad_st, ui_st, "up", 0.5, 60) + let slow = tap(ui_gamepad_st, ui_st, "a") + for k in 0 .. 8 { tap(ui_gamepad_st, ui_st, "down") } + let vol = hold(ui_gamepad_st, ui_st, "right", 0.5, 60) + tap(ui_gamepad_st, ui_st, "down") + let q = tap(ui_gamepad_st, ui_st, "left") + tap(ui_gamepad_st, ui_st, "down") + let open = tap(ui_gamepad_st, ui_st, "a") + let shut = tap(ui_gamepad_st, ui_st, "b") + print(`{fast} | {slow} | {vol} | {q} | {open} | {shut}`) + } +} diff --git a/examples/library/ui_gamepad_parts/Pad.ludic b/examples/library/ui_gamepad_parts/Pad.ludic new file mode 100644 index 00000000..6d3776cc --- /dev/null +++ b/examples/library/ui_gamepad_parts/Pad.ludic @@ -0,0 +1,7 @@ +# Pad.ludic - eight buttons, a range, a select and a button that opens a popover, for a gamepad +component Pad { + state last: int = -1 + state vol: int = 0 + state q: int = 0 + state open: bool = false +} diff --git a/examples/library/ui_gamepad_parts/Pad.xml b/examples/library/ui_gamepad_parts/Pad.xml new file mode 100644 index 00000000..0596d59f --- /dev/null +++ b/examples/library/ui_gamepad_parts/Pad.xml @@ -0,0 +1,8 @@ +
+ + + + +
+

last {last} vol {vol} q {q} open {open}

+
diff --git a/examples/library/ui_hold.ludic b/examples/library/ui_hold.ludic new file mode 100644 index 00000000..60547577 --- /dev/null +++ b/examples/library/ui_hold.ludic @@ -0,0 +1,61 @@ +# ui_hold.ludic — on-hold fires every frame a button is held, by the pointer (still when it is dragged +# off, until let go) or by Enter or the pad's A on the focus, with event.dt and event.t on the ui clock, +# the same at 60 and 240 Hz; on-down and on-up still mark the ends. +import "ludic.ui" +program UiHold { + numbers float + state UiHoldState { + now: float = 0.0 + held: float = 0.0 + frames: int = 0 + last_t: float = 0.0 + ends: string = "" + } + view Craft (ui_hold_st: mut UiHoldState) { + on tick(dt: float, t: float) { + ui_hold_st.held = ui_hold_st.held + dt + ui_hold_st.frames = ui_hold_st.frames + 1 + ui_hold_st.last_t = t + } + on down() { ui_hold_st.ends = ui_hold_st.ends + "d" } + on up() { ui_hold_st.ends = ui_hold_st.ends + "u" } + } + const PAGE: string = "" + function clock(ui_hold_st: UiHoldState) -> float { return ui_hold_st.now } + function frame(ui_st: mut UiState, i: UiInput) -> UiNode { + ui_input(ui_st, i) + ui_show(ui_st, "h", view_craft(), 0.0, 0.0, 400.0, 400.0) + let r: UiNode = ui_nodes(ui_st, "h", view_craft()) + ui_place(ui_st, r, 0.0, 0.0, 400.0, 400.0) + return r.children[0] + } + # held for `secs` at `hz`, by the pointer (off the button after the first frame) or by A + function hold(ui_hold_st: mut UiHoldState, ui_st: mut UiState, b: UiNode, secs: float, hz: int, pad: bool) -> string { + ui_hold_st.held = 0.0 + ui_hold_st.frames = 0 + let n = int(secs * float(hz) + 0.5) + for k in 0 .. n + 1 { + let i = new UiInput + i.pad_a = pad + i.down = not pad + i.x = b.x + 5.0 + i.y = b.y + 5.0 + if k > 0 and not pad { i.x = 390.0 } + frame(ui_st, i) + ui_hold_st.now = ui_hold_st.now + 1.0 / float(hz) + } + frame(ui_st, new UiInput) + ui_hold_st.now = ui_hold_st.now + 1.0 / float(hz) + return `{ui_hold_st.frames} frames {int(Math.round(ui_hold_st.held * 1000.0))} ms t {int(Math.round(ui_hold_st.last_t * 1000.0))}` + } + entry (ui_hold_st: mut UiHoldState, ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + ui_clock(ui_st, fn clock) + ui_load_text(ui_st, PAGE, "h.xml") + let b = frame(ui_st, new UiInput) + let slow = hold(ui_hold_st, ui_st, b, 0.5, 60, false) + let fast = hold(ui_hold_st, ui_st, b, 0.5, 240, false) + let pad = hold(ui_hold_st, ui_st, b, 0.25, 60, true) + print(`{slow} | {fast} | pad {pad} | {ui_hold_st.ends}`) + } +} diff --git a/examples/library/ui_html.ludic b/examples/library/ui_html.ludic new file mode 100644 index 00000000..f639d5bc --- /dev/null +++ b/examples/library/ui_html.ludic @@ -0,0 +1,30 @@ +# ui_html.ludic — ludic.ui read as HTML and CSS: HTML's elements with a browser's defaults, ids, +# classes, style="...", hidden and disabled, onclick; selectors with ids, descendants, children, +# attributes and pseudo-classes, weighed by specificity; CSS's own property names and the box model. +import "ludic.ui" +program UiHtml { + numbers float + state UiHtmlState { + clicks: int = 0 + } + view Page (ui_html_st: mut UiHtmlState) { + clicks = ui_html_st.clicks + on clicked() { ui_html_st.clicks += 1 } + } + const LOOK: string = "/* look.lss */\n.card { padding: 4 8 12 16; margin: 3; border: 2px solid #0000ff; width: 50% }\n#title { font-size: 40 }\nh1.big { font-size: 20 }\n.card p { color: #00ff00 }\nul > li { height: 30 }\nli:first-child { color: #ff0000 }\nli:nth-child(even) { color: #0000ff }\nli:last-child:not(.keep) { display: none }\n[kind=warn] { background-color: #ffff00 }\nbutton:disabled { opacity: 0.5 }\n" + const PAGE: string = "\n\n\n

Title

\n

inside

careful
\n

outside

\n
  • one
  • two
  • three
  • gone
\n
\n

mid

\n \n \n
\n
\n" + entry (ui_html_st: UiHtmlState, ui_st: mut UiState) { + ui_load_text(ui_st, LOOK, "ui/look.lss") + ui_load_text(ui_st, PAGE, "ui/page.ludic.xml") + var root: UiNode = ui_nodes(ui_st, "page", view_page()) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 600.0) + let card = root.children[1] + let items = root.children[3].children + print(`id beats class: {int(root.children[0].rsize)}; card {int(card.cw)} wide at {int(card.x)},{int(card.y)} pad {int(card.pt)} {int(card.pr)} {int(card.pb)} {int(card.pl)} border {int(card.border)}`) + print(`inside {card.children[0].fg}, outside {root.children[2].fg}; warn bg {card.children[1].bg}; items {len(items)} of 30: {int(items[0].ch)}; colours {items[0].fg} {items[1].fg} {items[2].fg}; hr {int(root.children[4].ch)}; children {len(root.children)}`) + ui_press(ui_st, root.children[6]) + root = ui_nodes(ui_st, "page", view_page()) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 600.0) + print(`clicks {ui_html_st.clicks}; the button now disabled {not root.children[6].enabled} at opacity {root.children[6].alpha}; text-align {root.children[5].talign}`) + } +} diff --git a/examples/library/ui_inputs.ludic b/examples/library/ui_inputs.ludic new file mode 100644 index 00000000..4a995afc --- /dev/null +++ b/examples/library/ui_inputs.ludic @@ -0,0 +1,83 @@ +# ui_inputs.ludic — more of ludic.ui's controls, driven by the keys: a number field takes typed digits +# (Enter or leaving it commits them, held between min and max) and steps with the arrows; a key field +# listens for the next key (Tab included), Esc stops it listening and Backspace unbinds it; a range +# shows its value as a percentage or with decimals and a unit; a label carries a note; an action may +# call an event named set; and a string prop given a number reads it as text. +import "ludic.ui" +import "ui_inputs_parts/Form.ludic" +import "ui_inputs_parts/Tag.ludic" +program UiInputs { + numbers float + function frame(ui_st: mut UiState, i: UiInput) -> UiNode { + ui_input(ui_st, i) + ui_show(ui_st, "Form", null, 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "Form", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root.children[0] + } + function keys(tab: bool, shift: bool, enter: bool, left: bool) -> UiInput { + let i = new UiInput + i.tab = tab + i.shift = shift + i.enter = enter + i.left = left + if tab { i.key = Key.Tab } + if enter { i.key = Key.Enter } + return i + } + function typed(s: string) -> UiInput { + let i = new UiInput + i.typed = s + return i + } + function shown_state(page: UiNode) -> string { return page.children[len(page.children) - 1].text } + function last(n: UiNode) -> string { return n.children[len(n.children) - 1].text } + entry (ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + var page = frame(ui_st, new UiInput) + frame(ui_st, keys(true, false, false, false)) + frame(ui_st, typed("12")) + page = frame(ui_st, keys(false, false, true, false)) + let shown = page.children[0].children[1].children[0].text + let a = shown_state(page) + frame(ui_st, typed("99")) + page = frame(ui_st, keys(true, false, false, false)) + let b = shown_state(page) + frame(ui_st, keys(true, true, false, false)) + page = frame(ui_st, keys(false, false, false, true)) + let c = shown_state(page) + let note = page.children[0].children[0].children[1].text + frame(ui_st, keys(true, false, false, false)) + let space = page.children[1].children[1].children[0].text + frame(ui_st, keys(false, false, true, false)) + let cap = ui_capturing(ui_st) + page = frame(ui_st, keys(true, false, false, false)) + let d = shown_state(page) + frame(ui_st, keys(false, false, true, false)) + let esc = new UiInput + esc.escape = true + esc.key = Key.Escape + page = frame(ui_st, esc) + let e = shown_state(page) + let back = new UiInput + back.backspace = true + page = frame(ui_st, back) + let f = shown_state(page) + frame(ui_st, keys(true, false, false, false)) + let right = new UiInput + right.right = true + page = frame(ui_st, right) + page = frame(ui_st, new UiInput) + let button = page.children[4] + let click = new UiInput + click.x = button.x + 2.0 + click.y = button.y + 2.0 + click.down = true + frame(ui_st, click) + let up = new UiInput + up.x = click.x + up.y = click.y + page = frame(ui_st, up) + print(`count {shown} {a} | {b} | {c} | {note} | {space} listening {cap} {d} | {e} | {f} | {last(page.children[2])} {last(page.children[3])} | {shown_state(page)} | {page.children[5].text}`) + } +} diff --git a/examples/library/ui_inputs_parts/Form.lss b/examples/library/ui_inputs_parts/Form.lss new file mode 100644 index 00000000..0f774ffd --- /dev/null +++ b/examples/library/ui_inputs_parts/Form.lss @@ -0,0 +1,2 @@ +.page { width: 400px; gap: 4px } +input { width: fill } diff --git a/examples/library/ui_inputs_parts/Form.ludic b/examples/library/ui_inputs_parts/Form.ludic new file mode 100644 index 00000000..f81669f3 --- /dev/null +++ b/examples/library/ui_inputs_parts/Form.ludic @@ -0,0 +1,9 @@ +# Form.ludic - a number, a key binding, two ranges with their values formatted, a note under a label, +# and an event called set +component Form { + state count: int = 5 + state bind: int = 32 + state vol: float = 0.5 + state said: int = 0 + on set(v: int) { said = v } +} diff --git a/examples/library/ui_inputs_parts/Form.xml b/examples/library/ui_inputs_parts/Form.xml new file mode 100644 index 00000000..1d13d356 --- /dev/null +++ b/examples/library/ui_inputs_parts/Form.xml @@ -0,0 +1,9 @@ +
+ + + + + + +

{count} {bind} {vol} {said}

+
diff --git a/examples/library/ui_inputs_parts/Tag.ludic b/examples/library/ui_inputs_parts/Tag.ludic new file mode 100644 index 00000000..196cadfa --- /dev/null +++ b/examples/library/ui_inputs_parts/Tag.ludic @@ -0,0 +1,4 @@ +# Tag.ludic - a string prop, which a number given to it arrives in as text +component Tag { + prop text: string = "none" +} diff --git a/examples/library/ui_inputs_parts/Tag.xml b/examples/library/ui_inputs_parts/Tag.xml new file mode 100644 index 00000000..6fd5cee1 --- /dev/null +++ b/examples/library/ui_inputs_parts/Tag.xml @@ -0,0 +1 @@ +

{text}!

diff --git a/examples/library/ui_keycap.ludic b/examples/library/ui_keycap.ludic new file mode 100644 index 00000000..9a3ad764 --- /dev/null +++ b/examples/library/ui_keycap.ludic @@ -0,0 +1,57 @@ +# ui_keycap.ludic — a key field takes a mouse button as well as a key: listening (and :capturing, +# which a stylesheet can colour), the next press of the left, right or middle button anywhere is its +# value (UI_MOUSE_LEFT / RIGHT / MIDDLE), named for what it is, and that press presses nothing else. +import "ludic.ui" +program UiKeycap { + numbers float + state UiKeycapState { + bind: int = 32 + pressed: int = 0 + } + view Keys (ui_keycap_st: mut UiKeycapState) { + bind = ui_keycap_st.bind + pressed = ui_keycap_st.pressed + on set_bind(v: int) { ui_keycap_st.bind = v } + on press() { ui_keycap_st.pressed = ui_keycap_st.pressed + 1 } + } + const PAGE: string = "" + function frame(ui_st: mut UiState, x: float, y: float, b: int) -> UiNode { + let i = new UiInput + i.x = x + i.y = y + i.down = b == 0 + i.down_right = b == 1 + i.down_middle = b == 2 + ui_input(ui_st, i) + ui_show(ui_st, "k", view_keys(), 0.0, 0.0, 400.0, 400.0) + let r: UiNode = ui_nodes(ui_st, "k", view_keys()) + ui_place(ui_st, r, 0.0, 0.0, 400.0, 400.0) + return r + } + function listen(ui_st: mut UiState, k: UiNode) -> int { + frame(ui_st, k.x + 5.0, k.y + 5.0, 0) + frame(ui_st, k.x + 5.0, k.y + 5.0, -1) + return frame(ui_st, -1.0, -1.0, -1).children[0].bg + } + function take(ui_keycap_st: UiKeycapState, ui_st: mut UiState, at: UiNode, b: int) -> string { + frame(ui_st, at.x + 5.0, at.y + 5.0, b) + frame(ui_st, at.x + 5.0, at.y + 5.0, -1) + let r = frame(ui_st, -1.0, -1.0, -1) + let f = r.children[0] + return `{ui_keycap_st.bind} {f.children[1].children[0].text} {f.bg}` + } + entry (ui_keycap_st: UiKeycapState, ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + ui_load_text(ui_st, PAGE, "k.xml") + let r = frame(ui_st, -1.0, -1.0, -1) + let k = r.children[0] + let btn = r.children[1] + let red = listen(ui_st, k) + let right = take(ui_keycap_st, ui_st, btn, 1) + listen(ui_st, k) + let middle = take(ui_keycap_st, ui_st, btn, 2) + listen(ui_st, k) + let left = take(ui_keycap_st, ui_st, btn, 0) + print(`listening {red} | {right} | {middle} | {left} | pressed {ui_keycap_st.pressed}`) + } +} diff --git a/examples/library/ui_look.ludic b/examples/library/ui_look.ludic new file mode 100644 index 00000000..0e279b45 --- /dev/null +++ b/examples/library/ui_look.ludic @@ -0,0 +1,67 @@ +# ui_look.ludic — what ludic.ui draws, through a backend that writes each call down: a component +# whose root is another component, styled by every sheet above it by specificity; text-shadow under +# text; a native reading the opacity it is drawn at (ui_opacity); border-image as painted, tinted by +# a background colour, and gone under `transparent`; a picture drawn as it is and an atlas cell in +# the text's colour; a tooltip of two lines; and popovers placed beside their anchors, one flipped. +import "ludic.ui" +import "ui_look_parts/Look.ludic" +import "ui_look_parts/Middle.ludic" +import "ui_look_parts/Inner.ludic" +import "ui_look_parts/Menu.ludic" +program UiLook { + numbers float + state UiLookState { + said: []string = new []string + seen: float = 0.0 + } + function rec_rect(x: float, y: float, w: float, h: float, c: int, a: float) -> void { } + function rec_text(ui_look_st: mut UiLookState, x: float, y: float, s: string, size: float, c: int, a: float) -> void { push(ui_look_st.said, `text {s} {int(x)},{int(y)} {ui_hex(c)}`) } + function rec_image(ui_look_st: mut UiLookState, src: string, x: float, y: float, w: float, h: float, c: int, a: float) -> void { push(ui_look_st.said, `image {src} {ui_hex(c)}`) } + function rec_nine(ui_look_st: mut UiLookState, src: string, slice: float, x: float, y: float, w: float, h: float, dst: float, c: int, a: float) -> void { push(ui_look_st.said, `nine {int(w)} {ui_hex(c)}`) } + function gauge_measure(n: UiNode, avail: float) -> void { } + function gauge_draw(ui_look_st: mut UiLookState, ui_st: UiState, n: UiNode) -> void { ui_look_st.seen = ui_opacity(ui_st) } + function count(ui_look_st: UiLookState, s: string) -> int { + var n = 0 + for i in 0 .. len(ui_look_st.said) { + if Text.starts_with(ui_look_st.said[i], s) { n += 1 } + } + return n + } + function has(ui_look_st: UiLookState, s: string) -> string { + for i in 0 .. len(ui_look_st.said) { + if Text.starts_with(ui_look_st.said[i], s) { return ui_look_st.said[i] } + } + return "-" + } + function show(ui_look_st: mut UiLookState, ui_st: mut UiState, name: string, i: UiInput) -> UiNode { + ui_input(ui_st, i) + ui_look_st.said = new []string + ui_show(ui_st, name, null, 0.0, 0.0, 400.0, 300.0) + let root: UiNode = ui_nodes(ui_st, name, null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 300.0) + return root.children[0] + } + entry (ui_look_st: mut UiLookState, ui_st: mut UiState) { + let b = new UiBackend + b.rect = fn rec_rect + b.text = fn rec_text + b.image = fn rec_image + b.nine = fn rec_nine + ui_backend(ui_st, b) + ui_native(ui_st, "gauge", fn gauge_measure, fn gauge_draw) + var page = show(ui_look_st, ui_st, "Look", new UiInput) + let inner = page.children[0] + let tip = page.children[7] + let rest = new UiInput + rest.x = tip.x + 2.0 + rest.y = tip.y + 2.0 + for f in 0 .. 40 { page = show(ui_look_st, ui_st, "Look", rest) } + print(`root {inner.tag}.{inner.classes[0]}.{inner.classes[1]}.{inner.classes[2]} pad {int(inner.pt)} margin {int(inner.mt)} color {ui_hex(inner.fg)}`) + print(`{has(ui_look_st, "text Hi")} | {has(ui_look_st, "text Hi 0")} | opacity {ui_look_st.seen} | {has(ui_look_st, "nine 40 #ffffff")} {has(ui_look_st, "nine 40 #ff0000")} of {count(ui_look_st, "nine")}`) + print(`{has(ui_look_st, "image photo")} | {has(ui_look_st, "image icon")} | {has(ui_look_st, "text first")} | {has(ui_look_st, "text second")}`) + let menu = show(ui_look_st, ui_st, "Menu", new UiInput) + let a = menu.children[1] + let c = menu.children[3] + print(`by id at {int(a.x)},{int(a.y)} | flipped at {int(c.x)},{int(c.y)} {int(c.cw)}x{int(c.ch)}`) + } +} diff --git a/examples/library/ui_look_parts/Inner.lss b/examples/library/ui_look_parts/Inner.lss new file mode 100644 index 00000000..a2a23a45 --- /dev/null +++ b/examples/library/ui_look_parts/Inner.lss @@ -0,0 +1 @@ +div.in { padding: 5px } diff --git a/examples/library/ui_look_parts/Inner.ludic b/examples/library/ui_look_parts/Inner.ludic new file mode 100644 index 00000000..ea13f836 --- /dev/null +++ b/examples/library/ui_look_parts/Inner.ludic @@ -0,0 +1,4 @@ +# Inner.ludic - the innermost root: its own rule is more specific than Middle's, so it holds +component Inner { + state n: int = 0 +} diff --git a/examples/library/ui_look_parts/Inner.xml b/examples/library/ui_look_parts/Inner.xml new file mode 100644 index 00000000..6ac2251d --- /dev/null +++ b/examples/library/ui_look_parts/Inner.xml @@ -0,0 +1 @@ +

in

diff --git a/examples/library/ui_look_parts/Look.lss b/examples/library/ui_look_parts/Look.lss new file mode 100644 index 00000000..10bfe048 --- /dev/null +++ b/examples/library/ui_look_parts/Look.lss @@ -0,0 +1,9 @@ +.page { width: 400px; gap: 2px } +.m { margin: 9px } +.shade { text-shadow: 2px 3px 4px #102030 } +.dim { opacity: 0.5 } +gauge { width: 10px; height: 10px } +.chip { width: 40px; height: 20px; border-image: url(chip.png) 8 } +.clear { background: transparent } +.red { background: #ff0000 } +.pics { flex-direction: row; color: #00ff00 } diff --git a/examples/library/ui_look_parts/Look.ludic b/examples/library/ui_look_parts/Look.ludic new file mode 100644 index 00000000..6dcb508d --- /dev/null +++ b/examples/library/ui_look_parts/Look.ludic @@ -0,0 +1,5 @@ +# Look.ludic - what is drawn: a nested component's root styled by every sheet above it, a shadow +# under text, a native at its group's opacity, border-images, pictures, and a tooltip of two lines +component Look { + state n: int = 0 +} diff --git a/examples/library/ui_look_parts/Look.xml b/examples/library/ui_look_parts/Look.xml new file mode 100644 index 00000000..953c4731 --- /dev/null +++ b/examples/library/ui_look_parts/Look.xml @@ -0,0 +1,10 @@ +
+ +

Hi

+
+
+
+
+
+

rest here

+
diff --git a/examples/library/ui_look_parts/Menu.lss b/examples/library/ui_look_parts/Menu.lss new file mode 100644 index 00000000..c391c723 --- /dev/null +++ b/examples/library/ui_look_parts/Menu.lss @@ -0,0 +1,4 @@ +.page { width: 400px; height: 300px } +.cell { position: absolute; left: 20px; top: 50px; width: 60px; height: 30px } +.edge { position: absolute; left: 350px; top: 280px; width: 50px; height: 30px } +.verbs { width: 100px; margin-left: 4px; margin-right: 4px } diff --git a/examples/library/ui_look_parts/Menu.ludic b/examples/library/ui_look_parts/Menu.ludic new file mode 100644 index 00000000..040af0e7 --- /dev/null +++ b/examples/library/ui_look_parts/Menu.ludic @@ -0,0 +1,5 @@ +# Menu.ludic - popovers beside what they belong to: one by the anchor's id, one beside the element +# before it, which has no room on its right and opens to the left +component Menu { + state n: int = 0 +} diff --git a/examples/library/ui_look_parts/Menu.xml b/examples/library/ui_look_parts/Menu.xml new file mode 100644 index 00000000..f1d58856 --- /dev/null +++ b/examples/library/ui_look_parts/Menu.xml @@ -0,0 +1,6 @@ +
+ +
+ +
+
diff --git a/examples/library/ui_look_parts/Middle.lss b/examples/library/ui_look_parts/Middle.lss new file mode 100644 index 00000000..25c4e0e7 --- /dev/null +++ b/examples/library/ui_look_parts/Middle.lss @@ -0,0 +1 @@ +.i { padding: 3px; color: #ff0000 } diff --git a/examples/library/ui_look_parts/Middle.ludic b/examples/library/ui_look_parts/Middle.ludic new file mode 100644 index 00000000..f849c893 --- /dev/null +++ b/examples/library/ui_look_parts/Middle.ludic @@ -0,0 +1,4 @@ +# Middle.ludic - a component whose root is another component +component Middle { + state n: int = 0 +} diff --git a/examples/library/ui_look_parts/Middle.xml b/examples/library/ui_look_parts/Middle.xml new file mode 100644 index 00000000..eef6d672 --- /dev/null +++ b/examples/library/ui_look_parts/Middle.xml @@ -0,0 +1 @@ + diff --git a/examples/library/ui_mounted.ludic b/examples/library/ui_mounted.ludic new file mode 100644 index 00000000..083468b4 --- /dev/null +++ b/examples/library/ui_mounted.ludic @@ -0,0 +1,18 @@ +# ui_mounted.ludic - an editor's questions of a running interface: which components the last frame +# showed (in tree order, each once) and one's model, read in place. App shows two Counters: the list +# is App then Counter, a Counter has a model, and a class not shown has none. Prints `App|Counter| 1 1`. +import "ludic.ui" +import "ui_component_parts/Counter.ludic" +import "ui_component_parts/App.ludic" +program UiMounted { + numbers float + entry (ui_st: mut UiState) { + let root: UiNode = ui_nodes(ui_st, "App", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + let names = new []string + ui_mounted(ui_st, names) + var joined = "" + for i in 0 .. len(names) { joined = joined + names[i] + "|" } + print(`{joined} {ui_model_of(ui_st, "Counter") != null} {ui_model_of(ui_st, "Nothing") == null}`) + } +} diff --git a/examples/library/ui_nine.ludic b/examples/library/ui_nine.ludic new file mode 100644 index 00000000..ca2efdbd --- /dev/null +++ b/examples/library/ui_nine.ludic @@ -0,0 +1,19 @@ +# ui_nine.ludic — a nine-slice's cuts: its corners are at most half the box across and half of it +# down, each direction on its own, so a key cap shorter than two corners keeps them across; the cuts +# land on whole pixels so the pieces meet. +import "ludic.ui" +program UiNine { + numbers float + function cuts(x: float, y: float, w: float, h: float, dst: float) -> string { + let c = ui_nine_cuts(x, y, w, h, dst) + var s = "" + for i in 0 .. 8 { + if i == 4 { s = s + "/" } + s = s + ` {int(c[i])}` + } + return s + } + entry { + print(`cap{cuts(0.0, 0.0, 30.0, 16.0, 12.0)} | wide{cuts(0.0, 0.0, 100.0, 40.0, 12.0)} | odd{cuts(10.4, 3.6, 20.3, 9.0, 14.0)}`) + } +} diff --git a/examples/library/ui_override.ludic b/examples/library/ui_override.ludic new file mode 100644 index 00000000..867341e2 --- /dev/null +++ b/examples/library/ui_override.ludic @@ -0,0 +1,36 @@ +# ui_override.ludic - a template's text given from outside in place of its file (a studio editing a +# running game's interface): the component is shown again with the new template and its state kept, +# and letting go brings the file back, the state still kept. Prints three lines: the counters as the +# files have them, as the override has them, and as the files have them again. +import "ludic.ui" +import "ui_component_parts/Counter.ludic" +import "ui_component_parts/App.ludic" +program UiOverride { + numbers float + function texts(n: UiNode, out: []string) -> void { + if n.text != "" { push(out, n.text) } + for i in 0 .. len(n.children) { texts(n.children[i], out) } + } + function frame(ui_st: mut UiState) -> UiNode { + let root: UiNode = ui_nodes(ui_st, "App", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root + } + function said(ui_st: mut UiState) -> string { + let out = new []string + texts(frame(ui_st), out) + var joined = "" + for i in 0 .. len(out) { joined = joined + out[i] + "|" } + return joined + } + entry (ui_st: mut UiState) { + let root = frame(ui_st) + ui_press(ui_st, root.children[0].children[1].children[1]) # counter a: + once + print(said(ui_st)) + let xml = "examples/library/ui_component_parts/Counter.xml" + ui_override(ui_st, xml, "

[{label}={count}]

") + print(`{said(ui_st)} {ui_overridden(ui_st, xml)}`) + ui_override_clear(ui_st, "") + print(`{said(ui_st)} {ui_overridden(ui_st, xml)}`) + } +} diff --git a/examples/library/ui_override_up.ludic b/examples/library/ui_override_up.ludic new file mode 100644 index 00000000..0a5c5824 --- /dev/null +++ b/examples/library/ui_override_up.ludic @@ -0,0 +1,26 @@ +# ui_override_up.ludic - the override through a component registered under a path with `..` in it, as a +# game's lab build registers every component (lab/../src/ui/...): the studio names the file as the game's +# root has it, and it still matches. Prints `counters|[a=0]|+|[b=0]|+|outside| 1 1` - the override shown, +# and the same file found by its plain path and by a ./ path. +import "ludic.ui" +import "../library/ui_component_parts/Counter.ludic" +import "../library/ui_component_parts/App.ludic" +program UiOverrideUp { + numbers float + function texts(n: UiNode, out: []string) -> void { + if n.text != "" { push(out, n.text) } + for i in 0 .. len(n.children) { texts(n.children[i], out) } + } + entry (ui_st: mut UiState) { + ui_override(ui_st, "examples/library/ui_component_parts/Counter.xml", "

[{label}={count}]

") + let root: UiNode = ui_nodes(ui_st, "App", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + let out = new []string + texts(root, out) + var joined = "" + for i in 0 .. len(out) { joined = joined + out[i] + "|" } + let plain = ui_overridden(ui_st, "examples/library/ui_component_parts/Counter.xml") + let dotted = ui_overridden(ui_st, "./examples/library/../library/ui_component_parts/Counter.xml") + print(`{joined} {plain} {dotted}`) + } +} diff --git a/examples/library/ui_pointer.ludic b/examples/library/ui_pointer.ludic new file mode 100644 index 00000000..3a6e615d --- /dev/null +++ b/examples/library/ui_pointer.ludic @@ -0,0 +1,62 @@ +# ui_pointer.ludic — pointer events: a native map takes presses, drags (still its own when the +# pointer leaves it, until let go), the wheel and the release, but not a press on the button over it; +# a pad takes the same in markup; a hold button says on-down and on-up, and is :active while held. +import "ludic.ui" +import "ui_pointer_parts/Chart.ludic" +program UiPointer { + numbers float + state UiPointerState { + log: string = "" + dragged: float = 0.0 + } + function map_measure(n: UiNode, avail: float) -> void { } + function map_draw(n: UiNode) -> void { } + function map_input(ui_pointer_st: mut UiPointerState, n: UiNode, e: UiPointer) -> bool { + if e.kind == "drag" { ui_pointer_st.dragged = ui_pointer_st.dragged + e.dx } else if e.kind != "pointermove" { ui_pointer_st.log = ui_pointer_st.log + `{e.kind} {int(e.x)},{int(e.y)} {e.button} {int(e.wheel)}; ` } + return true + } + function frame(ui_st: mut UiState, x: float, y: float, down: bool, wheel: float, enter: bool) -> UiNode { + let i = new UiInput + i.x = x + i.y = y + i.down = down + i.wheel = wheel + i.held_enter = enter + ui_input(ui_st, i) + ui_show(ui_st, "Chart", null, 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "Chart", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root.children[0] + } + function said(page: UiNode) -> string { return page.children[len(page.children) - 1].text } + entry (ui_pointer_st: UiPointerState, ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + ui_native(ui_st, "chartmap", fn map_measure, fn map_draw) + ui_native_input(ui_st, "chartmap", fn map_input) + let page = frame(ui_st, -1.0, -1.0, false, 0.0, false) + let box = page.children[0] + let pad = page.children[1] + let hold = page.children[2] + frame(ui_st, box.x + 20.0, box.y + 20.0, true, 0.0, false) + frame(ui_st, box.x + 20.0, box.y + 20.0, false, 0.0, false) + frame(ui_st, box.x + 100.0, box.y + 80.0, true, 0.0, false) + frame(ui_st, box.x + 130.0, box.y + 60.0, true, 0.0, false) + frame(ui_st, box.x + 300.0, box.y + 300.0, true, 0.0, false) + frame(ui_st, box.x + 300.0, box.y + 300.0, false, 0.0, false) + frame(ui_st, box.x + 50.0, box.y + 50.0, false, 1.0, false) + frame(ui_st, pad.x + 7.0, pad.y + 9.0, true, 0.0, false) + frame(ui_st, pad.x + 17.0, pad.y + 9.0, true, 0.0, false) + frame(ui_st, pad.x + 17.0, pad.y + 9.0, false, 0.0, false) + frame(ui_st, pad.x + 17.0, pad.y + 9.0, false, 2.0, false) + frame(ui_st, hold.x + 5.0, hold.y + 5.0, true, 0.0, false) + let down = frame(ui_st, hold.x + 5.0, hold.y + 5.0, true, 0.0, false) + let active = down.children[2].bg + let mid = said(down) + frame(ui_st, hold.x + 5.0, hold.y + 90.0, false, 0.0, false) + let off = said(frame(ui_st, -1.0, -1.0, false, 0.0, false)) + frame(ui_st, -1.0, -1.0, false, 0.0, true) + let keyed = said(frame(ui_st, -1.0, -1.0, false, 0.0, true)) + let up = frame(ui_st, -1.0, -1.0, false, 0.0, false) + print(`{ui_pointer_st.log}dragged {int(ui_pointer_st.dragged)} | {mid} active {active} | {off} | {keyed} | {said(up)} {up.children[2].bg}`) + } +} diff --git a/examples/library/ui_pointer_parts/Chart.lss b/examples/library/ui_pointer_parts/Chart.lss new file mode 100644 index 00000000..12ead9db --- /dev/null +++ b/examples/library/ui_pointer_parts/Chart.lss @@ -0,0 +1,7 @@ +.page { width: 400px; gap: 4px } +.box { width: 200px; height: 100px; position: relative } +.map { width: 200px; height: 100px } +.zoom { position: absolute; top: 10px; left: 10px; width: 30px; height: 30px; padding: 0 } +.pad { width: 100px; height: 50px } +.hold { width: 80px; height: 30px } +.hold:active { background: #ff0000 } diff --git a/examples/library/ui_pointer_parts/Chart.ludic b/examples/library/ui_pointer_parts/Chart.ludic new file mode 100644 index 00000000..e2717401 --- /dev/null +++ b/examples/library/ui_pointer_parts/Chart.ludic @@ -0,0 +1,12 @@ +# Chart.ludic - a native map with a zoom button over it, a pad that takes pointer events in markup, +# and a hold button that says when it goes down and comes up +component Chart { + state zoomed: int = 0 + state px: float = -1.0 + state dragx: float = 0.0 + state ups: int = 0 + state wh: float = 0.0 + state holding: bool = false + state held: int = 0 + state clicked: int = 0 +} diff --git a/examples/library/ui_pointer_parts/Chart.xml b/examples/library/ui_pointer_parts/Chart.xml new file mode 100644 index 00000000..ba64f3d7 --- /dev/null +++ b/examples/library/ui_pointer_parts/Chart.xml @@ -0,0 +1,9 @@ +
+
+ + +
+
+ +

zoomed {zoomed} pad {px} {dragx} {ups} {wh} hold {holding} {held} {clicked}

+
diff --git a/examples/library/ui_pointer_through.ludic b/examples/library/ui_pointer_through.ludic new file mode 100644 index 00000000..a2c5e69e --- /dev/null +++ b/examples/library/ui_pointer_through.ludic @@ -0,0 +1,55 @@ +# ui_pointer_through.ludic - a screen whose root
holds only a positioned panel (a component's +# root round a modal) has no size of its own; the pointer looks through it, so the panel's button is +# pressed and hovered - once nothing under it was. A select's < and > answer :hover as elements do. +import "ludic.ui" +program UiPointerThrough { + numbers float + state ThroughState { + got: int = 0 + v: int = 1 + } + view V (through_st: mut ThroughState) { + v = through_st.v + on hit() { through_st.got += 1 } + on set(x: int) { through_st.v = x } + } + const PAGE: string = "
" + function frame(ui_st: mut UiState, x: float, y: float, down: bool) -> UiNode { + let i = new UiInput + i.x = x + i.y = y + i.down = down + ui_input(ui_st, i) + ui_show(ui_st, "s", view_v(), 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "s", view_v()) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root + } + function find(n: UiNode, tag: string, cls: string) -> UiNode { + var has = cls == "" + for i in 0 .. len(n.classes) { if n.classes[i] == cls { has = true } } + if n.tag == tag and has { return n } + for i in 0 .. len(n.children) { + let f = find(n.children[i], tag, cls) + if f != null { return f } + } + return null + } + entry (ui_st: mut UiState, through_st: ThroughState) { + ui_backend(ui_st, new UiBackend) + ui_load_text(ui_st, PAGE, "s.xml") + var root = frame(ui_st, -1.0, -1.0, false) + let b = find(root, "button", "") + let x = b.x + b.cw / 2.0 + let y = b.y + b.ch / 2.0 + frame(ui_st, x, y, false) + root = frame(ui_st, x, y, false) + let hover = ui_hex(find(root, "button", "").bg) + frame(ui_st, x, y, true) + frame(ui_st, x, y, false) + let nx = find(root, "span", "ui-next") + frame(ui_st, nx.x + nx.cw / 2.0, nx.y + nx.ch / 2.0, false) + root = frame(ui_st, nx.x + nx.cw / 2.0, nx.y + nx.ch / 2.0, false) + print(`pressed {through_st.got}, hovered {hover}; > hovered {find(root, "span", "ui-next").hovered}`) + } +} diff --git a/examples/library/ui_popover.ludic b/examples/library/ui_popover.ludic new file mode 100644 index 00000000..4aafafdd --- /dev/null +++ b/examples/library/ui_popover.ludic @@ -0,0 +1,59 @@ +# ui_popover.ludic — HTML's popover and progress in ludic.ui: a button opens a popover of verbs, the +# keyboard stays inside it, a press outside or Esc closes it, and a progress bar fills to its value. +import "ludic.ui" +import "ui_popover_parts/Verbs.ludic" +program UiPopover { + numbers float + function frame(ui_st: mut UiState, i: UiInput) -> UiNode { + ui_input(ui_st, i) + ui_show(ui_st, "Verbs", null, 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "Verbs", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root + } + function click(ui_st: mut UiState, x: float, y: float) -> void { + let d = new UiInput + d.x = x + d.y = y + d.down = true + frame(ui_st, d) + let u = new UiInput + u.x = x + u.y = y + frame(ui_st, u) + } + function said(root: UiNode) -> string { + let page = root.children[0] + return page.children[len(page.children) - 1].text + } + entry (ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + var root = frame(ui_st, new UiInput) + let cell = root.children[0].children[0] + click(ui_st, cell.x + 2.0, cell.y + 2.0) + root = frame(ui_st, new UiInput) + let tab = new UiInput + tab.tab = true + frame(ui_st, tab) + frame(ui_st, tab) + root = frame(ui_st, tab) + let enter = new UiInput + enter.enter = true + root = frame(ui_st, enter) + root = frame(ui_st, new UiInput) + let first = said(root) + click(ui_st, cell.x + 2.0, cell.y + 2.0) + root = frame(ui_st, new UiInput) + let opened = said(root) + click(ui_st, 390.0, 390.0) + root = frame(ui_st, new UiInput) + let outside = said(root) + click(ui_st, cell.x + 2.0, cell.y + 2.0) + let esc = new UiInput + esc.escape = true + frame(ui_st, esc) + root = frame(ui_st, new UiInput) + let bar = root.children[0].children[1] + print(`{first} | {opened} | {outside} | {said(root)} | fill {int(bar.children[0].cw)} of {int(bar.cw)}`) + } +} diff --git a/examples/library/ui_popover_align.ludic b/examples/library/ui_popover_align.ludic new file mode 100644 index 00000000..45b8c6c2 --- /dev/null +++ b/examples/library/ui_popover_align.ludic @@ -0,0 +1,20 @@ +# ui_popover_align.ludic — anchored popovers aligned along their side (start, center, end), kept +# within a named container (within="panel") and, by default, within the scroll box they are in, +# flipping to the other side against it rather than against the screen. +import "ludic.ui" +import "ui_popover_align_parts/Menus.ludic" +program UiPopoverAlign { + numbers float + function at(n: UiNode, base: UiNode) -> string { return `{int(n.x - base.x)},{int(n.y - base.y)}` } + entry (ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + ui_show(ui_st, "Menus", null, 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "Menus", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + let panel = root.children[0].children[0] + let list = root.children[0].children[1] + let b = list.children[1] + let p3 = list.children[2] + print(`centred, flipped in the panel {at(panel.children[1], panel)} | end below {at(panel.children[2], panel)} | above b in the list {int(p3.y + p3.ch - b.y)} inside {p3.y >= list.y}`) + } +} diff --git a/examples/library/ui_popover_align_parts/Menus.lss b/examples/library/ui_popover_align_parts/Menus.lss new file mode 100644 index 00000000..136750a7 --- /dev/null +++ b/examples/library/ui_popover_align_parts/Menus.lss @@ -0,0 +1,8 @@ +.page { width: 400px; height: 400px } +.panel { width: 200px; height: 150px; position: relative } +.a { position: absolute; left: 150px; top: 50px; width: 40px; height: 20px; padding: 0 } +.p1 { width: 60px; height: 40px } +.p2 { width: 80px; height: 30px } +.list { overflow: auto; width: 150px; height: 100px } +.gap { height: 60px } +.p3 { width: 50px; height: 40px } diff --git a/examples/library/ui_popover_align_parts/Menus.ludic b/examples/library/ui_popover_align_parts/Menus.ludic new file mode 100644 index 00000000..3269819a --- /dev/null +++ b/examples/library/ui_popover_align_parts/Menus.ludic @@ -0,0 +1,3 @@ +# Menus.ludic - popovers anchored inside a panel and inside a scroll box, aligned along their side +component Menus { +} diff --git a/examples/library/ui_popover_align_parts/Menus.xml b/examples/library/ui_popover_align_parts/Menus.xml new file mode 100644 index 00000000..bb735bfb --- /dev/null +++ b/examples/library/ui_popover_align_parts/Menus.xml @@ -0,0 +1,12 @@ +
+
+ +
+
+
+
+

x

+ +
+
+
diff --git a/examples/library/ui_popover_mouse.ludic b/examples/library/ui_popover_mouse.ludic new file mode 100644 index 00000000..921a0864 --- /dev/null +++ b/examples/library/ui_popover_mouse.ludic @@ -0,0 +1,52 @@ +# ui_popover_mouse.ludic — a popover takes the mouse: its own button, at the same pixels as the grid +# cell under it, is the one a click presses; a click outside it (on that cell, or on nothing) closes +# it and presses nothing else. +import "ludic.ui" +import "ui_popover_mouse_parts/Grid.ludic" +program UiPopoverMouse { + numbers float + function frame(ui_st: mut UiState, i: UiInput) -> UiNode { + ui_input(ui_st, i) + ui_show(ui_st, "Grid", null, 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "Grid", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root + } + function click(ui_st: mut UiState, x: float, y: float) -> string { + let d = new UiInput + d.x = x + d.y = y + d.down = true + frame(ui_st, d) + let u = new UiInput + u.x = x + u.y = y + frame(ui_st, u) + let root = frame(ui_st, new UiInput) + let page = root.children[0] + return page.children[len(page.children) - 1].text + } + function find(n: UiNode, id: string) -> UiNode { + if n.id == id { return n } + for i in 0 .. len(n.children) { + let f = find(n.children[i], id) + if f != null { return f } + } + return null + } + entry (ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + frame(ui_st, new UiInput) + let opened = click(ui_st, 20.0, 20.0) + let root = frame(ui_st, new UiInput) + let drop = find(root, "drop") + let cell = find(root, "cell") + let over = drop.x >= cell.x and drop.y >= cell.y and drop.x + drop.cw <= cell.x + cell.cw + let dropped = click(ui_st, drop.x + drop.cw / 2.0, drop.y + drop.ch / 2.0) + click(ui_st, 20.0, 20.0) + let on_cell = click(ui_st, 180.0, 90.0) + click(ui_st, 20.0, 20.0) + let on_nothing = click(ui_st, 390.0, 390.0) + print(`over the cell {over} | {opened} | {dropped} | {on_cell} | {on_nothing}`) + } +} diff --git a/examples/library/ui_popover_mouse_parts/Grid.lss b/examples/library/ui_popover_mouse_parts/Grid.lss new file mode 100644 index 00000000..b80c6af9 --- /dev/null +++ b/examples/library/ui_popover_mouse_parts/Grid.lss @@ -0,0 +1,3 @@ +.page { width: 300px; height: 300px } +.cell { width: 200px; height: 100px } +.verbs { top: 10px; left: 10px; width: 120px } diff --git a/examples/library/ui_popover_mouse_parts/Grid.ludic b/examples/library/ui_popover_mouse_parts/Grid.ludic new file mode 100644 index 00000000..67823d5f --- /dev/null +++ b/examples/library/ui_popover_mouse_parts/Grid.ludic @@ -0,0 +1,8 @@ +# Grid.ludic - a grid cell with a popover of verbs laid over it, written BEFORE the cell, so the cell +# comes later in the tree and would win any hit test that went by the order things are drawn in +component Grid { + state open: bool = false + state opened: int = 0 + state dropped: int = 0 + state closed: int = 0 +} diff --git a/examples/library/ui_popover_mouse_parts/Grid.xml b/examples/library/ui_popover_mouse_parts/Grid.xml new file mode 100644 index 00000000..e119d580 --- /dev/null +++ b/examples/library/ui_popover_mouse_parts/Grid.xml @@ -0,0 +1,9 @@ +
+ +
+ +
+
+ +

opened {opened} dropped {dropped} closed {closed} {open}

+
diff --git a/examples/library/ui_popover_parts/Verbs.lss b/examples/library/ui_popover_parts/Verbs.lss new file mode 100644 index 00000000..ae965783 --- /dev/null +++ b/examples/library/ui_popover_parts/Verbs.lss @@ -0,0 +1,3 @@ +.page { width: 300px; height: 200px; gap: 4px } +.verbs { top: 40px; left: 10px; width: 120px } +progress { width: 200px } diff --git a/examples/library/ui_popover_parts/Verbs.ludic b/examples/library/ui_popover_parts/Verbs.ludic new file mode 100644 index 00000000..090f1929 --- /dev/null +++ b/examples/library/ui_popover_parts/Verbs.ludic @@ -0,0 +1,6 @@ +# Verbs.ludic - a grid cell that opens a popover of verbs +component Verbs { + state open: bool = false + state used: string = "none" + state health: int = 30 +} diff --git a/examples/library/ui_popover_parts/Verbs.xml b/examples/library/ui_popover_parts/Verbs.xml new file mode 100644 index 00000000..34ec8bb4 --- /dev/null +++ b/examples/library/ui_popover_parts/Verbs.xml @@ -0,0 +1,11 @@ +
+ + +
+ + +
+
+ +

{used} {open}

+
diff --git a/examples/library/ui_react.ludic b/examples/library/ui_react.ludic new file mode 100644 index 00000000..09edaf48 --- /dev/null +++ b/examples/library/ui_react.ludic @@ -0,0 +1,61 @@ +# ui_react.ludic — ludic.ui's React side without a renderer: keyed lists keep each item's state when +# the list is reordered, names a value, context reaches through a component, named slots, +# on-mount and on-unmount, and a native element of the program's own firing on-change with a value. +import "ludic.ui" +program UiReact { + numbers float + state UiReactState { + names: []string = new []string + volume: int = 3 + mounted: int = 0 + unmounted: int = 0 + show_extra: bool = true + } + view Mixer (ui_react_st: mut UiReactState) { + names = ui_react_st.names + volume = ui_react_st.volume + extra = ui_react_st.show_extra + on set_volume(v: int) { ui_react_st.volume = v } + on mount() { ui_react_st.mounted += 1 } + on unmount() { ui_react_st.unmounted += 1 } + } + # a dial the program draws itself: it measures 40 square and, pressed, turns up by one + function dial_measure(n: UiNode, avail: float) -> void { + n.mw = 40.0 + n.mh = 40.0 + } + function dial_draw(n: UiNode) -> void { } + const PAGE: string = "\n\n

{theme}

\n
\n\n \n \n\n\n \n \n \n \n

{loud ? 'loud' : 'quiet'}

\n
\n
\n \n \n \n

extra

\n \n

checkbox {on}

\n
\n
\n" + function texts(n: UiNode, out: []string) -> void { + if n.text != "" { push(out, n.text) } + for i in 0 .. len(n.children) { texts(n.children[i], out) } + } + function frame(ui_st: mut UiState) -> UiNode { + ui_show(ui_st, "mix", view_mixer(), 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "mix", view_mixer()) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root + } + entry (ui_react_st: mut UiReactState, ui_st: mut UiState) { + ui_native(ui_st, "dial", fn dial_measure, fn dial_draw) + push(ui_react_st.names, "bass") + push(ui_react_st.names, "drums") + ui_load_text(ui_st, PAGE, "ui/mix.xml") + var root = frame(ui_st) + ui_press(ui_st, root.children[2]) + ui_react_st.names[0] = "drums" + ui_react_st.names[1] = "bass" + root = frame(ui_st) + let dial = root.children[3] + ui_event(ui_st, dial, "change", Value.int(ui_react_st.volume + 4)) + ui_event(ui_st, root.children[4], "change", Value.bool(1)) + ui_react_st.show_extra = false + root = frame(ui_st) + let out = new []string + texts(root, out) + var joined = "" + for i in 0 .. len(out) { joined = joined + out[i] + "|" } + print(joined) + print(`volume {ui_react_st.volume}; dial {int(dial.cw)}x{int(dial.ch)}; mounted {ui_react_st.mounted}, unmounted {ui_react_st.unmounted}`) + } +} diff --git a/examples/library/ui_reload.ludic b/examples/library/ui_reload.ludic new file mode 100644 index 00000000..9084d431 --- /dev/null +++ b/examples/library/ui_reload.ludic @@ -0,0 +1,29 @@ +# ui_reload.ludic — a template read from disk, changed there, and read again by ui_reload: the new +# text shows at once, and the screen's state (a counter pressed before the change) is kept. +import "ludic.ui" +program UiReload { + numbers float + view Note { + title = "note" + } + const PATH: string = "build/ui_reload_example.xml" + const V1: string = "" + const V2: string = "

{title} again {n}

" + function shown(ui_st: mut UiState) -> string { + let root: UiNode = ui_nodes(ui_st, "note", view_note()) + let first = root.children[0] + if len(first.children) > 0 { return first.children[0].text } + return first.text + } + entry (ui_st: mut UiState) { + Fs.write_text(PATH, V1) + ui_load(ui_st, PATH) + let root: UiNode = ui_nodes(ui_st, "note", view_note()) + ui_press(ui_st, root.children[0]) + let before = shown(ui_st) + let same = ui_reload(ui_st) + Fs.write_text(PATH, V2) + let changed = ui_reload(ui_st) + print(`{before} | reloaded {same} then {changed} | {shown(ui_st)}`) + } +} diff --git a/examples/library/ui_remount.ludic b/examples/library/ui_remount.ludic new file mode 100644 index 00000000..ed607165 --- /dev/null +++ b/examples/library/ui_remount.ludic @@ -0,0 +1,30 @@ +# ui_remount.ludic - a component that comes and goes: mounted again, it starts as new (its state back +# to its defaults), and a thousand comings and goings hold the heap flat, because an unmounted +# instance is put back and mounted again rather than a record, props and model made each time +import "ludic.ui" +import "ui_remount_parts/Counter.ludic" +import "ui_remount_parts/Shown.ludic" +program UiRemount { + numbers float + function frame(ui_st: mut UiState) -> UiNode { + let root: UiNode = ui_nodes(ui_st, "Shown", null) + ui_place(ui_st, root, 0.0, 0.0, 300.0, 200.0) + return root + } + entry (ui_st: mut UiState) { + var root = frame(ui_st) + let c = root.children[0].children[1] + ui_press(ui_st, c.children[1]) + ui_press(ui_st, c.children[1]) + root = frame(ui_st) + let before = root.children[0].children[1].children[0].text + var at = 0 + for i in 0 .. 2000 { + ui_press(ui_st, root.children[0].children[0]) + root = frame(ui_st) + if i == 99 { at = Os.heap_bytes() } + } + let grew = Os.heap_bytes() - at + print(`{before} | {root.children[0].children[1].children[0].text} | heap {grew}`) + } +} diff --git a/examples/library/ui_remount_parts/Counter.lss b/examples/library/ui_remount_parts/Counter.lss new file mode 100644 index 00000000..e69de29b diff --git a/examples/library/ui_remount_parts/Counter.ludic b/examples/library/ui_remount_parts/Counter.ludic new file mode 100644 index 00000000..57112f96 --- /dev/null +++ b/examples/library/ui_remount_parts/Counter.ludic @@ -0,0 +1,9 @@ +# Counter.ludic - a counter that starts at 0 each time it is mounted +component Counter { + prop label: string + prop step: int = 1 + state count: int = 0 + doubled: int = count * 2 + function big() -> bool { return count > 3 } + on bump() { count += step } +} diff --git a/examples/library/ui_remount_parts/Counter.xml b/examples/library/ui_remount_parts/Counter.xml new file mode 100644 index 00000000..617de5aa --- /dev/null +++ b/examples/library/ui_remount_parts/Counter.xml @@ -0,0 +1,5 @@ +
+

{label}: {count} ({doubled}){big() ? ' big' : ''}

+ + +
diff --git a/examples/library/ui_remount_parts/Shown.ludic b/examples/library/ui_remount_parts/Shown.ludic new file mode 100644 index 00000000..8d808144 --- /dev/null +++ b/examples/library/ui_remount_parts/Shown.ludic @@ -0,0 +1,5 @@ +# Shown.ludic - a counter shown or not: each time it comes back it is a new counter +component Shown { + state on: bool = true + on flip() { on = not on } +} diff --git a/examples/library/ui_remount_parts/Shown.xml b/examples/library/ui_remount_parts/Shown.xml new file mode 100644 index 00000000..cb93e733 --- /dev/null +++ b/examples/library/ui_remount_parts/Shown.xml @@ -0,0 +1,4 @@ +
+ + +
diff --git a/examples/library/ui_scroll_hold.ludic b/examples/library/ui_scroll_hold.ludic new file mode 100644 index 00000000..70e2811a --- /dev/null +++ b/examples/library/ui_scroll_hold.ludic @@ -0,0 +1,32 @@ +# ui_scroll_hold.ludic — scroll-top holds a box at an offset: one follows its state, which on-scroll +# keeps in step with the wheel; one stays where it is held; and ui_scroll_set moves another once. +import "ludic.ui" +import "ui_scroll_hold_parts/Held.ludic" +program UiScrollHold { + numbers float + function frame(ui_st: mut UiState, x: float, y: float, wheel: float) -> UiNode { + let i = new UiInput + i.x = x + i.y = y + i.wheel = wheel + ui_input(ui_st, i) + ui_show(ui_st, "Held", null, 0.0, 0.0, 400.0, 600.0) + let root: UiNode = ui_nodes(ui_st, "Held", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 600.0) + return root.children[0] + } + function at(p: UiNode) -> string { return `{int(p.children[0].scroll_y)} {int(p.children[1].scroll_y)} {int(p.children[2].scroll_y)} {p.children[3].text}` } + entry (ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + let p = frame(ui_st, -1.0, -1.0, 0.0) + let first = at(frame(ui_st, -1.0, -1.0, 0.0)) + frame(ui_st, p.children[0].x + 5.0, p.children[0].y + 5.0, 1.0) + let followed = at(frame(ui_st, -1.0, -1.0, 0.0)) + frame(ui_st, p.children[1].x + 5.0, p.children[1].y + 5.0, -1.0) + let fixed = at(frame(ui_st, -1.0, -1.0, 0.0)) + ui_scroll_set(ui_st, "free", 70.0) + let set = at(frame(ui_st, -1.0, -1.0, 0.0)) + frame(ui_st, p.children[2].x + 5.0, p.children[2].y + 5.0, 1.0) + print(`{first} | {followed} | {fixed} | {set} | {at(frame(ui_st, -1.0, -1.0, 0.0))}`) + } +} diff --git a/examples/library/ui_scroll_hold_parts/Held.lss b/examples/library/ui_scroll_hold_parts/Held.lss new file mode 100644 index 00000000..3d39fcdf --- /dev/null +++ b/examples/library/ui_scroll_hold_parts/Held.lss @@ -0,0 +1,3 @@ +.page { width: 400px; gap: 4px } +.list { overflow: auto; width: 200px; height: 100px } +p { height: 40px } diff --git a/examples/library/ui_scroll_hold_parts/Held.ludic b/examples/library/ui_scroll_hold_parts/Held.ludic new file mode 100644 index 00000000..a3c2c779 --- /dev/null +++ b/examples/library/ui_scroll_hold_parts/Held.ludic @@ -0,0 +1,5 @@ +# Held.ludic - three scroll boxes: one held at its state's offset and told when it moves, one held +# at a fixed offset, and one the program moves once +component Held { + state top: float = 120.0 +} diff --git a/examples/library/ui_scroll_hold_parts/Held.xml b/examples/library/ui_scroll_hold_parts/Held.xml new file mode 100644 index 00000000..94d975cb --- /dev/null +++ b/examples/library/ui_scroll_hold_parts/Held.xml @@ -0,0 +1,6 @@ +
+

{r}

+

{r}

+

{r}

+

top {top}

+
diff --git a/examples/library/ui_scrollbar.ludic b/examples/library/ui_scrollbar.ludic new file mode 100644 index 00000000..e7756500 --- /dev/null +++ b/examples/library/ui_scrollbar.ludic @@ -0,0 +1,54 @@ +# ui_scrollbar.ludic — a scroll box's bar held and dragged: a press on the thumb holds it where it +# was taken, the pointer held moves it in proportion, letting go leaves it; a press on the track +# jumps the thumb's middle there; the wheel still scrolls; and the rows under the bar are never pressed. +import "ludic.ui" +import "ui_scrollbar_parts/Long.ludic" +program UiScrollbar { + numbers float + state UiScrollbarState { + bx: float = 0.0 + by: float = 0.0 + } + function frame(ui_st: mut UiState, i: UiInput) -> UiNode { + ui_input(ui_st, i) + ui_show(ui_st, "Long", null, 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "Long", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root.children[0] + } + # the pointer at (bx + x, by + y), held or not; how far the box is scrolled then + function at(ui_scrollbar_st: UiScrollbarState, ui_st: mut UiState, x: float, y: float, down: bool) -> int { + let i = new UiInput + i.x = ui_scrollbar_st.bx + x + i.y = ui_scrollbar_st.by + y + i.down = down + return int(frame(ui_st, i).children[0].scroll_y) + } + function wheel(ui_scrollbar_st: UiScrollbarState, ui_st: mut UiState, x: float, y: float) -> int { + let i = new UiInput + i.x = ui_scrollbar_st.bx + x + i.y = ui_scrollbar_st.by + y + i.wheel = 1.0 + frame(ui_st, i) + return int(frame(ui_st, new UiInput).children[0].scroll_y) + } + entry (ui_scrollbar_st: mut UiScrollbarState, ui_st: mut UiState) { + ui_backend(ui_st, new UiBackend) + let list = frame(ui_st, new UiInput).children[0] + ui_scrollbar_st.bx = list.x + ui_scrollbar_st.by = list.y + let grab = at(ui_scrollbar_st, ui_st, 195.0, 10.0, true) + let moved = at(ui_scrollbar_st, ui_st, 195.0, 40.0, true) + let far = at(ui_scrollbar_st, ui_st, 195.0, 300.0, true) + let back = at(ui_scrollbar_st, ui_st, 195.0, 25.0, true) + let kept = at(ui_scrollbar_st, ui_st, 195.0, 25.0, false) + at(ui_scrollbar_st, ui_st, 0.0 - ui_scrollbar_st.bx - 1.0, 0.0 - ui_scrollbar_st.by - 1.0, false) + let jump = at(ui_scrollbar_st, ui_st, 195.0, 80.0, true) + at(ui_scrollbar_st, ui_st, 195.0, 80.0, false) + let wheeled = wheel(ui_scrollbar_st, ui_st, 50.0, 50.0) + at(ui_scrollbar_st, ui_st, 50.0, 20.0, true) + at(ui_scrollbar_st, ui_st, 50.0, 20.0, false) + let page = frame(ui_st, new UiInput) + print(`grab {grab} moved {moved} far {far} back {back} kept {kept} | jump {jump} | wheel {wheeled} | {page.children[1].text}`) + } +} diff --git a/examples/library/ui_scrollbar_parts/Long.lss b/examples/library/ui_scrollbar_parts/Long.lss new file mode 100644 index 00000000..5cee44f2 --- /dev/null +++ b/examples/library/ui_scrollbar_parts/Long.lss @@ -0,0 +1,3 @@ +.page { width: 300px; height: 300px } +.list { overflow: auto; width: 200px; height: 100px } +.row { width: fill; height: 40px; padding: 0; border-radius: 0 } diff --git a/examples/library/ui_scrollbar_parts/Long.ludic b/examples/library/ui_scrollbar_parts/Long.ludic new file mode 100644 index 00000000..bbdf2132 --- /dev/null +++ b/examples/library/ui_scrollbar_parts/Long.ludic @@ -0,0 +1,5 @@ +# Long.ludic - a box of ten rows, four times taller than it shows; each row is a button as wide as +# the box, so the scrollbar sits over them +component Long { + state pressed: int = 0 +} diff --git a/examples/library/ui_scrollbar_parts/Long.xml b/examples/library/ui_scrollbar_parts/Long.xml new file mode 100644 index 00000000..08daf0ea --- /dev/null +++ b/examples/library/ui_scrollbar_parts/Long.xml @@ -0,0 +1,4 @@ +
+
+

pressed {pressed}

+
diff --git a/examples/library/ui_select_arrows.ludic b/examples/library/ui_select_arrows.ludic new file mode 100644 index 00000000..20352a2a --- /dev/null +++ b/examples/library/ui_select_arrows.ludic @@ -0,0 +1,51 @@ +# ui_select_arrows.ludic - a select is a cycler: a press let go on its < steps back and on its > +# (or anywhere else on it, or Enter) forward. The < once stepped forward too. +import "ludic.ui" +program UiSelectArrows { + numbers float + state SelState { v: int = 1 } + view V (sel_st: mut SelState) { + v = sel_st.v + on set(x: int) { sel_st.v = x } + } + const PAGE: string = "" + function frame(ui_st: mut UiState, x: float, y: float, down: bool) -> UiNode { + let i = new UiInput + i.x = x + i.y = y + i.down = down + ui_input(ui_st, i) + ui_show(ui_st, "s", view_v(), 0.0, 0.0, 400.0, 400.0) + let root: UiNode = ui_nodes(ui_st, "s", view_v()) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root + } + function part(n: UiNode, cls: string) -> UiNode { + for i in 0 .. len(n.children) { + if List.contains(n.children[i].classes, cls) { return n.children[i] } + let f = part(n.children[i], cls) + if f != null { return f } + } + return null + } + function click(ui_st: mut UiState, cls: string) -> void { + let root = frame(ui_st, -1.0, -1.0, false) + let p = part(root, cls) + let x = p.x + p.cw / 2.0 + let y = p.y + p.ch / 2.0 + frame(ui_st, x, y, false) + frame(ui_st, x, y, true) + frame(ui_st, x, y, false) + frame(ui_st, x, y, false) + } + entry (ui_st: mut UiState, sel_st: SelState) { + ui_backend(ui_st, new UiBackend) + ui_load_text(ui_st, PAGE, "s.xml") + frame(ui_st, -1.0, -1.0, false) + click(ui_st, "ui-prev") + let a = sel_st.v + click(ui_st, "ui-next") + click(ui_st, "ui-next") + print(`prev {a} next next {sel_st.v}`) + } +} diff --git a/examples/library/ui_select_disabled.ludic b/examples/library/ui_select_disabled.ludic new file mode 100644 index 00000000..2adba55d --- /dev/null +++ b/examples/library/ui_select_disabled.ludic @@ -0,0 +1,55 @@ +# ui_select_disabled.ludic -