Compare commits

..

1,768 commits

Author SHA1 Message Date
5efe6e24c9 fix(counter): drop local makepad-native-glue path dep
The examples/counter manifest referenced makepad-native-glue via an
out-of-repo relative path (../../../) — a local nigig glue crate not
present in upstream makepad. The dependency was never used by counter
code, so remove it to keep the fork tree self-contained.
2026-08-16 02:45:24 +03:00
28044a7367 feat(test): Android makepad_test via adb + native-activity, standalone terminal mode
Apply the makepad_test workstream onto the fork's portallist pin:
- makepad_test: runtime support for terminal (standalone) + Android (adb) runners,
  dropping the Studio-hub-only requirement for headless UI tests
- platform: native_activity Android path (looper_wrapper.c, android_native.rs,
  android_jni_native.rs), standalone terminal app_main path
- cargo-makepad: native_activity + profile-dependent prefer-dynamic in android
  build; keystore-create/build-aab packaging inputs
- studio hub: standalone hub_server binary for headless run
- examples/counter: terminal UI test entry (wait_text/click via Selector) ready
  for Android + desktop parity

Base: fork portallist_flow_adaptive_view d82756a (keeps map work + re-exports).
2026-08-16 02:17:48 +03:00
d82756a364 feat(map): sync with upstream/dev latest map improvements
- Updated tile.rs with baked fills/faces support
- Enhanced 3D building rendering
- Improved road geometry and elevation
- Better theme matching and styling
- Advanced overlay composition

This brings the fork up to date with makepad dev branch map capabilities.
2026-08-04 11:20:08 +00:00
Arena Agent
a79f0dce4d fix(widgets): remove the duplicated optional-dependency block
11375214 fixed the misplaced dependency block by adding a correct copy
inside [dependencies], but the misplaced copy below [features] was still
present -- it had already been moved back by 5eda8056, the commit
11375214 is built on. The result is three dependencies declared twice:

    error: duplicate key

so widgets/Cargo.toml still does not parse and every consumer of the
fork is still blocked, just with a different message.

Two people fixed the same bug in parallel. Removing the second, now
redundant, copy; the surviving declaration sits with the other optional
sibling crates in [dependencies].

Verified:
    cargo metadata --manifest-path widgets/Cargo.toml \
      --features maps,csg,gltf,test
resolves.
2026-07-31 19:29:17 +00:00
11375214b3 fix: move fork-specific dependencies to correct section in widgets/Cargo.toml
The makepad-gltf, makepad-csg, and makepad-test dependencies were incorrectly
placed after the [features] section, causing TOML parsing errors. Moved them
to the [dependencies] section where they belong.
2026-07-31 19:24:14 +00:00
Arena Agent
5eda8056f4 fix(widgets): move three dependency lines back into [dependencies]
The "Update fork to upstream dev 5d4483f" merge relocated this block:

    makepad-gltf  = { path = "../libs/gltf",         optional = true }
    makepad-csg   = { path = "../libs/csg/csg",      optional = true }
    makepad-test  = { path = "../libs/makepad_test", optional = true }

from the end of [dependencies] to below the [features] header. TOML has
no way to know these are dependencies once they sit under [features], so
cargo parses each as a feature definition whose value should be an array
of strings and fails:

    error: invalid type: map, expected a sequence
      --> widgets/Cargo.toml:47:16

Every consumer of this fork is broken as a result. The three crates stop
being dependencies at all, so the features that gate them --
gltf/csg/test, and maps via i_overlay -- no longer exist:

    package `nigig-map` depends on `makepad-widgets` with feature `maps`
    but `makepad-widgets` does not have that feature.
    help: available features: default, serde

At the previous rev (2c5cd97) the same three lines are inside
[dependencies], which is why that rev resolves and this one does not.

This is a pure relocation -- the six moved lines are byte-identical, no
version, path or flag changed. Verified with

    cargo metadata --manifest-path widgets/Cargo.toml \
      --features maps,csg,gltf,test

which fails on d6d1f99c and succeeds with this commit.
2026-07-31 19:14:24 +00:00
d6d1f99ca9 Update fork to upstream dev 5d4483f
- Sync with latest upstream dev branch
- Include all map improvements: 2D/3D toggle, shadows, labels, overlays
- Include platform updates: location API, audio echo cancellation
- Preserve fork-specific re-exports (gltf, csg, test)
2026-07-31 18:47:03 +00:00
817d881052 feat(map): sync with upstream dev branch map improvements
- Add drape.rs for terrain hillshade landcover draping
- Add overlay.rs for route polylines, markers, and position puck
- Add icons.rs and icons/ directory for map icon management
- Update geometry.rs with 3D road elevation and join improvements
- Update tile.rs with unified road mesh rendering
- Update view.rs with seamless 2D/3D mode transitions
- Update style.rs with night themes and emissive roads
- Add i_overlay dependency for polygon boolean operations
- Update maps feature to include i_overlay

Key improvements:
- 3D road elevation and seam continuity
- Building shadow geometry and terrain cast shadows
- Route assistant and navigation layer support
- Clickable themes and night mode
- Water, grass, and shrub rendering
- Optimized road geometry and mode transitions
2026-07-31 18:38:54 +00:00
16a4d04cbb feat(widgets): reexport optional Makepad sibling crates 2026-07-27 04:19:54 +00:00
Kevin Boos
273ccc452c PortalList: allow setting layout flow at runtime; AdaptiveView fixes
* PortalList: add `set_flow(cx, flow)` to switch a list between vertical and
  horizontal layout flow at runtime. Much faster than using `script_apply_eval`,
  and always fully correct because it updates the `vec_index` axis.
  It also avoids a full ScriptReapply sequence, which is potentially expensive
  across all of an app's widgets.
* AdaptiveView: add `active_variant()` getter so that widgets using AdaptiveView   do not have to separately track which variant it should be in.   Removes all ambiguity and possibility of divergence... finally!
  * Also fixes long-standing TODO item in AdaptiveView about properly handling
    weird window geom events, e.g., on macOS sometimes it spits out a 0-width
    window update which is total b.s.
* DisplayContext: add `is_desktop_width()` helper.
* Window: ignore spurious zero-size window geom events.
2026-07-23 18:44:18 -07:00
Athan
b41e7404b6
feat : add advance vide player example (#1138)
Co-authored-by: Athan Xiao <Athan.Xiao@one.nz>
2026-07-23 12:16:19 +02:00
Patrik Husfloen
434583def4
feat(draw): add sdf.arc_to circular-arc path segment (#1142)
Replace the abandoned arc2 colour-stub with a real path primitive: the
distance to a bare circular-arc CENTERLINE (no baked thickness), so it
chains after move_to/line_to and is covered by a single stroke(w) -- unlike
arc_round_caps/arc_flat_caps which bake their own width. Angles in radians,
0 = +x axis, counter-clockwise positive; last_pos advances to the arc end so
a following line_to connects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

feat(draw): add sdf.arc_to circular-arc path segment
2026-07-23 12:14:24 +02:00
Kevin Boos
3af2e93f0d
Honor X & Y align values for deferred Fill child widgets (#1143)
A `Fill` child under Flow::Right (or `height: Fill` under Flow::Down) is
deferred and given the row's full slack up front, so end_turtle's align
step takes the deferred branch, which distributes width/height to fills
and drops main-axis align entirely. That's correct when a fill consumes
its whole slot, but a fill that draws narrower than its slot (e.g. an
Image that aspect-fits, or `Fill{max: N}` capped below the container)
leaves real slack that then anchors to the start regardless of align.

Add row_align_x_shift / col_align_y_shift: reclaim align * (inner -
actually_drawn) in each deferred branch. They early-return 0 when align
is 0, the inner size is unknown, or the content fills, so the only
behavior change is deferred-flow containers that set main-axis align and
hold an under-filling Fill child.

Add examples to the uizoo "Layout Demos" tab that proves they boht work.
2026-07-23 12:14:04 +02:00
Kevin Boos
b01e35faca
Avoid redrawing for simple text-based widgets (#1141)
redrawing is significantly more expensive than text comparisons,
and doing this outside of each widget is difficult and less efficient.

this helped avoid a bunch of redraw cycles in robrix,
so it's probably worth doing for all widgets, especially since
setting text or another basic property now does an auto-redraw
(it didn't used to be like that)
2026-07-23 12:13:44 +02:00
Kevin Boos
a4fea8e259
New unified kinetic scrolling. Vastly improve draw shaprness on low-res screens. Fix rendering and text perf issues on CPU+GPU (#1127)
* Fix rendering, gradient, sampling, etc issues on older GPUs

* Fix bug in `box_y` sdf function, which caused gradients to be split
  into two bands incorrectly. Mostly a problem on lower-res screens.
* Use per-texture filtering instead of GL sampler objects on Linux,
  especially for Mesa drivers that ignore min filter samplers.

This should help prevent blocky/pixellated things like emoji/avatars

* Improve rendering sharpness on low-DPI screens (icons, emoji, images)

On 1.0-DPI screens, emoji/SVG-icons/avatars were minified without
adequate sampling and SVG AA was sub-pixel, producing blocky/aliased
output. This reworks each path and adds optional full-window SSAA.

SVG icons (device-aware AA + round caps):
- draw_svg.rs/draw_vector.rs/render.rs: size the fill & stroke AA
  fringe and the curve-flatten tolerance in DEVICE pixels (≈constant
  regardless of icon size), so edges resolve via the analytic
  d/fwidth coverage and curves stay smooth at any scale; re-tessellate
  on scale change.
- triangulate.rs: thread the flatten tolerance through path fill/stroke.
- tessellate.rs: emit round caps as a solid disc (u=0.5) instead of a
  radial fade that collapsed to a square at small sizes.
- widgets/icon.rs: don't clip the Icon to its Fit bounds, so round
  caps that extend past the box render fully instead of being sheared.

Emoji:
- glyph_raster_image.rs/rasterizer.rs: rasterize color emoji near the
  on-screen size with an alpha-weighted box downscale (geometric-mean
  scale factor) instead of the font's native PNG strike.

Images / avatars (mipmaps):
- image_cache.rs/texture.rs/draw_list.rs/lib.rs: optionally emit a CPU
  mipmap chain (VecMipBGRAu8_32) for non-animated images so minified
  avatars/thumbnails sample cleanly. Env-gated MAKEPAD_IMAGE_MIPMAPS;
  default on for GL on Linux.
- metal.rs: real per-level mip upload. d3d11.rs/vulkan.rs/web_gl.rs:
  safe single-level fallback (no crash; real mips TODO).

Full-window supersampling (optional):
- window.rs: render the whole UI into an offscreen target at
  MAKEPAD_SUPERSAMPLE× device resolution and downscale-resolve into the
  window. Default 2×, env-tunable, 1× disables. Modeled on the existing
  GaussStack render-to-texture path.

* don't unconditionally enable supersampling SSAA of 2x by default

it's too expensive and too slow for most older devices

* cleanup, reduce comment verbosity

* improve SVG anti aliasing

* Windows: fix laggy/juddery scroll performance

- Pace the render loop to the display refresh using a DXGI frame-latency
  waitable object and present with vsync, replacing the free-spinning,
  uncapped Poll loop that caused uneven scroll cadence.
- Coalesce consecutive WM_MOUSEMOVE messages and paint once per loop pass
  to stop the judder when moving the mouse during fling deceleration.
- Cache get_dpi_factor() and the WM_NCHITTEST WindowDragQuery result to
  avoid per-mouse-move GetDeviceCaps syscalls and widget-tree hit-tests.
- Throttle XInput/DirectInput polling of empty/disconnected controller
  slots, which was stalling the UI thread.
- Rework the momentum fling to a native exponential model with a
  frame-interval EMA, and stop the tail auto-scroll from fighting an
  active fling/drag.
- D3D11: update the glyph atlas and image textures in place via
  UpdateSubresource instead of recreating them on every change, and
  spread D3D11 shader-object creation across frames.
- Slug atlas: only force a full re-layout on a width change; append rows
  on height growth.

* Windows: correctness fixes from review (off the scroll hot path)

- d3d11: close the DXGI frame-latency waitable HANDLE in Drop (it was
  leaked once per main-window lifecycle and the field comment was wrong);
  keep popup swap chains at frame-latency 1; track the waitable-swapchain
  flag for ResizeBuffers instead of inferring it from the handle; present
  without the vsync interval during a live resize.
- windows.rs: poll game input on the idle signal tick so a gamepad button
  can be serviced while the app is otherwise idle.
- win32_window / window: invalidate the WM_NCHITTEST / WindowDragQuery
  caches on window move and on a caption relayout, with a generation
  counter guarding against a reentrant invalidation being clobbered.
- windows_game_input: detect a controller already plugged in at launch via
  a one-shot full scan on the first poll, probe slot 0 (Player 1) first,
  and offset the DirectInput enumeration so it never stacks with the
  XInput probe.
- comment/doc corrections.

* Image cache: accept any Arc<D: AsRef<[u8]> + ?Sized> for async image data

The load_image_from_data_async family required Arc<Vec<u8>>, forcing callers
that already hold the bytes as Arc<[u8]> (e.g. a content-addressed media cache)
to copy the whole buffer via .to_vec() just to satisfy the type. Generalize the
data parameter to Arc<D> where D: AsRef<[u8]> + ?Sized, so those callers can pass
their existing Arc by refcount-clone with no byte copy. The decode path only ever
borrowed the bytes (&[u8]), so this is purely a signature relaxation; existing
Arc<Vec<u8>> callers are unaffected (D = Vec<u8>).

* Linux: GL glyph-atlas in-place texture update + X11/Wayland mouse-move coalescing

* Scroll: unified fling model + native trackpad momentum deceleration

Share one kinetic-scroll model between PortalList and ScrollBar (and thus
ScrollXView/ScrollYView/ScrollXYView) via a new widgets/src/scroll_motion.rs:

- Touch-drag flicks use an iOS-style exponential self-decay, frame-rate
  independent via a per-frame integrator with dt smoothing.
- Trackpad scrolling applies the OS momentum directly while fast (responsive,
  full native speed), then hands off to a gentler self-decaying tail once it
  slows past a threshold, so the deceleration is longer and smoother than the
  OS's short, choppy tail. Handoff is seeded at the current speed for a
  continuous transition; the seed is clamped against degenerate event timing.
- Add ScrollPhase to scroll events, mapped from NSEventPhase/momentumPhase on
  macOS and wl_pointer AxisStop on Wayland; None elsewhere (wheels/X11/Windows
  behave as before). MAKEPAD_RAW_TRACKPAD_MOMENTUM=1 bypasses the smoothed tail.
- A press catches an in-progress fling (stops the scroll, consumes the press so
  it doesn't also activate a child), matching iOS/Android/macOS.

* Shader codegen: prefix Metal/WGSL locals to avoid reserved-word collisions

The Metal/WGSL backends emitted user-declared shader locals verbatim, so a
local named after a reserved type keyword (e.g. `half`) produced invalid
shader source and failed to compile at runtime. Prefix them with `l_` like the
HLSL/GLSL backends already do.

* TextFlow: don't panic on unbalanced HTML close tags

end_code/end_quote unwrapped the area stack, so a stray `</pre>` or
`</blockquote>` in untrusted content (e.g. a chat message) panicked. Return
early instead, and drop a vestigial per-list-item area-stack push that leaked
an entry and could hand a stray close tag the wrong block's area.

* Scroll: expose fling decel, handoff threshold, & tail-decel as `#[live]` fields

This allows app devs to override the scroll feel per-widget in the DSL,
or globally by overriding the base widget's defaults — verified that a DSL
override takes effect.

* Windows: fix frame pacing, paste crash, and wheel input backlog

- Wait on the frame-latency waitable right before each window's vsync
  present instead of on every Paint, so input no longer stalls behind
  waits that have no matching present. Drain leftover credits after a
  live resize.
- Pasting when the clipboard has no text no longer panics.
- The poll loop now handles up to 32 messages (2 ms) per frame and
  merges consecutive mouse-wheel messages, so fast wheels can't build a
  backlog that keeps scrolling after the gesture ends. Sleep 1 ms when a
  frame presents nothing so animation polling doesn't spin a core.

* Image: don't build unused mip chains; fix stale images in recycled widgets

- Only build the CPU mip chain when a backend actually uploads it
  (Metal, behind its env var), and build it on the decode thread instead
  of the UI thread. Linux GL still gets its mipmaps via glGenerateMipmap
  and now retains less CPU memory per image.
- Recycled Image widgets no longer show the previous item's image or
  apply an old decode result. Placeholder textures set via set_texture
  (like blurhashes) stay visible while the real image decodes, and a
  failed load clears the widget instead of leaving old content up.

* Widgets: avoid needless caption redraws; cheaper PortalList height tracking

- Label::set_text does nothing when the text hasn't changed, and the
  window caption title is only synced when it actually changes, so mouse
  moves and animation ticks no longer redraw the whole window every
  event. The caption centering padding requests its own redraw now.
- PortalList records item heights only when new or changed, and only
  re-applies the default height after it drifts by half a pixel, so big
  lists don't walk every unmeasured item on every scroll frame.

* Text: cache layouts of long texts; stop cloning glyph outlines every frame

- The layout cache now accepts texts of any length (long messages and
  code blocks used to re-layout on every scroll frame). It is a real LRU
  with a byte budget on top of the entry cap, and texts drawn in the
  current frame are never evicted, so one heavy frame can't thrash the
  cache into a permanent miss cycle. The shaper cache is LRU now too.
- Glyph outlines are shared via Rc, so drawing a cached glyph no longer
  copies its command list, and outline complexity is computed once when
  the outline is built instead of every frame.

* Html: fix stale links/spans in recycled widgets and <details> renumbering

- set_text only rebuilds when the content actually changed, so a
  recycled link can't open the previous message's URL, and re-setting
  identical content keeps the user's <details> open/closed state.
- Custom widgets and <details> are keyed by their node index instead of
  a visit-order counter, so toggling a collapsed <details> can't
  renumber the widgets after it and rebind them to the wrong nodes.
  item_with_scope also recreates its widget when the template changes.

* Linux: fixed-distance wheel scrolling; Wayland frame-callback pacing

- Wheel scrolling moves a fixed 60 px per detent on X11 and Wayland
  instead of a timing-based guess that flipped between 12 px and 240 px
  depending on how events batched. Wayland reads real detent counts via
  AxisValue120 (wl_seat v9, with AxisDiscrete as the older fallback) and
  maps keymaps MAP_PRIVATE as v7+ requires. Touchpads are unchanged.
- Wayland frames are paced with wl_surface frame callbacks and swap
  interval 0, so redrawing a hidden or minimized window can no longer
  hang the whole app inside eglSwapBuffers (compositors withhold frame
  callbacks for hidden windows). Windows with a callback in flight skip
  presenting and stay dirty; X11 keeps vsync exactly as before.

* Text: bigger layout cache budget, reclaimed at the end of each frame

A maximal ~60 KB message lays out to roughly 4 MB of glyphs, so the 4 MB
budget couldn't hold even one alongside a normal screen. Raise it to
16 MB, and run eviction at the end of every frame so memory over the
budget is freed one frame after its content leaves the screen, instead
of lingering until some later layout happens to insert a new entry.

* Fix oversized uniform slices: the array lengths were in bytes, not f32 elements

* Wayland: flush buffered mouse motion before scroll events, and drop motion for closed windows

* GL: fall back to non-mipmapped filtering when glGenerateMipmap fails on strict GLES3 drivers

* Text: bucket emoji raster scales so zooming reuses atlas slots instead of re-decoding every step

* Image: add has_content() and record texture provenance on cache-hit loads too

* Scroll: native trackpad momentum, Chrome-model bounce; presses catch motion, never click children

* Dock: redraw the newly selected tab immediately when the active tab is closed

* Html: standard link colors with pressed precedence; skip re-parsing unchanged text; color setters

* Image: don't redraw on cache-hit loads of the already-bound image (per-draw reloaders looped forever)

* Scroll: log macOS momentum-end phase bits to check Cancelled (touch-cut) vs Ended (natural fade)

* Scroll: momentum state machine; flicks survive pagination; edge sentinels & once-per-frame actions

* Scroll: remove the MAKEPAD_SCROLL_DEBUG diagnostics

* Scroll: time-based fling velocity window; pointer fan-out guard; parked flings survive pagination

* PortalList: optional reached-start/end margins (Some(0) default); repositioning re-announces the edges

* Linux/Wayland: honor UI zoom across window resizes; scale caption bar with zoom

Keep the wayland-side window geom in native units so the zoomed dpi isn't
read back as "native" on the next Configure — UI zoom no longer resets or
flickers on maximize/tile. Also let the caption bar height scale with the
zoom (pin to native only on macOS, where the buttons are OS traffic lights).

* Windows: D3D11 fixes for drawlist mgmt

trying to help with the `new_batch` bugs

* draw_list.rs — `set_zbias` now returns whether it changed
* d3d11.rs — uploads draw_call_uniforms on the given condition:
  `uniforms_dirty || zbias_changed || buffer.is_none()`
  and the zbias advance is hoisted above the early-continues

* Scroll: hard flicks carry farther and boost on re-flick; iOS-style touch rubber band with per-edge bounce gating; Android fling spline behind a flag

* Splitter: redraw both pane subtrees on drag; cached views keep fresh fixed sizes in the dirty check

* iOS: re-deliver window-geometry changes dropped by re-entrant UIKit callbacks; Init only ever from the first draw

* final cleanup fixes. Ensure all examples, experiments, `studio` all work

* Image: skip re-parsing an SVG that is already shown, keyed on the caller's shared bytes

* CachedView: fix upside-down offscreen texture on GL/GL-ES

GL/GL-ES store offscreen FBOs bottom-up, unlike Metal/D3D/WGSL.
The DSL->script-shader migration switched the CachedView composite to plain
.sample() (non-flipping sample2d on GL), so cached views rendered
vertically mirrored on GL/GL-ES; Metal was fine on macOS.

Add a `sample_rt` script sampler (emits the V-flipping sample2d_rt on
GLSL, plain no-flip sample elsewhere) and use it in the CachedView and
CachedRoundedView composites.

* Fix `AdaptiveView::redraw()` to actually do something
2026-07-21 09:59:12 +02:00
Admin
4f9ce7a8bb gamemaker: right analog stick rotates the camera, engine-default
Deadzone-rescaled right stick feeds the exact mouse-drag pipeline: same
0.01 rad/px orbit through pseudo-pixels (~2.6 rad/s full deflection, stick
up = look up), same look_dx/look_dy for scripts, same chase-rig authority
(stick held = kid owns the camera, recenters after release) and
cam_dragging visibility. Applied before script camera writes each tick,
like real mouse events, so set_cam_yaw still wins its tick. Zeroed under
tape tests for determinism. Camera-only pads now count in device selection.

splashgame.md: right stick documented; new rule — every new ability must
also be reachable from the gamepad (bind to the named actions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:18:55 +02:00
Admin
b08a098cc3 gamemaker: hide the perf overlay by default (F3 shows it)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:10:38 +02:00
Admin
221b6a4a72 gamemaker: Escape as push-to-talk alongside F1
VoiceWave grows an opt-in ptt_use_escape flag (Escape doubles as cancel/
dismiss elsewhere, so hosts choose); both keys drive the same logical talk
button. Gamemaker opts in via the caption_bar's hidden voice_wave and the
hints now read 'hold Esc' — the big friendly key for kids. Verified the
nested named-child merge lands on the live widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:05:45 +02:00
Admin
426501ff72 Move download_tts.sh to repo root next to the other download_* scripts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:53:47 +02:00
Admin
eb4165d5a0 macos: A/B diagnostics — MAKEPAD_NO_VSYNC, MAKEPAD_TIMER_TRACE, MAKEPAD_NO_GAUSS
Verdict on the gamemaker judder (measured, 25s A/B runs, same window size):
  gauss OFF        -> unchanged (25-33ms gaps)   [pyramid exonerated]
  empty game world -> unchanged                  [game exonerated]
  vsync OFF        -> FIXED (119.8fps steady, worst 9ms; slow callbacks 124->2)

The stall is CAMetalLayer display-sync throttling: the compositor (hardware-
mirrored + SwitchResX-scaled 7680x2160) returns drawables unevenly, and
nextDrawable blocks the main thread 10-25ms in phases. The durable fix is
present-gated pacing: track in-flight presents via addPresentedHandler and
skip the paint when the pool is busy instead of blocking the event loop.

MAKEPAD_TIMER_TRACE=1 logs paint-clock fire-to-fire gaps >20ms and slow
callbacks >10ms with a per-step breakdown (live_edit/net/pad/paint/gc).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:45:59 +02:00
Admin
e7a7cad092 perf 2026-07-10 15:34:38 +02:00
Admin
77e7d65fb7 macos: pace paint timer to display refresh; PerfGraph gpu channel + pass trace
The fixed 8ms timer0 presented ~125Hz into a 120Hz vsync queue: the drawable
pool drifted full and nextDrawable blocked the main thread in a ~25-frame
sawtooth (PerfGraph 'wait' ramps). Timer now arms at 1.002/max_fps across
attached NSScreens — the +0.2% makes NSTimer lateness drain the queue instead
of accumulating. Sawtooth verified gone.

perf_monitor: 'gpu' channel — presented-frame GPU interval tapped from the
existing command-buffer completion aggregation (atomic hand-off, folded at
frame_boundary; concurrent with CPU channels, plotted violet).

metal: MAKEPAD_GPU_PASS_TRACE=1 logs per-pass GPU time ([gpu-pass] name ms)
for frame-budget hunts.

Measured (gamemaker, empty world vs full racing game — identical ~4.7ms GPU):
the frame cost is the UI glass pipeline, not the game — scene capture +
6 mip downsamples + 3 smooth upsamples re-run every frame at 120Hz because
the game pane dirties the window; at low GPU clocks the 11-pass chain's
latency swings past the 8.3ms budget in phases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:30:08 +02:00
Admin
4cfb51877c PerfGraph: generic frame profiler widget + Cx perf monitor channels
platform: Cx.perf_monitor — per-frame ring (240) of paint-to-paint gap +
per-channel CPU us. Built-in channels: event dispatch (outermost, minus
app-attributed time), script exec, GC, pass encode, nextDrawable wait.
Apps register custom channels: cx.perf_monitor.channel("physics", rgb).
Off until enabled; hooks in event dispatch, macos repaint, metal draw_pass.

widgets: PerfGraph — corner-pinned live panel (DrawVector strips): frame-gap
bars colored against 120/60Hz budgets with guide lines, stacked per-channel
CPU, legend with averages. Self-positions bottom-right (DrawVector geometry
+ deferred turtle alignment don't mix — no aligning parent).

gamemaker: PerfGraph hovers the game pane (F3 toggles), engine feeds script
+ physics channels (incl. hot-reload evals); engine text overlay moved to
F4; per-phase engine window kept for ag perf / AIGAME_PERF=1; new 'ag perf'
harness verb + template guidance (template CLAUDE.md force-added: runtime
resource, blanket CLAUDE.md gitignore had kept it untracked).

Measured on my-game-5: engine frame CPU ~0.3ms; the hiccup is frame pacing —
the 8ms NSTimer paint clock beats against the 120Hz display, the drawable
pool drifts full and nextDrawable blocks the main thread in a ~25-frame
sawtooth (avg 2-3.4ms, spikes 20-30ms). The graph shows it as red ramps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:10:27 +02:00
Admin
fbd4972450 gamemaker: full engine round — chase camera rig, racing-game APIs, script stdlib math, real error line numbers
Engine (examples/gamemaker): chase camera rig (camera({chase}) — ease-behind
with mouse-wins/recenter authority), writable camera + look deltas, spatial
queries (raycast/overlap_sphere/ground_normal), save/load, sustained tones,
rot_y + collide:false spawnables, HUD slots/bars, terrain noise shaping +
height bands, per-shape instanced render batching with static slabs (3.2x),
error push-loop into the agent chat, unknown-verb/option diagnostics with
game.splash:line:col positions, streaming tail-statement finalization, quiet
toolbar UI, Shh voice hush, fable voice.

Platform: runtime vector methods (.length/.normalized/.dot/.cross), scalar+
vector lerp, TAU; ScriptVm error capture sink; window frame capture API; four
headless-JIT fixes (scalar casts, mat4 mul, Id-arg expansion, commuted
scalar-vec ops) with regression test stages. Widgets: single-line TextInput
baseline centering, TextFlow inline-code baseline alignment, glass button
corner_radius uniform. Voice/ggml Metal backends: debug logs behind
GGML_METAL_TRACE. splashgame.md: the runtime-loaded game API contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:20:03 +02:00
Admin
449f51be7a gamemaker prerequisites: makepad-tts library, TTS/whisper model downloader, aigame design docs
libs/tts was never tracked despite being a build dependency of the gamemaker
example. tools/download_tts.sh fetches the public upstream weights (HuggingFace
Kokoro-82M + whisper.cpp) and converts them locally with the in-repo stdlib-only
converter; model artifacts are gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:29:22 +02:00
Admin
2a680887d5 game maker 2026-07-10 12:09:47 +02:00
admin2
176725877d Clean unused workspace patch warnings 2026-07-08 12:48:33 +02:00
admin2
7354855ef2 Fix cargo-makepad install rustflags 2026-07-08 12:28:37 +02:00
Admin
eb0b7de06e box3d: broad-phase hybrid — drop the parallel batch, keep the serial BVTT
The parallel batch machinery from be21d627a only ever existed to chase a
default-on multi-threaded win that never came (washer w8 still +54% with
the hybrid on). At w1 — the only configuration this opt-in flag is for —
parallel_for runs inline, so the serial path gives the identical result
for ~340 fewer lines. Wire up the previously-dead
dynamic_tree_self_pairs/cross_pairs into a serial collect_batch_candidates
(three BVTT self/cross traversals -> canonical (a,b,child) sort -> serial
filter into move_results[0]) and delete BatchWork, BatchCtx, batch_drain_*,
BatchFilterCtx, batch_filter_*, bvtt_step, dynamic_tree_bvtt_drain/expand,
and the batch_frontier/worker_* scratch fields.

Determinism preserved exactly: OFF 0x61E35C31/step314 bit-identical, ON
0xBE99C5F7/step313 identical across workers 1/2/4. The debug SET-equality
oracle and the determinism_broad_phase_hybrid_across_worker_counts test
are unchanged and still pass; zero warnings.

PGO: pgo.sh never trained -bp=1, so an off-path-only profile laid the
hybrid branch out cold and collapsed the win to ~-5%. Add one -b=8 -bp=1
training run (neutral for the default path — counts merge, the OFF branch
stays hot) and retrain.

Corrected README numbers to measured values (hybrid-trained profile,
paired -bp toggle, washer w1): pair-finding stage 7.6k -> 3.6k ms/1000
(-52%); total ~-17% (~19.0k vs ~22.6k), which beats C (20661) and narrows
Rapier's lead from ~23% to ~12% — not the "17.7k / within 5% / -19%"
be21d627a claimed. Still default-off (w8 regresses ~+54%), opt-in
single-threaded accelerator for churn-heavy scenes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:08:45 +02:00
Admin
550d89b446 box3d: README — document opt-in broad-phase hybrid, annotate washer
Broad-phase-hybrid subsection (why washer loses, the batch design, the
determinism proof, the single-thread win / multi-thread floor tradeoff,
and the default-off rationale). Washer row in the single-thread rapier
table annotated with the opt-in number (~17.7s, beats C, ~5% behind
rapier → box3d ahead-or-even on all nine single-threaded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 02:51:21 +02:00
Admin
be21d627a0 box3d: opt-in broad-phase hybrid (parallel BVTT) — single-thread washer -19%, default off
WorldDef.enable_broad_phase_hybrid (default false; -bp=0/1 bench toggle):
an adaptive batch broad phase for high-churn scenes. When
move_count*4 > proxy_count, replaces the per-moved-proxy tree queries
(8k proxies x 3 root-descents on washer) with three BVTT self/cross
traversals (dynamic self + dynamic x static + dynamic x kinematic) that
share the upper-tree descent, plus an O(n) bottom-up refit instead of
the median rebuild. Both traversal and candidate-filter are parallelized
across the task system (per-worker buffers → merge → canonical sort by
(shape_a,shape_b,child) → deterministic contact creation).

Correctness: a #[cfg(debug_assertions)] SET-equality assertion (batch
candidate set == per-mover set) runs in every test and never fires — the
proof the BVTT finds identical contacts (the hash can't prove it since
creation order legitimately re-baselines). New test
determinism_broad_phase_hybrid_across_worker_counts. OFF hash 0x61E35C31
bit-identical; ON hash 0xBE99C5F7 identical across workers 1/2/4 +
external tasks. 180/186/180/180 tests, zero warnings, profile retrained.

Single-threaded washer -18.7% (17715 vs 21780, broad phase -51%) — beats
C (20661), within ~6% of Rapier (16844). DEFAULT OFF because it regresses
multi-threaded (washer w8 +52%): the batch materializes ~40-50k
candidates/step and serially merges+sorts them (a fundamental floor the
inline per-mover path avoids by filtering in the query callback), so at
w8 the parallel per-mover queries win. Cannot be worker-gated (would
break cross-worker determinism). Correct, deterministic, zero-cost when
off — an opt-in single-threaded accelerator for churn-heavy scenes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 02:48:47 +02:00
Admin
c2f54e7095 box3d: README — three-way single-thread matrix (Rust | C | Rapier), same-window run
Adds the C Box3D column to the nine-scene rapier table, all from one
same-window interleaved single-thread run (2026-07-06). Percentages vs
the box3d Rust column. box3d Rust beats Rapier on 8/9 (junkyard flipped
to a +5% win post-tier-2; only washer lost, Rapier's incremental-BVH
broad phase). vs C: within ~7% geomean, ahead on both pyramid scenes,
worst is junkyard +18%. Replaces the derived † junkyard cell with a
direct measurement. Headline updated 7/9→8/9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 01:08:28 +02:00
Admin
bb39a57e37 box3d: washer broad phase -3% — inline pair-query filter matching C
query_tree_for_pairs was materializing every tree-query hit into a Vec
then re-iterating; C runs try_add_pair inline in the query callback
(b3PairQueryCallback). Rewrote to filter inline like C (both world
borrows are shared, so it compiles); only the rare compound inner-query
still uses a child_hits scratch (no compounds in washer/junkyard/
pyramids/trees, so pair-discovery order is unchanged). Removed the
now-dead PairScratch.hits field.

query_tree_for_pairs is washer's single hottest symbol (8k dynamic cubes
churned by a rotating drum re-query the whole tree every step). washer
broad phase -3% (paired plain + retrained-PGO), junkyard -1.4%,
pyramids/trees neutral. Hash bit-identical (0x61E35C31) — pure
structural, same discovery order. 179/185/179/179 tests, zero warnings.
Profile retrained dual-mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:51:16 +02:00
Admin
0bba179884 rapier bench: --stages/--probe diagnostic modes + live stage timers
Instrumentation used to decompose the washer scene (which phase holds
rapier's ~24% advantage over box3d). Timer.rs drops the profiler-feature
gate so PhysicsPipeline's per-stage counters always measure (std Instant
instead of web_time); bench gains --stages (per-phase ms split) and
--probe (per-step contact/pair/sleep counts). Diagnostic tooling only;
no effect on simulation. Findings: washer's rapier advantage is entirely
broad phase (box3d ~8.5s vs rapier 1.7s), box3d's narrow phase is
actually faster than rapier's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:24:56 +02:00
Admin
4e498ca934 box3d: README — junkyard cells updated for tier-2 (derived from paired -8%), geomeans recomputed
Derived cells marked with a dagger and the derivation stated: cold-window
baseline x the same-binary paired improvement (thermal-drift-immune);
direct cold-window rerun will replace them. vs C: junkyard +17%->+7% w1,
+22%->+12% w8, geomeans +6%/+8%. vs rapier: junkyard -8% -> ~parity,
geomean +34%, washer now rapier's only win.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:49:14 +02:00
Admin
95eb8086fc box3d: tier-2 feature recycling (port extension) — default on, junkyard -8%
New middle tier between full manifold recycling and the full SAT, behind
WorldDef.enable_feature_recycling (default true; OFF path bit-identical,
hash 0x61E35C31 verified):

- Case A, separated-witness early-out: a previously-non-touching contact
  revalidates only the cached winning axis; still separating beyond the
  speculative distance means done in one test. Sound structurally: any
  cached axis is a valid separation witness (understates only, which
  falls through to the full SAT). Carried junkyard: 24k skips/step,
  full SATs 25.4k -> 4.5k per step.
- Case B, touching feature rebuild: re-clips the cached winning feature
  under explicit staleness bounds (SATCache::sat_pose at last full SAT,
  translation < 4x recycle distance, rotation < ~4.6 deg, forced refresh
  every 8 steps); degenerate rebuilds and touching<->separated
  transitions fall through same-step.

Probe-driven (junkyard: 89k full SATs/step on 105k pairs, 13k touching;
rapier maintains 3.4x fewer pairs): paired same-binary -fr=0/1 A/Bs show
junkyard -8% in every pairing (collide phase -15%), washer neutral to
-5%, pyramid/rain guards neutral, OFF costs nothing. Same-session
cross-engine junkyard: rapier's -8% lead closes to ~-3%.

pgo.sh now trains BOTH modes (single-mode training starved the remaining
full-SAT path); checked-in profile retrained dual-mode. SATCache pose
serialized in snapshots. feature_recycled_contact_count in Counters.
README: port-extension subsection, soundness argument, updated notes.
Also: rapier bench --probe mode from the workload-probe session.

179/185/179/179 tests green with the tier ON, zero warnings,
determinism suite passes across runs/workers/task systems.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:40:08 +02:00
Admin
901eafb07b rapier bench: extend to all nine box3d scenes, single-threaded matrix
- libs/rapier/crates/bench now mirrors every box3d benchmark scene
  (trees100/50/25, junkyard, rain, washer added to large_pyramid/
  many_pyramids/joint_grid), same -b indices as the box3d benchmark.
  Geometry, densities, filters, spawn cadence and joint counts match;
  body/collider/joint counts verified equal on all nine scenes.
- Fix an index-out-of-bounds panic in the vendored rapier simd-stable
  constraint grouping (interaction_groups.rs): bodies in a different
  island than the interaction (kinematic drivers, dynamics mid
  island-merge) indexed the wrong island's conflict masks. Out-of-island
  bodies are now exempt from conflict tracking, matching the solver's
  existing boundary treatment (bounds-checked gathers, dropped scatters).
  Original three scenes reproduce their previous timings after the fix.
- README: full nine-scene single-threaded matrix vs rapier (box3d wins
  7/9, +33% geomean; rapier wins the hull-churn scenes junkyard/washer)
  with comparability caveats for the extended scenes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:35:55 +02:00
Admin
d95c096bcd box3d: README — drop large_world from the matrix (measures scheduler overhead, not physics)
Geomeans over the nine real scenes: +7% w=1 / +9% w=8. The scene stays
in the benchmark binaries (upstream suite parity); its fixed-overhead
story is kept in the known-remainder notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:53:10 +02:00
Admin
e990959b55 box3d: README — trees rows re-paired (trees50 +22% was a noise cell), drop the w8-vs-serial-C vanity stat
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:49:04 +02:00
Admin
b976c2e40c box3d: README — matrix refreshed to current tree (same-session run, all rounds applied)
junkyard now shows its real post-fix +17%; footnote lattice replaced by
one measurement-conditions note. Geomeans +7% w=1 / +13% w=8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:29:19 +02:00
Admin
49e3bfc5ad box3d: README — junkyard floor mapped by recycle-disabled isolation
Pure full-update pipeline is +38% vs C (both engines, recycling forced
off), diluted to +17% by the at-parity recycle path. Gap is diffuse
(1.3-1.5x per pipeline function); all concentrated hypotheses measured
~zero, including a staging rewrite that halved build_face_a_contact's
instruction count with zero wall-clock effect (the bloat was cold code).
Note: sample attribution unreliable on PGO binaries (hot/cold splits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:26:04 +02:00
Admin
c7daefe6a3 box3d: README — junkyard footnote updated with post-fix verification (+17%)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:02:46 +02:00
Admin
d8e1bade98 box3d: junkyard pass — restore manifold-pipeline function boundaries (-3.6%), unchecked-hulls demoted to neutral
Disassembly attribution on current binaries: junkyard's +23% sits
entirely in the NON-SAT narrow phase (2.06x C) — the edge SAT is now
FASTER than C. Cause: LLVM+PGO mega-inlining (third occurrence) —
update_contact compiled to 5.5x C's instruction count, collide_hulls had
no symbol at all. inline(never) on collide_hulls /
compute_convex_manifold / query_face_directions restores C's layout:
junkyard -3.6% paired (retrained profile), washer neutral, others
untouched.

hull_at coverage extended to build_face_a_contact / build_polygon /
clip_segment_to_hull_face / find_incident_face for contract completeness
— measured NEUTRAL beyond the boundary fix, and the feature's earlier
-3.6% is now captured by the safe attribute instead. README documents
the demotion honestly (the safe fix superseded the unsafe one).

Gates: 179/179/185/179 tests (default/unchecked-hulls/dp/nosimd), hash
0x61E35C31 everywhere, zero warnings. PGO profile retrained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:40:33 +02:00
Admin
12cd7865e2 box3d: README — unchecked-hulls deltas noted in the matrix, idea review moved to bottom section
Matrix cells stay default-build (cold-window run); the hull scenes'
opt-in feature gains are annotated as paired deltas rather than absolute
cross-session numbers (thermal windows differ ~10%, mixing them would
misstate both). Algebraic-float-ops evaluation moved to a dedicated
'Evaluated ideas' section at the bottom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:06:00 +02:00
Admin
f75dd2bba6 box3d: unchecked-hulls opt-in feature + algebraic-float-ops evaluation
unchecked-hulls (off by default): elides bounds checks on hull-topology
indexing in the three SAT hot loops via a cfg'd accessor. Safety contract
= hull connectivity invariants validated at construction (hull.rs
is_valid_hull_impl + create_hull asserts), immutable behind Arc; debug
builds always assert, so every test run exercises the contract. Measured
(paired, retrained PGO): junkyard -3.6%, washer -2%, nothing elsewhere —
documented honestly that the checks were NOT most of the hull residue.
Tests 179 green with and without the feature; hash 0x61E35C31 both.

README: evaluation of the newly-stabilized algebraic float ops idea —
incompatible with the determinism contract as a default (compiler-
version/ISA-dependent results break cross-arch equality and cross-build
replay), modest expected upside since hot paths are already hand-
contracted; possible future opt-in, not planned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:55:41 +02:00
Admin
11b955694d box3d: parallelize finalize-bodies + bullet passes — 8-worker geomean +28% -> +11% vs C
A sweep of every C b3ParallelFor/enqueue dispatch against the port found
the finalize-bodies pass (per-body transforms, AABB updates, sleep
accounting, continuous/TOI) and the bullet pass were left serial when
threading was ported — C runs both under b3ParallelFor. rain's w=8 gap
was almost entirely this serial fraction (Amdahl decomposition showed
its parallel portion already at C parity).

FinalizeCtx mirrors the collide pass's pattern: taken arrays + SyncSlice
disjoint per-body access, per-worker task contexts, deterministic merges
(bitset OR, split-candidate max like C), bullet list via atomic cursor
(C's b3AtomicFetchAddInt mirror). No new unsafe primitives. Pre-solve/
custom-filter callbacks force single-worker like collide.

w=8: rain +42% -> +8%, joint_grid +36% -> +11%, large_pyramid and
many_pyramids and trees25 at parity; geomean +11%. Serial geomean +5%
(Rust wins joint_grid/large_pyramid/many_pyramids outright). PGO profile
retrained; README tables + narrative updated. Remaining known
serial-vs-C difference: the split-island enqueue overlap (documented).

Hash 0x61E35C31 bit-identical (runs, workers 1/2/4, external tasks);
179/185/179 tests, zero warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:24:46 +02:00
Admin
fed43d7ec3 box3d: PGO trains on all 10 scenes; small-stage fast path tested and dropped
Training set was missing washer and trees50/25 (washer -1.5% with
coverage, large_pyramid unchanged — no dilution). Profile refreshed.

Small-stage main-only fast path (generalizing C's single-block
shortcut): swept cutoffs 32/64/256 at w=8 — only large_world benefited
(-8% of ~11ms); rain regressed at every cutoff (its small-count stages
are mesh-contact stages with heavy per-item cost — serializing them
starves real parallelism) and joint_grid has few fat stages (grid
coloring = 2-4 colors), so its w=8 gap is NOT thin-stage sync. Reverted
per the measurable-win rule; negative result documented in the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:39:18 +02:00
Admin
02d72d4e10 box3d: narrow joint write-backs — joint_grid at C parity, serial geomean +7%
Disassembly census showed the joint solvers at exact FMA parity with C
but +110 loads/+54 stores per joint: the full 56-byte BodyState get/set
round trip keeps untouched fields live across the ~1000-instruction
solve bodies. StateAccess::set_velocities (same unsafe contract as set,
velocities only, like C's in-place stores) + get_ref field extraction
across all 16 warm-start/solve functions in the 8 joint types.

joint_grid: 817 vs C 801 ms (was -11%). Full fresh matrix in README:
serial geomean +7% vs C with Rust WINNING large_pyramid (-6%) and
many_pyramids (-3%); w=8 geomean +28%. Checked-in PGO profile retrained
for the new code (stale profile cost ~13% on joint scenes).

Same change was measured neutral for contact scatter and correctly
dropped there (state live ~40 instrs vs ~1000) — both verdicts in the
README as a paired case study.

Hash 0x61E35C31 bit-identical everywhere; 179/185/179 tests, zero
warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:29:55 +02:00
Admin
c5b03d3f10 box3d: README — percentage deltas in the comparison table
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:03:35 +02:00
Admin
c9cbb4334b box3d: check in PGO profile, apply by default via workspace .cargo/config.toml
cargo build --release on anything in the workspace now gets the PGO'd
box3d automatically (-Cprofile-use=libs/box3d/box3d.profdata; verified:
default build runs at the explicit-PGO binary's speed). The profile is
target-independent — x86_64 cross-build with the ARM-trained profile
compiles clean — and degrades gracefully when stale (unmatched functions
keep normal heuristics). Retrain with libs/box3d/pgo.sh.

Fresh four-way interleaved matrix in the README: default box3d is now
faster than or equal to rapier-simd on all three scenes (1118 vs 1451,
1510 vs 1690, 912 vs 914 ms) and faster than non-PGO C on two of three
(C keeps joint_grid 816 vs 912). 179/185/179 tests green with the
config active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:59:17 +02:00
Admin
604a0b60d7 box3d: PGO build recipe — 11-19% over plain fat-LTO, hash bit-identical
pgo.sh: instrument -> train on the benchmark scenes -> merge -> rebuild.
Paired same-machine runs: large_pyramid 1177 vs 1457 ms (-19%, now 15%
faster than the non-PGO C build), junkyard -14%, many_pyramids -11%.
Determinism hash unchanged under the PGO binary (0x61E35C31 across
runs/workers/task systems) — PGO changes layout/inlining, never
arithmetic. README notes the C-reference fairness caveat.

Also documented as tried-and-dropped (noise-floor in paired A/B, per
the keep-only-measurable-wins rule): cache-line padding of stage-sync
atomics, narrow velocity-only scatter writes, compound child Arc clone
(already eliminated by the earlier scratch fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:37:51 +02:00
Admin
5b88fcf311 box3d: README — add C box3d column to the rapier comparison table
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:17:23 +02:00
Admin
6187f463b2 box3d: README — drop rapier-without-simd references (not a realistic comparison)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:16:48 +02:00
Admin
3370d15264 box3d: perf round 3 — many_pyramids 1.28x -> 1.06x vs C, now within 5% of rapier-simd
Disassembly-driven (fork agents confirmed the stalls, killed the
bounds-check and recycle-rate hypotheses with instruction-level and
runtime-counter evidence — recycle counts are bit-identical to C):

- Manifolds inline-when-single store: Contact.manifolds Vec<Manifold> ->
  enum { None, One(Manifold), Many(Vec) } with deref-as-slice. Convex
  contacts keep their manifold inline (the Rust equivalent of C's block-
  allocator arena locality — the per-contact heap chase was the main
  stall in collide/prepare/store). Contact is #[repr(C)] with manifolds
  last so hot header fields stay on the leading cache lines. Public
  ContactData API unchanged via Deref; contact_solver.rs needed zero
  changes. Pure storage change: determinism hash identical (0x61E35C31).
- #[inline(never)] on update_contact + the four convex stage functions:
  C compiles these standalone; LLVM had inlined all of them into one
  13.6 KB execute_block paying constant register-spill traffic.

Definitive cold-machine matrix: serial geomean 1.15x -> 1.12x vs C
(many_pyramids 2071 vs 1949 ms, large_pyramid 1501 vs 1392); 8-worker
geomean 1.35x -> 1.30x. vs rapier-simd (adjacent runs): box3d ahead 16%
on large_pyramid and 3% on joint_grid, behind 5% on many_pyramids (was
21%). README grids updated.

179/185/179 tests green, zero warnings, hash unchanged across workers/
arch/task systems.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:14:52 +02:00
Admin
630577cb98 rapier: restore simd-stable (vendor wide + safe_arch), retest vs box3d
Re-vendors wide 0.7 + safe_arch, restores the upstream simd-stable
wiring in rapier3d/parry3d manifests and the cfg-simd source, and drops
the added 'stripped build does not support SIMD' guards (upstream's
simd-vs-enhanced-determinism exclusivity guard kept).

Interleaved single-thread retest (min of 4): SIMD buys rapier 1.8-2.2x;
box3d vs rapier-simd is now near parity — large_pyramid 1579 vs 1638 ms,
joint_grid 957 vs 1008 ms (box3d ahead), many_pyramids 2389 vs 1970 ms
(rapier ahead). box3d README grid updated with the honest three-column
table; box3d keeps cross-arch determinism + zero deps at that speed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:17:26 +02:00
Admin
470cffcdac box3d: rapier comparison — ~2x faster single-threaded, bench in libs/rapier/crates/bench
Same scenes/geometry/materials/dt, matched solver budget (4 substeps vs
4 solver iterations), interleaved min-of-4 runs: large_pyramid 2.23x,
many_pyramids 1.82x, joint_grid 1.88x (geomean ~1.97x). Table + fairness
notes at the top of the box3d README (vendored rapier has no SIMD;
enhanced-determinism measured free on these scenes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:54:05 +02:00
Admin
35c256a76d box3d: perf round 2 — 8-worker geomean 1.47x -> 1.35x vs C, serial 1.15x
- joint prepare: read BodySim through references (was deref-copying
  220 bytes twice per joint per step; prepare_joint now at C parity)
- FMA contraction extended to joint solvers (32 sites; hash re-baselined
  to 0x61E35C31, still bit-identical across workers/arch/task systems)
- scheduler: workers spin ~tens of us before committing to a kernel
  sleep (semaphore try_acquire spin phase; A/B: large_world w=8
  24 -> 11.5 ms, joint_grid w=8 1.58x -> 1.49x, other scenes neutral;
  intentional deviation from C documented in README)
- tried and reverted: chunks_exact twin-pair edge SAT (won 5% on
  junkyard compounds, cost box-box scenes 4-8%; keeps C 1:1 loop shape)
- README: fresh benchmark matrix, second-round notes, stale external
  task-hook claim fixed

179/185/179 tests green, zero warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:09:25 +02:00
Admin
364d65680a box3d: performance pass — serial geomean 1.30x -> 1.13x vs C, 8-worker 1.6x -> 1.47x
Profiling-driven (sample + disasm comparison vs clang -O3). All changes
safe Rust except one debug_assert-guarded extension of the existing
SyncSlice unsafe contract. Determinism preserved: hash bit-identical
across workers 1/2/4, NEON/SSE2/scalar, internal/external task systems
(new baseline 0x9018E2D8 after approved FMA contraction).

- f32/f64::mul_add contraction in hot scalar math (= C's -ffp-contract=on;
  89 sites; wide SIMD ops untouched like C intrinsics). large_pyramid
  now at parity with C (1387 vs 1373 ms serial)
- FloatW::get/set: direct lane load/store instead of vector-through-stack
  round trip; layout asserted at compile time
- gather_bodies by reference (removes 20-register spill storm)
- per-worker capacity-preserving scratch for convex + mesh collide paths
  (C-arena equivalent; mesh path allocated per triangle and serialized
  the parallel collide pass on allocator locks)
- update_contact: borrow shapes instead of cloning (deep compound
  geometry clones + cross-worker Arc traffic; junkyard w=8 -39%)
- scheduler semaphore: two-level atomic fast path (C uses
  dispatch_semaphore_t; old Mutex+Condvar locked every enqueue)
- SyncSlice::get_ref/get_mut unchecked indexing under the existing
  unsafe contract, debug_assert-guarded (-6% serial)
- README/PORTING: new numbers, FMA sync conventions, known remainders

179/185/179 tests green (default/double-precision/disable-simd), zero
warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:10:44 +02:00
Admin
995aa23bc1 box3d: recording replay player + test_recording port
Full op-stream player in recording_replay.rs (b3RecPlayer port):
opcode dispatch for ~150 ops, StateHash verification at every step
marker, query replay with bitwise comparison, keyframe ring with
budget-driven interval doubling, seek/restart/scrub, validate_replay.

tests/test_recording.rs ports test_recording.c (17 tests incl.
record-at-4-workers/replay-at-1-and-4 hash equality). 179/185/179
tests green across default/double-precision/disable-simd, zero
warnings, determinism hash unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:18 +02:00
Admin
dfa0b07753 box3d: recording op stream (capture side)
All ~140 opcodes from recording_ops.inl with exact C values, capture hooks
in every mutator and query (~137 sites across body/shape/joint/world),
48-byte header with registry locator backpatch, snapshot seed, query tag
interning, state-hash anchors per step. Recording is observer-only
(bit-identical world state with and without a recording attached).
Replay/player side lands separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:18 +02:00
Admin
ec3378b060 box3d: external task-system hooks + makepad 3D example
- WorldDef enqueue_task/finish_task/user_task_context (C contract incl.
  null-return-means-inline); TaskSystem dispatch (Serial/Internal/External)
  replaces the bare scheduler; determinism hash bit-identical through an
  external thread-per-task system.
- examples/box3d: makepad app rendering the live simulation (offscreen 3D
  pass with depth, orbit/zoom camera, instanced lit boxes/spheres, 204-box
  pyramid + spheres, 4-worker solver, Space to reset).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:18 +02:00
Admin
66b42fc9dd box3d: multithreading — scheduler, parallel_for, atomic solver stages
Port of the C threading design: worker threads with a fixed task ring and
help-while-waiting finish (scheduler.rs), atomic block-claiming parallel_for,
and the solver's stage machinery (per-block syncIndex CAS, sync-bits stage
advancement, mainClaimed race). Parallel narrow phase, broad-phase pairs,
sensors, finalize. Shared access goes through documented disjointness
primitives (sync.rs: SyncPtr/SyncSlice/AtomicIndex); worker_count 1 keeps the
serial path bit-identically. Results are bit-identical at any worker count
(determinism hash 0x7A796F4F asserted at 1/2/4 workers). 8 workers: 3.4-5.7x
over serial on heavy scenes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:18 +02:00
Admin
88f7d7a2c6 box3d: reuse per-step solver and broad-phase scratch allocations
Persist solver constraint arrays/spans/stage blocks and broad-phase pair
query buffers across steps instead of reallocating each world_step.
Bit-identical results (determinism hash unchanged); washer -7.6%, small
wins on trees/rain, pairs stage -6% on junkyard. A contact-manifold
reuse attempt regressed pyramid scenes and was dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:18 +02:00
Admin
60ec705ab2 box3d: SIMD (SSE2/NEON), double-precision large world, snapshots, benchmarks
- contact solver wide ops + V32 now have real SSE2 and NEON paths selected
  by target arch; scalar fallback behind the disable-simd feature. All three
  paths are bit-identical (cross-arch determinism verified: same ragdoll
  hash on NEON, SSE2 under Rosetta, and scalar).
- double-precision feature (C BOX3D_DOUBLE_PRECISION): f64 world positions
  with the exact C boundary-function semantics; enables the far-from-origin
  test halves (157 tests in DP mode, 151 default).
- world snapshots: recording substrate subset (buffer/writers/geometry
  registry/readers) + world_snapshot.c port; bit-identical continuation
  after restore, corrupt-image rejection.
- examples/benchmark.rs: all 10 C benchmark scenarios; serial Rust runs
  1.05-1.55x slower than C -O2 at one worker (geomean ~1.3x with fat LTO).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:18 +02:00
Admin
848e715c6c box3d: pure Rust port of Box3D (erincatto/box3d @ 29bf523)
Full engine port in libs/box3d: math, geometry, GJK/TOI, hull builder,
dynamic tree, manifolds, constraint graph, solver (serial, scalar SIMD
path), all 8 joint types, sensors, mover, world API. 147 ported C unit
tests green in debug and release. See libs/box3d/README.md for the
upstream revision and sync notes, PORTING.md for conventions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:18 +02:00
Kevin Boos
30eeae75ca
View: add fn for toggling optimize and force texture caching (#1136)
* TextInput: expose the location of the caret/cursor

in absolute window-relative coordinates.

THis allows, for ex, a widget to be placed relative to the
current location of the text being inputted by the user.

* View: add fn for toggling `optimize` and force texture caching

Add CachedView fns that make it easier to control its parameters:
* set_optimize(cx, ViewOptimize): switch optimize mode at runtime and
  allocate the draw_list on demand. Previously `optimize` was set once from
  `texture_caching` and never reset, so a view couldn't toggle between direct
  and texture-cached rendering per frame.
* set_texture_max_height(Option<f64>): cap the Texture-mode render turtle's
  height so a tall Fit-height cached view can't allocate a render target past
  the GPU's max texture size (was a hard MTLTextureDescriptor abort once
  content exceeded 16384px). None (default) leaves it uncapped; content past
  the cap is clipped. Only affects Texture mode.
* redraw_texture_cache() / force_texture_redraw: force one offscreen
  re-render after a content repopulate or an optimize-mode flip. The
  rect-based will_redraw check can't see a content change on a recycled or
  toggled view, so without this it would composite a stale texture.
* view_size is now updated in every optimize mode, not just draw-list modes.
  The None (direct-render) path previously left it stale, so a view toggled
  None<->Texture sized its next offscreen turtle from an old height and
  clipped/mis-positioned its content.
2026-07-02 08:34:19 +02:00
Kevin Boos
c65b72efad
TextInput: expose the location of the caret/cursor (#1132)
in absolute window-relative coordinates.

THis allows, for ex, a widget to be placed relative to the
current location of the text being inputted by the user.
2026-07-02 08:34:04 +02:00
Admin
3a82d26045 hypothetical heap access fix 2026-07-01 14:04:23 +02:00
Kevin Boos
cb93fa9822
android: don't make the surface invisible, that destroys it (#1134)
Setting MakepadSurface to be `INVISIBLE` in the pause path will
destroy that surface and cause system overlays to flash/flicker for a moment.
2026-06-30 09:42:03 +02:00
Admin
82abd655b9 history 2026-06-26 16:47:13 +02:00
Admin
a4c87a64dd animating buttons 2026-06-26 16:46:24 +02:00
Admin
e7939c8f14 animating bg 2026-06-26 15:49:25 +02:00
Admin
6c3c3252bf animating bg 2026-06-26 15:34:54 +02:00
Admin
c50a74b3b7 glass style 2026-06-26 14:06:57 +02:00
Admin
5cb5219d23 parse {}{} as {},{} 2026-06-26 13:37:17 +02:00
Admin
a6c7a22ba8 glass centering 2026-06-26 13:07:21 +02:00
Admin
283ef5553a cargo 2026-06-26 10:29:06 +02:00
Admin
02592599ec aichat 2026-06-25 18:52:10 +02:00
Admin
837da0d7bd aichat: track glass splash.md doc & fix runtime path
- Rename splash2.md -> splash.md so the tracked doc matches what aichat
  references via include_str!/read_to_string (a clean clone now builds).
- Fix the live-read path (CARGO_MANIFEST_DIR was ../../../ = one dir above
  the repo, loading a stale doc); now ../../splash.md -> repo-root glass doc.
- Full repaint (cx.redraw_all) on Clear/remove so self-managed glass overlay
  draw lists aren't left composited stale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:41:49 +02:00
Admin
7861ecb10e splash: replace splash2.md with updated Splash DSL guide
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:13:11 +02:00
Admin
cba6d6ff7e aichat 2026-06-25 16:11:42 +02:00
Admin
ac10a82c34 aichat otw 2026-06-25 16:11:42 +02:00
Admin
3ad65e8bd3 glass kit more 2026-06-25 16:11:42 +02:00
Admin
725a726e3d glass kit more 2026-06-25 16:11:42 +02:00
Admin
374fa0c285 gloop glass radio 2026-06-25 16:11:42 +02:00
Admin
f91b0fa0bd gloop glass radio 2026-06-25 16:11:42 +02:00
Admin
70062ed86b gloop glass radio 2026-06-25 16:11:42 +02:00
Admin
ba0306d4ee Add glass_panel widget example; restore rustfmt guard
- New GlassPanel widget (widgets/src/glass_panel.rs) + lib export
- gauss_view: honor surface_alpha for translucent glass surfaces
- examples/glass: standalone demo (wired into Cargo workspace + makepad.splash)
- rustfmt.toml: re-enable disable_all_formatting to stop rustfmt from
  reformatting the whole tree on save

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:11:42 +02:00
Kevin Boos
82db45a00c
parse and use orientation info for all supported image formats (#1130) 2026-06-23 09:08:45 +02:00
Kevin Boos
a13b8876a5
code editor: support proper Fit size bound with max height value (#1128) 2026-06-22 19:54:30 +02:00
Admin
d37a34f24e move after draw demo 2026-06-16 17:45:59 +02:00
damien
7aae8d6ffe
fix: hot reload broken when file contains script_apply_eval! calls (#1124)
script_apply_eval! expands to a ScriptMod{file, line, column, ...} with
its code field prefixed by __script_source__. This adds a runtime body
to the VM just like script_mod! does, so collect_compiled_sites_for_file
counts it toward the compiled-site total for that file.

The file extractor (extract_script_mods_from_rust_file) only scans for
the script_mod! macro pattern, so any file with N script_apply_eval!
calls and M script_mod! calls causes:

  hot reload could not match script_mod! blocks for <file>:
  runtime has N+M, file has M

Skip __script_source__ bodies in collect_compiled_sites_for_file. These
are Rust-driven runtime evals, not static DSL blocks, so the extractor
cannot match them and hot reload cannot update them anyway.

Fixes hot reload for files that mix script_mod! with script_apply_eval!.
2026-06-16 09:09:08 +02:00
Kevin Boos
e4e1585b05
TextInput: infer soft-keyboard input_mode from content_type if its unset (#1125)
* Support standard keyboard navg shortcuts/keys in TextInput

Implement platform-standard TextInput navigation and deletion behavior,
including Home, End, PageUp, PageDown, word movement, line/document
boundaries, and Shift-based selection.

* Use Apple Option/Cmd conventions on Apple targets
* Use Ctrl conventions on non-Apple targets
* Web accepts both shortcut styles for now, since we don't have a way
  to query the host OS from within a makepad web env.

Also, be extremely careful to ensure that we respect Unicode grapheme boundaries
when doing all the selection/navigation logic.

Fix `Delete`, which was erroneously handled before.

Add lots of missing keys in Linux X11 & Wayland backends, e.g.,
Home, End, Delete, Insert, PageUp/PageDown, and arrow keys

* Add `CropToFill` image fit variant, improve ImageFit docs

This allows you to easily achieve the "centered cropped fit" that most apps
want for things like avatars or small thubmnails that get masked.

* Detect and support hardware keyboards, distinguish from soft/virtual kbd

Mimic desktop behavior on mobile systems as much as possible.
This is esp important for tablets like iPad OS where you're more likely
to have a real physical keyboard attached.

For iOS:
* Arrow keys and Home/End/PageUp/PageDown navigate and auto-repeat
  at the system-defined rate (connected via `UIKeyCommand`)
* Cmd+Enter to submit a `TextInput` and Cmd+C/X/V clipboard shortcuts now work.
* Ensure the pop-up diacritic/accent menu is properly placed using a hidden
  `UITextInput` native widget, which acts as a sort of "proxy"
* Proactively drain `ShowTextIME` after each draw so the IME position will be
  properly updated after each keystroke.
* Importnatly, don't mark the IME dismissed when a hardware keyboard is attached.

For both iOS & Android:
* Add a `has_physical_keyboard()` detection mechanism across both backends,
  and fix platform-specific key repeat behavior

For Android:
* Ensure clipboard cut/copy works using the same Ctrl shortcuts (API 26+)

Soft/virtual keyboard/IME changes:
* For multiline TExtInputs, a soft keyboard Enter/Return key will always just
  insert a new line, to avoid complexity with keyboard shortcut cfgs.
* CJK keyboard character selection should also be properly positioned now

* minor optimization to avoid re-setting IME pos if it didn't change

* Ensure inline composition is still shown in TextInput on all platforms

This is mostly relevant when using CJK and other similar IMEs.
Previously Makepad didn't shown any "echoes" of the single latin chars
that the user would type, but it would correctly input the selected CJK
glyph. So if you typed `nihao` and then selected `你哈`, then you would
see the proper chinese characters but not the latin "nihao".
Tha't s a bit confusing while typing.

Summary of the fixes per platform:

macOS:
- `set_marked_text` now forwards the marked (composition) text to the
  focused TextInput with `replace_last = true`; previously it only stored
  it in an ivar. `unmark_text` clears the preview, and both it and
  `insert_text` share a new `clear_marked_text_ivar` helper so a commit
  doesn't emit a second, destructive text-input event.

Windows:
- Add `WM_IME_COMPOSITION` handling: commit `GCS_RESULTSTR`
  (`replace_last = false`) and show `GCS_COMPSTR` inline
  (`replace_last = true`), and clear on `WM_IME_ENDCOMPOSITION`. The
  message is consumed so DefWindowProc neither draws its own composition
  window nor synthesizes a duplicate WM_CHAR for the result.
- Extend the vendored `windows` binding with `ImmGetCompositionStringW`,
  `GCS_COMPSTR`/`GCS_RESULTSTR`, and `WM_IME_COMPOSITION`/
  `WM_IME_ENDCOMPOSITION`, which it didn't previously generate.

Linux (Wayland):
- Handle the `zwp_text_input_v3` `PreeditString` event (previously empty)
  and apply the double-buffered preedit/commit state on `Done`, in the
  protocol-mandated order (commit, then preedit), clearing the preview
  when a cycle carries no preedit.

Linux (X11):
- Create the input context with XIM on-the-spot (`XIMPreeditCallbacks`)
  and forward the preedit string from the draw/start/done callbacks,
  falling back to `XIMPreeditNothing` if the IM server doesn't support
  callbacks. Callbacks run inside `XFilterEvent`, so they only mutate a
  thread-local that the event loop drains into the widget afterward,
  avoiding re-entrant access to the app.

Android:
- Don't mark the IME dismissed when a physical keyboard is attached
  (mirrors the iOS guard). Android was unconditionally calling
  `text_ime_was_dismissed()` on soft-keyboard hide, which tore down the
  IME connection that hardware-key composition relies on.

* iOS: replace custom `UITextInput` with a native `UITextView`

`UITextView` is a full system-native keyboard client, so we get all the
major features for free: language HUD pill and the complete globe/Ctrl+Space
shortcut to cycle between IMEs/languages.

Makepad basically just mirrors the state of the system native text view,
via the `full_state_sync`, but the actual native text view is kept invisible
so it doesn't interfere with what we render in Makepad's TextInput.
Notably, the Full Keyboard Accessibility setting now does work properly,
whereas it did not before with our UITextInput-based approach.

We also make sure that arrow keys, nav keys, auto-repeat, and modifiers
are properly hanlded so we can retain the expected kbd shortcuts,
like other desktop platforms.

* iOS: remove the old `UITextInput` connection with the Makepad TextInput

We've now switched to the native UITextview, so we don't need this any more.

* iOS: fix desync during fast typing

Ensure there's no race between the native UITextView and
Makepad's TextInput, as the Enter/REturn key needs special handling
w.r.t. how `pressesBegan` gets it (From a real hardware kbd).

* TextInput: more iOS integration, and text input types

more native integration for things like username/password,
new password fields, email, address, URLs, etc.
These tell iOS to change the keyboard layout/type for the text input.

* iOS: don't let Full Keyboard Access focus on our hidden native cursor

* cleanup

* iOS/TextInput: fix perf issues

* iOS TextInput: more fixes for read-only efficiency, and filtered input

Also port some of these fixes to Android's IME integration layer

* iOS/TextInput: hide the native caret iOS draws during autocorrect

but still allow the "decline autocorrect" bubble to popup where that
hidden caret is located (and the CJK candidate window in the same spot)

* Avoid script VM re-entrant panic: defer animator_cut/play if script VM is held

`animator_cut` / `animator_play` call `cx.with_vm`, which panics
(*"Script VM swapped off"*) when invoked during an apply walk — e.g. a
widget's `on_after_apply` on `ScriptReapply` / `Reload` — because the VM
is already taken for the duration of that walk's enclosing `cx.with_vm`.

- The derive macro's `animator_cut_scoped` / `animator_play_scoped` now
  check `cx.is_script_vm_held()`; when held, they queue the op
  (`defer_cut` / `defer_play`) and return instead of re-entering the VM.
- `animator_handle_event_scoped` replays the queue via `flush_deferred`
  on the next frame, once the VM is free.

The defer path runs **only** in the formerly-panicking case, so VM-free
animations are byte-for-byte unchanged.

Also adds a re-entrancy-naming panic (`VmHolderGuard`) plus
`Cx::try_with_vm` / `Cx::is_script_vm_held` for diagnosing and handling
this class of bug.

* Better spacing/positioning for IME popups like the CJK candidate menu

applied to all platforms, but primarily an issue on macOS/iOS.

The candidate/conversion window (e.g. CJK pinyin) was covering the line of
text being composed. Carry the caret-line rect (not just a point) through
ShowTextIME and feed each backend its native "keep clear of this line" API,
so the OS places the candidate directly above/below the line with a small gap:

- macOS: firstRectForCharacterRange returns the line rect via AppKit
  convertRect:toView:nil + convertRectToScreen (drops the hand-rolled
  screen-coord math + fudge offsets); invalidate on caret move.
- Windows: ImmSetCandidateWindow with a CFS_EXCLUDE line rect.
- Wayland: set_cursor_rectangle with the real line rect.
- X11: XNSpotLocation/XNArea at the line.
- iOS: return the true composing-line box from firstRectForRange so iOS flips
  around the real edges (consistent at any screen position) instead of a
  degenerate point; only while marked text is active, to avoid an oversized
  autocorrect highlight when typing normally.

* Fix Linux X11 behavior: Ctrl-based kbd shortcuts didn't work in TextInput

also trying to fix X11 behavior for positioning the CJK candidate window,
turns out there was an X11 bug for Ubuntu 22 and older so it's not always
possible, but we can attempt a workaround if errors occur (based on that,
we try to auto-detect the version of X11)

* fix X11 event loop latency by draining only a max of 64 events before redrawing

still working on X11 CJK candidate window positioning...

* add logs to X11 ime to figure out wtf is going on

* more robust fallbacks for X11 CJK candidate window positioning... grr

* maybe try to set the XFontSet attribute? for CJK candidate positioning

* positioning works now but there is a bit of overlap still

* now that X11 CJK candidate positioning works in some cases,
we need to pass the full rectangle containing the current line of text
to the X11 library so that it can position the window both on top
and beneath the current line of text, if needed.

* tweaking X11 CJK candidate positioning

* abandon the screen-positioning heuristic

Instead, we just send the bounding rect of the current text character
and hopefully let the X11 platform libs decide where to put the
CJK candidate popup

* add more spacing to the bounding rect on X11

* tweak for a bit more space between CJK candidate window

* more tweaks, rect height isn't being respected for some reason...

* attempting to add more instrumentation to figure out wtf is going on with X11 CJK positioning

* remove bad instrumentation that was causing freezes. ugh

* different approach for IME placement on X11

* previous positioning attempts for X11 didn't work.

New strategy: let it be positioned, and then try to move it

* still trying to fix X11 CJK candidate window positoining...

* trying to find CJK candidate window with X11 queries (To move it)

* abandon window scanning approach

* better approach, now just tweaking it

* fix one case where the candidate window was flipped but it pointing too low

* tweaking more

* trying to fix above-text line positioning

* still trying to tweak CJK candidates ABOVE the text line

* be more conservative when guessing whether X11 will show the CJK candidate above or below

* improve size heuristic for CJK candidate height

* calling X11 as complete now. jfc. Cleanup, remove debug logs, etc

* TextInput: infer soft-keyboard `input_mode` from `content_type` if its unset

New functon: `effective_input_mode()` will now derive a keyboard layout
from the `content_type`, if one was provided and if it makes sense to infer.

Explicitly setting the `input_mode` will always take precendence.
2026-06-16 09:08:10 +02:00
Kevin Boos
af130e98fd
Limit cache growth for text/script, reclaim memory after gc (#1123)
* Run script-VM gc in the desktop and mobile event loops, not just macOS

Only macOS was calling the script VM's garbage collector.
Now we call it everywhere, based on the original implementation in macOS.

* Limit cache growth for text/script, reclaim memory after gc

This PR includes several misc improvements to reduce memory usage and/or
return unused memory to the OS properly.

- slug atlas: reset the append-only curve buffer past a cap (mirrors the raster
  atlas reset: cleared at the prepare_textures boundary, forcing a rebuild), so
  it no longer accumulates every distinct large glyph ever rendered.
- script heap: in gc(), truncate the String reuse pool and shrink over-allocated
  free-list/slot capacity (never moves a live slot, so all refs stay valid).
- font outline cache: cap per-font distinct-glyph entries (clear-on-exceed).
- image cache: evict Loaded entries past a cap; widgets keep their own texture
  clones so displayed images are unaffected, and in-flight loads are preserved.

The string intern table is intentionally left unbounded: it backs stable
pointer-based FontId/FontFamilyId, and is bounded by the few distinct font names.
2026-06-16 09:07:53 +02:00
Kevin Boos
529f7d7720
Ensure inline composition is still shown in TextInput on all platforms (#1109)
* Support standard keyboard navg shortcuts/keys in TextInput

Implement platform-standard TextInput navigation and deletion behavior,
including Home, End, PageUp, PageDown, word movement, line/document
boundaries, and Shift-based selection.

* Use Apple Option/Cmd conventions on Apple targets
* Use Ctrl conventions on non-Apple targets
* Web accepts both shortcut styles for now, since we don't have a way
  to query the host OS from within a makepad web env.

Also, be extremely careful to ensure that we respect Unicode grapheme boundaries
when doing all the selection/navigation logic.

Fix `Delete`, which was erroneously handled before.

Add lots of missing keys in Linux X11 & Wayland backends, e.g.,
Home, End, Delete, Insert, PageUp/PageDown, and arrow keys

* Add `CropToFill` image fit variant, improve ImageFit docs

This allows you to easily achieve the "centered cropped fit" that most apps
want for things like avatars or small thubmnails that get masked.

* Detect and support hardware keyboards, distinguish from soft/virtual kbd

Mimic desktop behavior on mobile systems as much as possible.
This is esp important for tablets like iPad OS where you're more likely
to have a real physical keyboard attached.

For iOS:
* Arrow keys and Home/End/PageUp/PageDown navigate and auto-repeat
  at the system-defined rate (connected via `UIKeyCommand`)
* Cmd+Enter to submit a `TextInput` and Cmd+C/X/V clipboard shortcuts now work.
* Ensure the pop-up diacritic/accent menu is properly placed using a hidden
  `UITextInput` native widget, which acts as a sort of "proxy"
* Proactively drain `ShowTextIME` after each draw so the IME position will be
  properly updated after each keystroke.
* Importnatly, don't mark the IME dismissed when a hardware keyboard is attached.

For both iOS & Android:
* Add a `has_physical_keyboard()` detection mechanism across both backends,
  and fix platform-specific key repeat behavior

For Android:
* Ensure clipboard cut/copy works using the same Ctrl shortcuts (API 26+)

Soft/virtual keyboard/IME changes:
* For multiline TExtInputs, a soft keyboard Enter/Return key will always just
  insert a new line, to avoid complexity with keyboard shortcut cfgs.
* CJK keyboard character selection should also be properly positioned now

* minor optimization to avoid re-setting IME pos if it didn't change

* Ensure inline composition is still shown in TextInput on all platforms

This is mostly relevant when using CJK and other similar IMEs.
Previously Makepad didn't shown any "echoes" of the single latin chars
that the user would type, but it would correctly input the selected CJK
glyph. So if you typed `nihao` and then selected `你哈`, then you would
see the proper chinese characters but not the latin "nihao".
Tha't s a bit confusing while typing.

Summary of the fixes per platform:

macOS:
- `set_marked_text` now forwards the marked (composition) text to the
  focused TextInput with `replace_last = true`; previously it only stored
  it in an ivar. `unmark_text` clears the preview, and both it and
  `insert_text` share a new `clear_marked_text_ivar` helper so a commit
  doesn't emit a second, destructive text-input event.

Windows:
- Add `WM_IME_COMPOSITION` handling: commit `GCS_RESULTSTR`
  (`replace_last = false`) and show `GCS_COMPSTR` inline
  (`replace_last = true`), and clear on `WM_IME_ENDCOMPOSITION`. The
  message is consumed so DefWindowProc neither draws its own composition
  window nor synthesizes a duplicate WM_CHAR for the result.
- Extend the vendored `windows` binding with `ImmGetCompositionStringW`,
  `GCS_COMPSTR`/`GCS_RESULTSTR`, and `WM_IME_COMPOSITION`/
  `WM_IME_ENDCOMPOSITION`, which it didn't previously generate.

Linux (Wayland):
- Handle the `zwp_text_input_v3` `PreeditString` event (previously empty)
  and apply the double-buffered preedit/commit state on `Done`, in the
  protocol-mandated order (commit, then preedit), clearing the preview
  when a cycle carries no preedit.

Linux (X11):
- Create the input context with XIM on-the-spot (`XIMPreeditCallbacks`)
  and forward the preedit string from the draw/start/done callbacks,
  falling back to `XIMPreeditNothing` if the IM server doesn't support
  callbacks. Callbacks run inside `XFilterEvent`, so they only mutate a
  thread-local that the event loop drains into the widget afterward,
  avoiding re-entrant access to the app.

Android:
- Don't mark the IME dismissed when a physical keyboard is attached
  (mirrors the iOS guard). Android was unconditionally calling
  `text_ime_was_dismissed()` on soft-keyboard hide, which tore down the
  IME connection that hardware-key composition relies on.

* iOS: replace custom `UITextInput` with a native `UITextView`

`UITextView` is a full system-native keyboard client, so we get all the
major features for free: language HUD pill and the complete globe/Ctrl+Space
shortcut to cycle between IMEs/languages.

Makepad basically just mirrors the state of the system native text view,
via the `full_state_sync`, but the actual native text view is kept invisible
so it doesn't interfere with what we render in Makepad's TextInput.
Notably, the Full Keyboard Accessibility setting now does work properly,
whereas it did not before with our UITextInput-based approach.

We also make sure that arrow keys, nav keys, auto-repeat, and modifiers
are properly hanlded so we can retain the expected kbd shortcuts,
like other desktop platforms.

* iOS: remove the old `UITextInput` connection with the Makepad TextInput

We've now switched to the native UITextview, so we don't need this any more.

* iOS: fix desync during fast typing

Ensure there's no race between the native UITextView and
Makepad's TextInput, as the Enter/REturn key needs special handling
w.r.t. how `pressesBegan` gets it (From a real hardware kbd).

* TextInput: more iOS integration, and text input types

more native integration for things like username/password,
new password fields, email, address, URLs, etc.
These tell iOS to change the keyboard layout/type for the text input.

* iOS: don't let Full Keyboard Access focus on our hidden native cursor

* cleanup

* iOS/TextInput: fix perf issues

* iOS TextInput: more fixes for read-only efficiency, and filtered input

Also port some of these fixes to Android's IME integration layer

* iOS/TextInput: hide the native caret iOS draws during autocorrect

but still allow the "decline autocorrect" bubble to popup where that
hidden caret is located (and the CJK candidate window in the same spot)

* Avoid script VM re-entrant panic: defer animator_cut/play if script VM is held

`animator_cut` / `animator_play` call `cx.with_vm`, which panics
(*"Script VM swapped off"*) when invoked during an apply walk — e.g. a
widget's `on_after_apply` on `ScriptReapply` / `Reload` — because the VM
is already taken for the duration of that walk's enclosing `cx.with_vm`.

- The derive macro's `animator_cut_scoped` / `animator_play_scoped` now
  check `cx.is_script_vm_held()`; when held, they queue the op
  (`defer_cut` / `defer_play`) and return instead of re-entering the VM.
- `animator_handle_event_scoped` replays the queue via `flush_deferred`
  on the next frame, once the VM is free.

The defer path runs **only** in the formerly-panicking case, so VM-free
animations are byte-for-byte unchanged.

Also adds a re-entrancy-naming panic (`VmHolderGuard`) plus
`Cx::try_with_vm` / `Cx::is_script_vm_held` for diagnosing and handling
this class of bug.

* Better spacing/positioning for IME popups like the CJK candidate menu

applied to all platforms, but primarily an issue on macOS/iOS.

The candidate/conversion window (e.g. CJK pinyin) was covering the line of
text being composed. Carry the caret-line rect (not just a point) through
ShowTextIME and feed each backend its native "keep clear of this line" API,
so the OS places the candidate directly above/below the line with a small gap:

- macOS: firstRectForCharacterRange returns the line rect via AppKit
  convertRect:toView:nil + convertRectToScreen (drops the hand-rolled
  screen-coord math + fudge offsets); invalidate on caret move.
- Windows: ImmSetCandidateWindow with a CFS_EXCLUDE line rect.
- Wayland: set_cursor_rectangle with the real line rect.
- X11: XNSpotLocation/XNArea at the line.
- iOS: return the true composing-line box from firstRectForRange so iOS flips
  around the real edges (consistent at any screen position) instead of a
  degenerate point; only while marked text is active, to avoid an oversized
  autocorrect highlight when typing normally.

* Fix Linux X11 behavior: Ctrl-based kbd shortcuts didn't work in TextInput

also trying to fix X11 behavior for positioning the CJK candidate window,
turns out there was an X11 bug for Ubuntu 22 and older so it's not always
possible, but we can attempt a workaround if errors occur (based on that,
we try to auto-detect the version of X11)

* fix X11 event loop latency by draining only a max of 64 events before redrawing

still working on X11 CJK candidate window positioning...

* add logs to X11 ime to figure out wtf is going on

* more robust fallbacks for X11 CJK candidate window positioning... grr

* maybe try to set the XFontSet attribute? for CJK candidate positioning

* positioning works now but there is a bit of overlap still

* now that X11 CJK candidate positioning works in some cases,
we need to pass the full rectangle containing the current line of text
to the X11 library so that it can position the window both on top
and beneath the current line of text, if needed.

* tweaking X11 CJK candidate positioning

* abandon the screen-positioning heuristic

Instead, we just send the bounding rect of the current text character
and hopefully let the X11 platform libs decide where to put the
CJK candidate popup

* add more spacing to the bounding rect on X11

* tweak for a bit more space between CJK candidate window

* more tweaks, rect height isn't being respected for some reason...

* attempting to add more instrumentation to figure out wtf is going on with X11 CJK positioning

* remove bad instrumentation that was causing freezes. ugh

* different approach for IME placement on X11

* previous positioning attempts for X11 didn't work.

New strategy: let it be positioned, and then try to move it

* still trying to fix X11 CJK candidate window positoining...

* trying to find CJK candidate window with X11 queries (To move it)

* abandon window scanning approach

* better approach, now just tweaking it

* fix one case where the candidate window was flipped but it pointing too low

* tweaking more

* trying to fix above-text line positioning

* still trying to tweak CJK candidates ABOVE the text line

* be more conservative when guessing whether X11 will show the CJK candidate above or below

* improve size heuristic for CJK candidate height

* calling X11 as complete now. jfc. Cleanup, remove debug logs, etc
2026-06-16 09:07:32 +02:00
Kevin Boos
bc13b891e5
iOS: replace custom UITextInput with a native UITextView (#1121)
* iOS: replace custom `UITextInput` with a native `UITextView`

`UITextView` is a full system-native keyboard client, so we get all the
major features for free: language HUD pill and the complete globe/Ctrl+Space
shortcut to cycle between IMEs/languages.

Makepad basically just mirrors the state of the system native text view,
via the `full_state_sync`, but the actual native text view is kept invisible
so it doesn't interfere with what we render in Makepad's TextInput.
Notably, the Full Keyboard Accessibility setting now does work properly,
whereas it did not before with our UITextInput-based approach.

We also make sure that arrow keys, nav keys, auto-repeat, and modifiers
are properly hanlded so we can retain the expected kbd shortcuts,
like other desktop platforms.

* iOS: remove the old `UITextInput` connection with the Makepad TextInput

We've now switched to the native UITextview, so we don't need this any more.

* iOS: fix desync during fast typing

Ensure there's no race between the native UITextView and
Makepad's TextInput, as the Enter/REturn key needs special handling
w.r.t. how `pressesBegan` gets it (From a real hardware kbd).

* TextInput: more iOS integration, and text input types

more native integration for things like username/password,
new password fields, email, address, URLs, etc.
These tell iOS to change the keyboard layout/type for the text input.

* iOS: don't let Full Keyboard Access focus on our hidden native cursor

* cleanup

* iOS/TextInput: fix perf issues

* iOS TextInput: more fixes for read-only efficiency, and filtered input

Also port some of these fixes to Android's IME integration layer

* iOS/TextInput: hide the native caret iOS draws during autocorrect

but still allow the "decline autocorrect" bubble to popup where that
hidden caret is located (and the CJK candidate window in the same spot)
2026-06-12 09:12:26 +02:00
Kevin Boos
4f1f545ef3
Linux: fix desktop close/terminate lifecycle handling (#1120)
This avoids a strange case where Linux (both X11 and wayland but
in different ways) failed to properly shut down cleanly.

* Fix X11 close handling by removing destroyed windows from the map,
  which helps avoid delivering duplicate window closed events.
* Restructure how close handling happens on Wayland too, and allow
  the app to respond to a close request just like other platforms.
* Add a Linux second-signal hard exit so that repeated Ctrl+C
  or `kill` comands can actually terminate a failed/hung shutdown.
* Also a tiny fix to windows too: clear Win32 `GWLP_USERDATA` during
  `WM_DESTROY` to avoid accessing an old window pointer.
2026-06-11 22:12:28 +02:00
Kevin Boos
585475d02a
Fix modal behavior: prevent scroll behind it, make Fit{max} scrollable (#1117)
* Ensure that a view that specifies Fit with a max value can be scrolled.
* Turtle: include a view's outer maring in the size calc for a `Fit{max}` bound.
* Modal: dismiss on Escape KeyUp (not KeyDown) so the release can't leak to a
  background widget behind the modal.
* Modal: allow scrolling, and reset the scroll to the top when showing it.
* Touch-baased dragging for views (ScrollBar) and PortalList now respect
  the blocked scrolling areas, not just the mouse wheel / trackpad scroll.
* Forward the `set_scroll_pos()` through the widget derive traits so that
  we don't have to hook it up for each specific widget.
2026-06-11 22:12:09 +02:00
Kevin Boos
bb1474003c
SVG: allow replacing and re-loading the SVG "doc" (#1116)
Without this, once you load an SVG for the first time,
you can never change it. This meant that you couldn't change
a buttton's icon, for example, at runtime, even using a script apply.

Now that works, at no cost too, since we track which SVG body/"doc"
has been loaded to ensure we're not re-loading it on every draw
(which was already there, it was just too strict).
2026-06-09 21:04:52 +02:00
Kevin Boos
8b2e7e3eb0
iOS: fix hardware kbd behavior with "Full Keyboard Access" enabled (#1113)
* Support standard keyboard navg shortcuts/keys in TextInput

Implement platform-standard TextInput navigation and deletion behavior,
including Home, End, PageUp, PageDown, word movement, line/document
boundaries, and Shift-based selection.

* Use Apple Option/Cmd conventions on Apple targets
* Use Ctrl conventions on non-Apple targets
* Web accepts both shortcut styles for now, since we don't have a way
  to query the host OS from within a makepad web env.

Also, be extremely careful to ensure that we respect Unicode grapheme boundaries
when doing all the selection/navigation logic.

Fix `Delete`, which was erroneously handled before.

Add lots of missing keys in Linux X11 & Wayland backends, e.g.,
Home, End, Delete, Insert, PageUp/PageDown, and arrow keys

* Add `CropToFill` image fit variant, improve ImageFit docs

This allows you to easily achieve the "centered cropped fit" that most apps
want for things like avatars or small thubmnails that get masked.

* Detect and support hardware keyboards, distinguish from soft/virtual kbd

Mimic desktop behavior on mobile systems as much as possible.
This is esp important for tablets like iPad OS where you're more likely
to have a real physical keyboard attached.

For iOS:
* Arrow keys and Home/End/PageUp/PageDown navigate and auto-repeat
  at the system-defined rate (connected via `UIKeyCommand`)
* Cmd+Enter to submit a `TextInput` and Cmd+C/X/V clipboard shortcuts now work.
* Ensure the pop-up diacritic/accent menu is properly placed using a hidden
  `UITextInput` native widget, which acts as a sort of "proxy"
* Proactively drain `ShowTextIME` after each draw so the IME position will be
  properly updated after each keystroke.
* Importnatly, don't mark the IME dismissed when a hardware keyboard is attached.

For both iOS & Android:
* Add a `has_physical_keyboard()` detection mechanism across both backends,
  and fix platform-specific key repeat behavior

For Android:
* Ensure clipboard cut/copy works using the same Ctrl shortcuts (API 26+)

Soft/virtual keyboard/IME changes:
* For multiline TExtInputs, a soft keyboard Enter/Return key will always just
  insert a new line, to avoid complexity with keyboard shortcut cfgs.
* CJK keyboard character selection should also be properly positioned now

* minor optimization to avoid re-setting IME pos if it didn't change

* Image support: add bmp/qoi,ico, webp, SVG in `Image` widget, 16-bit png

Generally, this commit makes improvements to image decoding and rendering.

Added a bunch of functions for image discovery / metadata gathering:
`decode_image_from_data()`, `image_size_by_data()`, `looks_like_svg()`

Added more `Image[Ref]` functions for other image formats:
`ImageRef::load_{bmp,qoi,ico,gif,webp,svg}_from_data()`, plus a nice
convenience fn for auto-detec+load: `load_image_from_data()`.

Added cheap, lazily-init'd support for SVGs within the `Image` widget.

Fixed some issues with aspect ratio being clobbered during image rotation.

* iOS: fix hardware kbd behavior with "Full Keyboard Access" enabled

That accessibility setting messed with our previous version, but now
we've made it play nicely with FKA.

It's not *quite* perfect yet, we still don't get the nice little
system-native "pill" pop-up that allows you to easily switch between
the languages/IMEs you've enabled. But it sort of works.
2026-06-09 10:01:57 +02:00
Kevin Boos
8b03b0b2ad
Audit and harden image decoding stuff against huge inputs (DoS) (#1110)
* Image support: add bmp/qoi,ico, webp, SVG in `Image` widget, 16-bit png

Generally, this commit makes improvements to image decoding and rendering.

Added a bunch of functions for image discovery / metadata gathering:
`decode_image_from_data()`, `image_size_by_data()`, `looks_like_svg()`

Added more `Image[Ref]` functions for other image formats:
`ImageRef::load_{bmp,qoi,ico,gif,webp,svg}_from_data()`, plus a nice
convenience fn for auto-detec+load: `load_image_from_data()`.

Added cheap, lazily-init'd support for SVGs within the `Image` widget.

Fixed some issues with aspect ratio being clobbered during image rotation.

* Audit and harden image decoding stuff against huge inputs (DoS)

Bound the size of the decoded image, pixel count, frame counts (for animated),
range of SVG sniffing, and encoded file size.
Only once we run those checks do we actually alloc a buffer for the decoded image. before allocating decode buffers. Validate

Add various other checks within the vendored image decoding libraries too.
2026-06-09 00:31:36 +02:00
Kevin Boos
f5df1eb4d1
Layout/turtle fixes: right-wrap flows could cut off the right side of widgets (#1114)
Especially in right-aligned view rows (`Align: {x: 1.0}`), the turtle logic
wasn't accounting for spacing nor alignment when deciding to wrap.
Now those are taken into account, so we don't get weird cut-off views.
2026-06-09 00:10:47 +02:00
Kevin Boos
850edc8ca6
Image support: add bmp/qoi,ico, webp, SVG in Image widget, 16-bit png (#1108)
Generally, this commit makes improvements to image decoding and rendering.

Added a bunch of functions for image discovery / metadata gathering:
`decode_image_from_data()`, `image_size_by_data()`, `looks_like_svg()`

Added more `Image[Ref]` functions for other image formats:
`ImageRef::load_{bmp,qoi,ico,gif,webp,svg}_from_data()`, plus a nice
convenience fn for auto-detec+load: `load_image_from_data()`.

Added cheap, lazily-init'd support for SVGs within the `Image` widget.

Fixed some issues with aspect ratio being clobbered during image rotation.
2026-06-09 00:09:29 +02:00
Kevin Boos
221cd7a1d5
Detect and support hardware keyboards, distinguish from soft/virtual kbd (#1106)
* Support standard keyboard navg shortcuts/keys in TextInput

Implement platform-standard TextInput navigation and deletion behavior,
including Home, End, PageUp, PageDown, word movement, line/document
boundaries, and Shift-based selection.

* Use Apple Option/Cmd conventions on Apple targets
* Use Ctrl conventions on non-Apple targets
* Web accepts both shortcut styles for now, since we don't have a way
  to query the host OS from within a makepad web env.

Also, be extremely careful to ensure that we respect Unicode grapheme boundaries
when doing all the selection/navigation logic.

Fix `Delete`, which was erroneously handled before.

Add lots of missing keys in Linux X11 & Wayland backends, e.g.,
Home, End, Delete, Insert, PageUp/PageDown, and arrow keys

* Add `CropToFill` image fit variant, improve ImageFit docs

This allows you to easily achieve the "centered cropped fit" that most apps
want for things like avatars or small thubmnails that get masked.

* Detect and support hardware keyboards, distinguish from soft/virtual kbd

Mimic desktop behavior on mobile systems as much as possible.
This is esp important for tablets like iPad OS where you're more likely
to have a real physical keyboard attached.

For iOS:
* Arrow keys and Home/End/PageUp/PageDown navigate and auto-repeat
  at the system-defined rate (connected via `UIKeyCommand`)
* Cmd+Enter to submit a `TextInput` and Cmd+C/X/V clipboard shortcuts now work.
* Ensure the pop-up diacritic/accent menu is properly placed using a hidden
  `UITextInput` native widget, which acts as a sort of "proxy"
* Proactively drain `ShowTextIME` after each draw so the IME position will be
  properly updated after each keystroke.
* Importnatly, don't mark the IME dismissed when a hardware keyboard is attached.

For both iOS & Android:
* Add a `has_physical_keyboard()` detection mechanism across both backends,
  and fix platform-specific key repeat behavior

For Android:
* Ensure clipboard cut/copy works using the same Ctrl shortcuts (API 26+)

Soft/virtual keyboard/IME changes:
* For multiline TExtInputs, a soft keyboard Enter/Return key will always just
  insert a new line, to avoid complexity with keyboard shortcut cfgs.
* CJK keyboard character selection should also be properly positioned now

* minor optimization to avoid re-setting IME pos if it didn't change
2026-06-09 00:09:14 +02:00
Kevin Boos
393206a356
Run script-VM gc in the desktop and mobile event loops, not just macOS (#1107)
Only macOS was calling the script VM's garbage collector.
Now we call it everywhere, based on the original implementation in macOS.
2026-06-06 09:00:59 +02:00
Kevin Boos
013e355e23
Linux: cache GL shaders to the local fs (#1105)
Previously, `get_cache_dir()` returned None for Linux (X11, Wayland, Direct)
so there was no shader caching happening like there was on
android and windows.

Now we cache it and also handle removal of stale shader binaries
2026-06-05 19:23:40 +02:00
alanpoon
ad73981737
Play and Pause fix in Video (#1096)
* video_debug

* Fix video pause being overridden by stall-recovery force-play on macOS

The native AVPlayer poll loop nudged a rate-0 player back into playing
whenever `autoplay` was true, intended as stall recovery but firing every
frame after a user-initiated pause. Once `begin_playback` latched
`autoplay = true` on first start, subsequent pauses were undone on the
next frame poll.

Split user playback intent into a `should_play` field that toggles on
play/pause/resume, and gate the force-play check on that instead of
`autoplay` (which is now a one-shot consumed in `check_prepared`).

Also restore the `Apply::Animate` early return in `Video::on_after_apply`
(needed so hover transitions don't re-decode the PNG/JPG thumbnail every
frame) and drop the redundant `Texture::new(cx)` allocation in
`apply_thumbnail_settings` that load_thumbnail_image immediately
overwrote anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 07:19:21 +02:00
Kevin Boos
0bc3c7798b
DrawText: avoid redrawing slug stuff on EVERY frame (#1104)
* Fix CPU core locked to 100% on Linux X11/Wayland when idle

The desktop event loop's `select()` call was watching stdin (file descriptor 0)
which acts as ALWAYS readable whenever stdin is redirected to /dev/null
or similar.
So taht was causing the loop to falsely run every time that the select
call was made, even if nothing actually was readable on any of those FDs.

The fix is to just ... not do that, haha. Only makepad-studio uses
something like that, but no longer. It now uses websockets only.

Also, harden vsync behavior by setting `eglSwapInterval` explicitly
(vsync on by default; MAKEPAD_NO_VSYNC opt-out) to ensure that things
that get continuously drawn are capped at the display's refresh rate.

* DrawText: avoid redrawing slug stuff on EVERY frame

On Linux/Windows, the slug text path redrew a draw item's "old area" whenever
that path wasn't drawn in the current draw_text call and the area wasn't Empty.

THis was causing an entire CPU core to be pinned to 100% due to an
infinite loop of repaints.

Now, we only clear a draw item when its area holds genuinely stale content
(instance_count > 0 and a stale redraw_id).
2026-06-05 07:19:06 +02:00
Kevin Boos
efeb6d6bbf
Fix CPU core locked to 100% on Linux X11/Wayland when idle (#1103)
The desktop event loop's `select()` call was watching stdin (file descriptor 0)
which acts as ALWAYS readable whenever stdin is redirected to /dev/null
or similar.
So taht was causing the loop to falsely run every time that the select
call was made, even if nothing actually was readable on any of those FDs.

The fix is to just ... not do that, haha. Only makepad-studio uses
something like that, but no longer. It now uses websockets only.

Also, harden vsync behavior by setting `eglSwapInterval` explicitly
(vsync on by default; MAKEPAD_NO_VSYNC opt-out) to ensure that things
that get continuously drawn are capped at the display's refresh rate.
2026-06-05 07:18:48 +02:00
Kevin Boos
7fff61c7a5
Add CropToFill image fit variant, improve ImageFit docs (#1102)
* Support standard keyboard navg shortcuts/keys in TextInput

Implement platform-standard TextInput navigation and deletion behavior,
including Home, End, PageUp, PageDown, word movement, line/document
boundaries, and Shift-based selection.

* Use Apple Option/Cmd conventions on Apple targets
* Use Ctrl conventions on non-Apple targets
* Web accepts both shortcut styles for now, since we don't have a way
  to query the host OS from within a makepad web env.

Also, be extremely careful to ensure that we respect Unicode grapheme boundaries
when doing all the selection/navigation logic.

Fix `Delete`, which was erroneously handled before.

Add lots of missing keys in Linux X11 & Wayland backends, e.g.,
Home, End, Delete, Insert, PageUp/PageDown, and arrow keys

* Add `CropToFill` image fit variant, improve ImageFit docs

This allows you to easily achieve the "centered cropped fit" that most apps
want for things like avatars or small thubmnails that get masked.
2026-06-05 07:18:35 +02:00
Kevin Boos
f497a9fc14
Support standard keyboard navg shortcuts/keys in TextInput (#1101)
Implement platform-standard TextInput navigation and deletion behavior,
including Home, End, PageUp, PageDown, word movement, line/document
boundaries, and Shift-based selection.

* Use Apple Option/Cmd conventions on Apple targets
* Use Ctrl conventions on non-Apple targets
* Web accepts both shortcut styles for now, since we don't have a way
  to query the host OS from within a makepad web env.

Also, be extremely careful to ensure that we respect Unicode grapheme boundaries
when doing all the selection/navigation logic.

Fix `Delete`, which was erroneously handled before.

Add lots of missing keys in Linux X11 & Wayland backends, e.g.,
Home, End, Delete, Insert, PageUp/PageDown, and arrow keys
2026-06-03 20:53:07 +02:00
Admin
cb8ed4c119 zune forgotten thing 2026-06-03 11:19:02 +02:00
Kevin Boos
e3ac9f2035
iOS: if MTKView can't be drawn to, keep the resize/redraw "flag" dirty (#1100)
There was a bug where apps on iPad would not always be properly redrawn
when being resized (in windowed mode). This fixes that (at least in my testing)
by NOT clearing the dirty state when the MTKView has no render pass descriptor

Thus, the next time it's valid, we repaint it as expected.
2026-06-03 07:47:48 +02:00
Admin
52d290581f Add isolate example files 2026-06-02 19:12:25 +02:00
Admin
a86f6e3bda isolates 2026-06-02 18:51:17 +02:00
Admin
4912f2690d update zune 2026-06-02 18:47:28 +02:00
Kevin Boos
e697eb81ae
cargo_makepad: fix iOS icon behavior to not override app-specific icon bundles (#1099)
* Extend support for system bar appearance to iOS too

* cargo_makepad: fix default icon behavior for iOS

Icons need to not be modified by cargo_makepad if they're already
in the proper iOS-expected format, otherwise they'll end up with
some kind of extra black border around the icon, which looks bad.
2026-06-02 10:26:25 +02:00
Kevin Boos
eba743032d
Extend support for system bar appearance to iOS too (#1098) 2026-06-02 10:26:11 +02:00
admin
3d18a137ca splash md for calculator 2026-05-27 07:39:41 +02:00
Kevin Boos
12dcfc1e77
Fix subtle layout bug that ignored padding for text wrapping calc (#1094) 2026-05-24 06:34:08 +02:00
Kevin Boos
46ee22634c
Redesign StackNavigation to support pushing dynamically-defined widgets (#1093)
Remove the concept of a "full-screen" override for stack nav,
as it's completely useless and just added complexity.

This also fixes the push/pop "sliding" animation to be more fluid
2026-05-23 10:13:20 +02:00
alanpoon
d0bc4c8a69
Video enhancement (#1092)
* Gate side-effects

* Allow Sync to another VideoRef in Modal

* minor fix
2026-05-23 10:13:09 +02:00
Kevin Boos
a391f5e347
Overhaul android tooling and platform layers to support Android 8 (#1091)
* Dock: avoid ID collisions in drag/drop; never delete dock root in unsplit_tabs

* Clean up and further harden dock logic around splitting/dragging

* cargo_makepad: Android App Bundle builds, API 26 support, stable toolchain

Overhaul the Android build pipeline. Three related build-tooling
changes that share compile.rs/sdk.rs and so are committed together.

Android App Bundle (.aab) support — required for Google Play uploads:
- New `build-aab` command: compile resources with aapt2, link a
  proto-format APK, assemble the base module, run bundletool, and sign
  with jarsigner.
- New `keystore-create` command wrapping keytool, with a reusable
  keystore sidecar file; new `--keystore*`, `--no-sign`,
  `--version-code`, `--version-name` flags.
- Version codes may be explicit or auto-generated as a monotonic
  YYYYMMDDHH UTC integer.
- Read app id, version, and signing metadata from
  `[package.metadata.packager]` / `[package.metadata.makepad.android]`
  in Cargo.toml; support a custom AndroidManifest.xml template.
- Upgrade the bundled TOML parser for the dotted keys, inline tables,
  and multi-line strings those metadata sections use.
- Download bundletool and copy jarsigner/keytool/aapt2 into the SDK.

minSdkVersion 26:
- Lower the default Android minimum SDK from 33 to 26 and track the
  target SDK (35) separately, emitting minSdkVersion and
  targetSdkVersion independently in the generated manifest; add a
  `--min-sdk-version` override.

Stable Rust toolchain:
- Build Android and iOS on stable instead of nightly. tvOS still needs
  nightly for `-Z build-std`, so the channel is resolved per target.
- Add `ensure_rust_toolchain_installed` (install only when missing).

* Android: load newer NDK symbols at runtime to support API 26

With the minimum SDK lowered to 26, NDK entry points that only exist
on newer API levels can no longer be declared with `extern "C"` —
doing so breaks `dlopen`/startup on API 26-28. Resolve them at
runtime instead:

- amidi_sys: lazily `dlopen` libamidi.so (API 29+) into a cached
  vtable; the wrappers degrade to error/zero returns when the library
  is absent on older devices.
- android_jni: `dlsym` the AChoreographer vsync callbacks, gated on
  the running API level.
- ndk_sys: drop the `extern "C"` declarations for
  `ANativeWindow_setFrameRate` and the Choreographer callbacks;
  android.rs drops the now-unused frame-rate call.
- MakepadActivity: guard `setInitialSurroundingSubText` (API 30+) and
  `layoutInDisplayCutoutMode` (API 28+) behind version checks.
- android_jni: the fallback render-loop thread now exits cleanly when
  the app is torn down.

* Android: automatic and app-controlled system bar appearance

Add a way to control the tint of the status and navigation bar icons,
fixing white-on-white (invisible) icons when an app draws a light
background under a system dark-mode theme.

- New `Cx::set_system_bar_appearance(SystemBarAppearance)`. The default
  `Auto` mode picks dark or light icons from the window background
  luminance; `DarkIcons`/`LightIcons` force the choice.
- The `Window` widget resolves the setting each event cycle — for
  `Auto`, the Rec.709 luma of `pass.clear_color` — and emits
  `CxOsOp::SetSystemBarDarkIcons` only when the resolved value changes.
- On Android this drives `WindowInsetsController.setSystemBarsAppearance`
  (API 30+) or the `SYSTEM_UI_FLAG_LIGHT_*` flags (API 26-29). The tint
  is re-asserted after fullscreen toggles, since the legacy path
  rewrites the whole `systemUiVisibility` bitmask.

* Android: fix soft-keyboard handling and edge-to-edge insets

Several related window-inset and IME fixes, mostly affecting devices
that are not edge-to-edge (Android versions before 15).

- Report safe-area and IME insets as the overlap with the render
  surface, not the raw window-edge insets. On a non-edge-to-edge
  window the surface already sits inside the system bars, so the raw
  insets double-counted — leaving oversized gaps around content and
  above the keyboard.
- Also drive safe-area insets from `onGlobalLayout`, so the app is
  inset correctly from launch instead of drawing under the status bar
  until the first keyboard show or rotation.
- While the keyboard animates, treat the `WindowInsetsAnimation`
  callback as the authoritative per-frame inset source and have the
  layout-driven callbacks defer to it. Read target IME visibility from
  `getRootWindowInsets()` so a show animation is not misread as an
  instant dismissal.
- Only reconfigure the Java IME when the `TextInputConfig` actually
  changes, instead of on every show.
- `KeyboardView`: compute and apply the content shift at keyboard-show
  event time, removing a one-frame lag and a tail-end jump; only
  reconcile post-draw when the focused field actually redrew.
- `Modal::close()`: skip the focus revert when the modal is already
  closed — it was stealing focus from a just-tapped text input and
  causing a first-tap keyboard flicker.
- Hide the keyboard via `WindowInsetsController.hide(ime())` on API 30+.

* platform: don't panic posting actions during shutdown

post_action no longer unwraps the global action sender. It now
silently drops the action if the sender mutex is poisoned, no Cx
sender is installed, or the receiver has been dropped during app
teardown, and only raises the UI signal when the send succeeds.

(Also shortens an over-long field doc comment in cx.rs; no behavior
change.)

* cargo-makepad: link std statically in AAB builds (16 KB page-size fix)

`-C prefer-dynamic` ships std as a separate, 4 KB-aligned libstd.so that
fails Play's 16 KB page-size rule. AAB builds now link std statically;
APK/dev builds keep prefer-dynamic. Also documents {min_sdk_version} in help.
2026-05-22 01:40:29 +02:00
Kevin Boos
877234c6cf
Dock: avoid ID collisions in drag/drop; never delete dock root in unsplit_tabs (#1089)
* Dock: avoid ID collisions in drag/drop; never delete dock root in unsplit_tabs

* Clean up and further harden dock logic around splitting/dragging
2026-05-22 01:40:15 +02:00
Edward Tan
8447f5666a
Fix Android rendering, loading issues (#1090)
* Disable SLUG text band acceleration

* Load Android optional APIs dynamically

* Fixed the android-only Gauss-pane vertical flip by correcting render-target Y sampling in:

  - widgets/src/window.rs:110
  - widgets/src/gauss_view.rs:151

  Root cause: Gauss captures the scene into render-target textures, then samples them back
  into the UI. Those render-target textures need a Y flip when displayed, matching the
  existing Image widget behavior.
2026-05-21 09:08:13 +02:00
Kevin Boos
6d1e9d324c
Don't duplicately convert the layout points for iOS IME area calculation (#1087) 2026-05-19 08:36:26 +02:00
Kevin Boos
a982f741d7
macOS: don't trigger both menu items and keyboard events for shortcuts (#1086) 2026-05-19 08:36:13 +02:00
Kevin Boos
d5d85d7501
Improve mobile IME and soft keyboard handling (#1085)
* Support overriding the dpi factor at runtime, on all platforms

Add `Cx::set_window_dpi_override` for runtime UI zoom, but dispatch it
in a deferred manner so it's safe to call in an event handler.

For each platform, we connect the dpi override to the click/tap
coordinates to remap it properly, which was done on some platforms
but not most.

* Fix and restyle todo example

* cad

* Fix todo input styling and studio build env

* fix slides

* Don't apply UI scaling (dpi override) to safe inset areas / IME areas

Scaling those areas doesn't make sense, as it can lead to empty space
(extra unnecessary padding) on the border of the IME or the device inset area,
which looks bad and is basically objectively wrong

-------------

Centralize native/physical/layout DPI conversion on `CxWindow`, and use it at platform boundaries for window geometry, safe-area insets, input coordinates, soft keyboard spacing, clipboard and selection overlays, and camera preview rects.

Add runtime `set_window_dpi_override` support that rescales all window-local metrics and emits `WindowGeomChange`.

* Improve mobile IME and soft keyboard handling

- Add broader soft keyboard configuration for input modes, autocorrect, autocapitalization, return key types, multiline, and secure text entry.
- Improve iOS text input state handling, composition ranges, selection sync, and native/layout coordinate conversion.
- Improve Android InputConnection handling for composing text, selection updates, batch edits, editor actions, surrounding text, and programmatic text sync.
- Wire TextInput through the expanded IME configuration surface.
- Cover newer iOS and Android IME APIs while preserving fallbacks for older keyboard behavior.

* Fix iOS IME area DPI override scaling

* Remove Android-specific IME hack

* Further fix Android IME to avoid stale composition ranges being underlined

espeically after you tap elsewhere in the TextInput widget

---------

Co-authored-by: admin <info@makepad.nl>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:14:21 +02:00
Kevin Boos
c96b28628c
Don't apply UI scaling (dpi override) to safe inset areas / IME areas (#1084)
* Don't apply UI scaling (dpi override) to safe inset areas / IME areas

Scaling those areas doesn't make sense, as it can lead to empty space
(extra unnecessary padding) on the border of the IME or the device inset area,
which looks bad and is basically objectively wrong

-------------

Centralize native/physical/layout DPI conversion on `CxWindow`, and use it at platform boundaries for window geometry, safe-area insets, input coordinates, soft keyboard spacing, clipboard and selection overlays, and camera preview rects.

Add runtime `set_window_dpi_override` support that rescales all window-local metrics and emits `WindowGeomChange`.

* Fix pre-existing iOS and Android build breaks

iOS (platform/src/os/apple/ios/ios.rs): five `CxOsOp` arms had edits
that landed one match-arm late, so each block referenced bindings only
in scope on the preceding arm. Re-home them:

- `WindowGeomChange` now applies `native_window_geom_to_layout` (the
  two stray lines previously sat inside the `Paint`/`prepared` arm).
- `ShowTextIME` now converts `pos` via `layout_vec2d_to_native_points`
  (previously lodged inside the `SyncImeState` destructure pattern).
- `ShowClipboardActions` now converts `rect` / `keyboard_shift` to
  native points (previously appended to `ShowSelectionHandles`).
- `ShowSelectionHandles` / `UpdateSelectionHandles` now convert `start`
  and `end` (the `Update` arm had no conversion, and the `Show` arm's
  conversion was actually the clipboard one).
- `FullscreenWindow` / `NormalizeWindow` drop the bogus `start` / `end`
  conversions that didn't belong there.

Android (platform/src/ime.rs): `android_jni::to_java_configure_keyboard`
matches on `InputMode::None` and `ReturnKeyType::{Next, None, Previous,
Google, Yahoo, Join, Route, Continue, EmergencyCall}` — variants that
exist on the `ime_improvements` branch (commit 3dc039f0a) but weren't
pulled into this branch. Add them to the enum definitions so the
android target compiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:32:21 +02:00
alanpoon
5416cdd25c
Added gif (#1083)
* added giphy

* added gif

* remove unnecessary file changes

* animated_image_git
2026-05-18 22:47:53 +02:00
Kevin Boos
3eae9b762a
Don't always set the app's icon (e.g., for packaged app bundles) (#1082)
* Support overriding the dpi factor at runtime, on all platforms

Add `Cx::set_window_dpi_override` for runtime UI zoom, but dispatch it
in a deferred manner so it's safe to call in an event handler.

For each platform, we connect the dpi override to the click/tap
coordinates to remap it properly, which was done on some platforms
but not most.

* Don't always set the app's icon (e.g., for packaged app bundles)
2026-05-18 22:47:11 +02:00
Kevin Boos
8c276fefbc
Support overriding the dpi factor at runtime, on all platforms (#1081)
Add `Cx::set_window_dpi_override` for runtime UI zoom, but dispatch it
in a deferred manner so it's safe to call in an event handler.

For each platform, we connect the dpi override to the click/tap
coordinates to remap it properly, which was done on some platforms
but not most.
2026-05-18 22:46:55 +02:00
admin
e76019a9f4 fix slides 2026-05-05 10:00:39 +02:00
admin
621c7dfee4 Fix todo input styling and studio build env 2026-05-05 07:50:38 +02:00
admin
5ebbe29850 cad 2026-05-04 18:34:29 +02:00
admin
245d47ef5a Fix and restyle todo example 2026-05-04 17:37:21 +02:00
admin
9df7c778f3 Add local AI talk slide 2026-05-04 17:05:19 +02:00
admin
4eef5a1cb0 Tighten realtime CAD demo wording 2026-05-04 17:02:46 +02:00
admin
84c634ac32 Add Robrix opening demo 2026-05-04 17:01:23 +02:00
Kevin Boos
4c3093a39b
Fix virtual/soft keyboard shift on both iOS & Android (#1079)
This normalizes the IME geometry calcs across iOS and Android.
The main difference is to make KeyboardView shift the content based on
the focused TExtInput instance instead of trying to shrink the viewport.

Also make sure that StackNavigation widget properly handles the
keyboard shift, even when it is drawing in full-screen mode.
2026-05-04 16:58:44 +02:00
admin
fdcf33e167 Clarify realtime CAD demo 2026-05-04 16:58:37 +02:00
Kevin Boos
ee39df2089
Windows: properly catch sigquit and actually exit (#1077) 2026-05-04 16:58:11 +02:00
admin
b377e6b9e4 Update AI talk demo sequence 2026-05-04 16:57:16 +02:00
admin
2a7e59ac84 Wrap AI talk title slide 2026-05-04 16:54:24 +02:00
admin
17ca8cf9ce Refine AI talk slide content 2026-05-04 16:53:04 +02:00
admin
ba1ba116c3 Add slides example to splash run items 2026-05-04 16:42:27 +02:00
admin
48612ea771 Add AI Makepad talk slides example 2026-05-04 16:25:03 +02:00
Admin
30dc2e621e aichat 2026-05-04 13:50:02 +02:00
Admin
1dfae4654b fix cad 2026-05-04 11:35:36 +02:00
Admin
77208b237e ai chat working 2026-05-04 08:48:38 +02:00
Codex
c2fb2c4b06 Fix Linux RunView DPI propagation 2026-05-03 23:52:29 +02:00
Admin
a5992d3fb2 xr 2026-05-03 23:45:59 +02:00
Admin
fe7cc8babc gauss ok 2026-05-03 23:45:59 +02:00
Admin
55db1be071 gauss blurs 2026-05-03 23:45:59 +02:00
Kevin Boos
20b6c53b6f
App menu bar: restore Quit option, use proper app name (#1076)
* App menu bar: restore Quit option, use proper app name

The menu bar shows "MakepadStdInLoop" by default, which is really strange
especially for published apps. This fixes that and also allows the app
to set the name, as well as using a sensible default for it if the app
didn't specify it.

We also now restore the previous Makepad 1.0 behavior of having a default
"Quit" entry in the main app's first menu bar entry.

* ensure Exit flow actually makes it out, on macOS
2026-05-02 09:35:44 +02:00
Kevin Boos
e0549dc502
iOS: don't always prompt the user for camera/mic access on app startup (#1075)
* WIP: adding full app lifecycle event support, and Ctrl+C/signal catch

* iOS: don't always prompt the user for camera/mic access on app startup

This fixes two unrelated but similar-structure issues, both on iOS:

- `Cx::handle_repaint` ran a 32-slot encoder-capture sweep every frame,
  which lazy-created `AvCaptureAccess` and fired
  `requestAccessForMediaType:AVMediaTypeVideo` — i.e. the camera prompt —
  on every Makepad app, even ones that never touch the camera. Gate the
  loop on `av_capture.is_some()` and stop the three `video_encoder_*`
  methods in `apple_media.rs` from lazy-creating it; they now return
  `EncoderNotStarted` (or no-op for `push_frame`) when no encoder exists.

- `AudioUnitAccess::new()` unconditionally activated the shared
  `AVAudioSession` with category `PlayAndRecord` + `VoiceChat` mode,
  which is wrong for playback-only apps (mic prompt + forced VPIO).
  Defer session config to a new `ensure_ios_session(for_input)` helper,
  called from `use_audio_inputs` (PlayAndRecord) and `use_audio_outputs`
  (Playback). Idempotent, never downgrades.

* Added fuller lifecycle event support, plus ability to catch sigquit

Tested working well on every platform except web/wasm, as I don't have
a working setup able to test that.
2026-05-01 22:30:33 +02:00
Kevin Boos
5ec03ff0ad
Add full app lifecycle event support, and Ctrl+C/signal catch (#1074)
* WIP: adding full app lifecycle event support, and Ctrl+C/signal catch

* Added fuller lifecycle event support, plus ability to catch sigquit

Tested working well on every platform except web/wasm, as I don't have
a working setup able to test that.
2026-05-01 22:30:24 +02:00
Kevin Boos
ecff7b816b
Don't duplicate fonts in Android/iOS app bundles (#1073)
* Fix portallist alignment handling so centering actually works

Now, alignment on PortalLists now only applies to the "cross-axis"
of each item, not the main axis.
So when you center a portallist itself, it won't add weird space
on the leading side of the list

* Fix text not drawing on top of a background, e.g., `<code>` tags

This worked in *most* but not all cases, e.g., if you had a ton of
inline code tags, some of them would rarely but deterministically
not show the actual text glyphs, but just an empty background.

* Avoid large margin on the left of `<code>` if it's on a new line

* Add a separate "touch" margin; use it on dock splitter and tab close

Without this, those dock UI elements are nearly impossible to grab
and press on a real touch screen device, even on my iPad.

Also, tweak cargo-makepad iOS and Android builds to use a polished
display name for the app (uppercase first character) by default.

* Don't duplicate fonts in Android/iOS app bundles
2026-04-30 08:33:33 +02:00
Kevin Boos
3fe61b3ed0
Add separate touch margin; use it on dock splitter and tab close (#1072)
* Fix portallist alignment handling so centering actually works

Now, alignment on PortalLists now only applies to the "cross-axis"
of each item, not the main axis.
So when you center a portallist itself, it won't add weird space
on the leading side of the list

* Fix text not drawing on top of a background, e.g., `<code>` tags

This worked in *most* but not all cases, e.g., if you had a ton of
inline code tags, some of them would rarely but deterministically
not show the actual text glyphs, but just an empty background.

* Avoid large margin on the left of `<code>` if it's on a new line

* Add a separate "touch" margin; use it on dock splitter and tab close

Without this, those dock UI elements are nearly impossible to grab
and press on a real touch screen device, even on my iPad.

Also, tweak cargo-makepad iOS and Android builds to use a polished
display name for the app (uppercase first character) by default.
2026-04-30 08:33:04 +02:00
Kevin Boos
1a2453e7e4
Misc fixes for PortalList alignment and inline <code> tags (#1071)
* Fix portallist alignment handling so centering actually works

Now, alignment on PortalLists now only applies to the "cross-axis"
of each item, not the main axis.
So when you center a portallist itself, it won't add weird space
on the leading side of the list

* Fix text not drawing on top of a background, e.g., `<code>` tags

This worked in *most* but not all cases, e.g., if you had a ton of
inline code tags, some of them would rarely but deterministically
not show the actual text glyphs, but just an empty background.

* Avoid large margin on the left of `<code>` if it's on a new line
2026-04-30 08:32:51 +02:00
Admin
9d3d0e4450 ai mgr update 2026-04-28 18:25:08 +02:00
Admin
a3294c3c99 profiler 2026-04-28 12:33:37 +02:00
Admin
d8d6a7d971 profiler 2026-04-28 12:33:05 +02:00
Admin
61a3f53c5c ai manager otw 2026-04-28 12:24:40 +02:00
Admin
30385ae3c6 cleanup 2026-04-27 14:52:45 +02:00
Admin
614e203f17 aimgr 2026-04-27 14:51:24 +02:00
Kevin Boos
b32022a7d5
Stack nav now handles script reapply for previously-pushed views (#1070)
* Simplify tooltip logic, fix positioning to respect safe inset areas

And other misc positioning/formatting fixes, like wrapping
after a hard line break, as well as ensuring that the callout
arrow thing itself is centered (to the fullest extent possible)
w.r.t. the rest of the tooltip body.

Basically, now it looks good again.

* Fix CheckBox/Toggle `set_active` to animate like others

All other widgets allow you to pass an `animate` arg when setting
them as active, except CheckBox (and by proxy, its wrapper Toggle).
This is necessary for proper non-animated thigns like restoring the
state of a toggle from persistent storage, or other similar examples
where you don't really want the animation to occur (because that'll
look like the user did it accidentally or some kind of phantom movement).

* PortalList: pass "touch stop" (FingerUp) events to children, always

I had recently implemented a feature where PortalList would not pass
events down to its children if those events were being direclty handled
by the PortalList as part of its scroll-action. That was generally correct,
but it missed one rare case where a child widget was waiting on a
finger up (touch stop/release) for something like stopping a hover/down
animation.

So the child, like a button, could capture the initial FingerDown
but never the FingerUp, so they'd get stuck on the hover or down
animation. This fixes that issue by passing FingerUp-causing events
down to the child widgets.

Note that the children must use `was_tap()` on the FingerUp in order
to handle a regular click -- but they should have already been doing that.
So this doesn't break anything, it's just strictly a proper fix
for something that i should've covered previously.

* Separate apply-reload and script-reapply into different concepts

The goal here is to differentiate between "applies" that change the actual
Splash script "DSL" (i.e., the template) from a "re-apply" that doesn't
change the DSL template but does change runtime heap objects.

Mostly, we want to ensure that LiveEdit (the former) is different from
things that require heap updates (the latter), such as changing a theme
value or doing screen rotation that changes safe inset areas on mobile.

I don't know that I love this approach as a permanent solution,
but it's a good stepping stone until we can redesign LiveEdit/Apply
to funnel all of these various events through the same singular system.
We probably want to use different attributes on widget fields in order
to have more fine-grained control over what happens on an Apply action.

Generated list of brief details here:

* `Apply::ScriptReapply` variant + `is_script_reapply` /
  `is_live_edit_reload` predicates; `Event::ScriptReapply` now applies
  via `Apply::ScriptReapply`.
* `String` / `ArcStringMut` `script_apply` early-return on
  `ScriptReapply`.
* Codegen: `#[deref]` runs before `#[apply_default]`'s recursive call
  so animator state wins over template defaults.
* `Animator::script_apply_default` returns `state_object` on
  `ScriptReapply`; new `current_state_apply()` helper for
  `on_after_apply` hooks.

* `Cx::request_live_edit()` + `pending_live_edit_request` for primitive
  heap mutations (safe-area insets baked into `script_mod!`
  expressions).
* `handle_live_edit()` returns `LiveEditTrigger {None, FileChange,
  Manual}`; `run_live_edit_if_needed` skips shader-cache reset and
  same-tick `ScriptReapply` follow-up for `Manual` (fixes ~1s rotation
  lag).
* iOS/Android post-event hook now drains both flags via
  `run_live_edit_if_needed` (was firing `LiveEdit` indiscriminately).
* `Window` `WindowGeomChange` uses `request_live_edit()` for
  safe-area.

* `StackNavigationView` gains `runtime_title` field re-asserted in
  `on_after_apply`; new `StackNavigation::set_title` API.
* `app_main!` collapses 4 duplicate platform branches into a shared
  `_app_main_event_closure!` macro.

* Stack nav now handles script reapply for previously-pushed views

`StackNavigation`'s `_after_apply` only restored the currentt view,
but skipped all the pushed view, making their visibility false.

Now, `on_after_apply` sets all pushed views as visible, and also
that the offset (for the slide animation) gets properly re-set
2026-04-27 09:23:42 +02:00
Kevin Boos
cd6d2e78cd
Separate apply-reload and script-reapply into different concepts (#1069)
* Simplify tooltip logic, fix positioning to respect safe inset areas

And other misc positioning/formatting fixes, like wrapping
after a hard line break, as well as ensuring that the callout
arrow thing itself is centered (to the fullest extent possible)
w.r.t. the rest of the tooltip body.

Basically, now it looks good again.

* Fix CheckBox/Toggle `set_active` to animate like others

All other widgets allow you to pass an `animate` arg when setting
them as active, except CheckBox (and by proxy, its wrapper Toggle).
This is necessary for proper non-animated thigns like restoring the
state of a toggle from persistent storage, or other similar examples
where you don't really want the animation to occur (because that'll
look like the user did it accidentally or some kind of phantom movement).

* PortalList: pass "touch stop" (FingerUp) events to children, always

I had recently implemented a feature where PortalList would not pass
events down to its children if those events were being direclty handled
by the PortalList as part of its scroll-action. That was generally correct,
but it missed one rare case where a child widget was waiting on a
finger up (touch stop/release) for something like stopping a hover/down
animation.

So the child, like a button, could capture the initial FingerDown
but never the FingerUp, so they'd get stuck on the hover or down
animation. This fixes that issue by passing FingerUp-causing events
down to the child widgets.

Note that the children must use `was_tap()` on the FingerUp in order
to handle a regular click -- but they should have already been doing that.
So this doesn't break anything, it's just strictly a proper fix
for something that i should've covered previously.

* Separate apply-reload and script-reapply into different concepts

The goal here is to differentiate between "applies" that change the actual
Splash script "DSL" (i.e., the template) from a "re-apply" that doesn't
change the DSL template but does change runtime heap objects.

Mostly, we want to ensure that LiveEdit (the former) is different from
things that require heap updates (the latter), such as changing a theme
value or doing screen rotation that changes safe inset areas on mobile.

I don't know that I love this approach as a permanent solution,
but it's a good stepping stone until we can redesign LiveEdit/Apply
to funnel all of these various events through the same singular system.
We probably want to use different attributes on widget fields in order
to have more fine-grained control over what happens on an Apply action.

Generated list of brief details here:

* `Apply::ScriptReapply` variant + `is_script_reapply` /
  `is_live_edit_reload` predicates; `Event::ScriptReapply` now applies
  via `Apply::ScriptReapply`.
* `String` / `ArcStringMut` `script_apply` early-return on
  `ScriptReapply`.
* Codegen: `#[deref]` runs before `#[apply_default]`'s recursive call
  so animator state wins over template defaults.
* `Animator::script_apply_default` returns `state_object` on
  `ScriptReapply`; new `current_state_apply()` helper for
  `on_after_apply` hooks.

* `Cx::request_live_edit()` + `pending_live_edit_request` for primitive
  heap mutations (safe-area insets baked into `script_mod!`
  expressions).
* `handle_live_edit()` returns `LiveEditTrigger {None, FileChange,
  Manual}`; `run_live_edit_if_needed` skips shader-cache reset and
  same-tick `ScriptReapply` follow-up for `Manual` (fixes ~1s rotation
  lag).
* iOS/Android post-event hook now drains both flags via
  `run_live_edit_if_needed` (was firing `LiveEdit` indiscriminately).
* `Window` `WindowGeomChange` uses `request_live_edit()` for
  safe-area.

* `StackNavigationView` gains `runtime_title` field re-asserted in
  `on_after_apply`; new `StackNavigation::set_title` API.
* `app_main!` collapses 4 duplicate platform branches into a shared
  `_app_main_event_closure!` macro.
2026-04-26 22:11:31 +02:00
Kevin Boos
07354753b1
PortalList: pass "touch stop" (FingerUp) events to children, always (#1068)
* Simplify tooltip logic, fix positioning to respect safe inset areas

And other misc positioning/formatting fixes, like wrapping
after a hard line break, as well as ensuring that the callout
arrow thing itself is centered (to the fullest extent possible)
w.r.t. the rest of the tooltip body.

Basically, now it looks good again.

* Fix CheckBox/Toggle `set_active` to animate like others

All other widgets allow you to pass an `animate` arg when setting
them as active, except CheckBox (and by proxy, its wrapper Toggle).
This is necessary for proper non-animated thigns like restoring the
state of a toggle from persistent storage, or other similar examples
where you don't really want the animation to occur (because that'll
look like the user did it accidentally or some kind of phantom movement).

* PortalList: pass "touch stop" (FingerUp) events to children, always

I had recently implemented a feature where PortalList would not pass
events down to its children if those events were being direclty handled
by the PortalList as part of its scroll-action. That was generally correct,
but it missed one rare case where a child widget was waiting on a
finger up (touch stop/release) for something like stopping a hover/down
animation.

So the child, like a button, could capture the initial FingerDown
but never the FingerUp, so they'd get stuck on the hover or down
animation. This fixes that issue by passing FingerUp-causing events
down to the child widgets.

Note that the children must use `was_tap()` on the FingerUp in order
to handle a regular click -- but they should have already been doing that.
So this doesn't break anything, it's just strictly a proper fix
for something that i should've covered previously.
2026-04-26 22:11:17 +02:00
Kevin Boos
93aaca16fe
Fix CheckBox/Toggle set_active to animate like others (#1067)
* Simplify tooltip logic, fix positioning to respect safe inset areas

And other misc positioning/formatting fixes, like wrapping
after a hard line break, as well as ensuring that the callout
arrow thing itself is centered (to the fullest extent possible)
w.r.t. the rest of the tooltip body.

Basically, now it looks good again.

* Fix CheckBox/Toggle `set_active` to animate like others

All other widgets allow you to pass an `animate` arg when setting
them as active, except CheckBox (and by proxy, its wrapper Toggle).
This is necessary for proper non-animated thigns like restoring the
state of a toggle from persistent storage, or other similar examples
where you don't really want the animation to occur (because that'll
look like the user did it accidentally or some kind of phantom movement).
2026-04-26 22:11:07 +02:00
Kevin Boos
5d214947d7
Simplify tooltip logic, fix positioning to respect safe inset areas (#1066)
And other misc positioning/formatting fixes, like wrapping
after a hard line break, as well as ensuring that the callout
arrow thing itself is centered (to the fullest extent possible)
w.r.t. the rest of the tooltip body.

Basically, now it looks good again.
2026-04-26 22:10:54 +02:00
Kevin Boos
18669f5a58
Support runtime changes to script-level heap objects (#1063)
* Support runtime-reassigned module templates and app-wide events

- Dock/PortalList: add `refresh_widgets_mod_template()` so callers can
  re-capture a content template from `mod.widgets.*` after reassigning
  it at runtime via `script_eval!`. Dock's variant takes a separate
  template_key and mod_widgets_name since local DSL names (e.g.
  `room_screen`) don't always match the module entry (`RoomScreen`).
- PortalList: add `all_items_and_pool()` iterator so callers can walk
  every live and pooled item (e.g. to push a new property across the
  whole list on a preference change).
- StackNavigation: forward non-visibility events (`Event::Actions`) to
  all child stack views, not just visible ones. Inactive views need
  global state updates too; `View::handle_event` still gates on each
  child's own `visible` flag for events that require visibility.
- TextInput: add `submit_on_enter` so callers can opt into Cmd/Ctrl+
  Enter submit semantics, plus a `key_focus_lost` helper for commit-
  on-blur inputs.
- Image: honor `Size::Fit { max }` when `peek_walk_turtle` returns NaN
  so `Fit{max: Abs(..)}` caps image height without clipping.
- FlatList: derive `Default` on the shared `WidgetItem` struct.
- draw: re-export `Base` and `FitBound` from turtle.

* Add Event::ScriptReapply + preserve Dock state on reload

- `Event::ScriptReapply`: new event signalling a widget-tree Apply::Reload
  that does NOT re-run `script_mod!`. Fires from `run_live_edit_if_needed`
  when `Cx::pending_script_reapply` is set (previously the flag was never
  observed on desktop). The AppMain macro caches the app's script root as
  a rooted `ScriptObjectRef` and re-applies the tree with it — so runtime
  heap mutations (e.g. `script_eval!` overriding a user preference) stay
  intact. If a file-driven `LiveEdit` handler then sets the flag again, a
  bounded follow-up `ScriptReapply` pass runs in the same tick.

- Dock: on `Apply::Reload`, preserve existing runtime `dock_items` (open
  tabs, selected indices, splitter positions). Only insert DSL-defined
  items for IDs that don't already exist — so a source hot-reload no
  longer wipes the user's opened tabs.

* cleanup, remove unnecessary crap from prior approaches

the whole `refresh_widgets_mod_template` was a misguided approach,
and now that we have script reload/re-apply working and we have fixed
LiveEdit for most widgets, we just don't need it

* cleanup, remove more unused functions
2026-04-22 23:36:56 +02:00
Admin
ea993f30c4 Batch Qwen CUDA prefill MoE 2026-04-20 13:48:29 +02:00
Admin
92eee25a4c Optimize Qwen CUDA routing and exact decode 2026-04-20 13:48:29 +02:00
Admin
2325f23dbe Optimize Qwen CUDA MoE decode path 2026-04-20 13:48:29 +02:00
Admin
8049779c0b Checkpoint Qwen CUDA exact progress 2026-04-20 13:48:29 +02:00
Kevin Boos
2faaf7b16b
Implement the <details>/<summary> widget within Html (#1052)
* draw_text: adopt outer many_instances batch on linux/windows

CodeEditor opens an outer raster batch via DrawText::begin_many_instances
before its glyph loop. The linux/windows branch of DrawText::draw_text
ignored self.many_instances and opened its own nested batch, which
resolved (via find_appendable_drawcall) to the same draw_item whose
`instances` Vec was already swapped out by the outer open, panicking on
unwrap in Cx2d::begin_many_instances.

Mirror what the other platform branch (and draw_rasterized_glyphs_abs)
already do: take self.many_instances on entry, track whether the active
raster batch is the outer one, and hand it back on exit so the caller's
end_many_instances finalizes it. Skip the !drew_raster_this_frame area
clear when the outer batch is still live.

* Html/Markdown fixes: sub/superscript, table outlines/alignment, etc

- **Sub/sup in HTML**: added a `y_shift_scales` stack on `TextFlow`, composed onto `temp_y_shift` in `draw_text`. `<sub>` pushes `+0.55`, `<sup>` pushes `-0.2` in html.rs
- **Sub/sup in Markdown**: now handles `MdEvent::InlineHtml` for `<sub>`/`<sup>` (case-insensitive), using the same stacks.
- **Space after `&amp;` (and other entities)**: the HTML lib's entity decoder now resets `last_non_whitespace` after truncate+push, so the whitespace-collapse check no longer drops the next real space.
- **Table column alignment (Markdown)**: `begin_table_cell` takes `align_x: f64`; tracks `Tag::Table` alignments and a per-row column index, passing each cell's `Alignment` through.
- **Table column alignment (HTML)**: `<td>`/`<th>` now honor `align="…"` and inline `style="text-align: …"` via new `cell_align_x` / `align_keyword_to_x` helpers in html.rs.
- **Per-row text alignment plumbing**: new `layout_align` field on `DrawText` is passed to the layouter, which already supports per-row alignment. `TextFlow` propagates a `cell_text_align_x` into it. This is the actual fix that makes cell alignment visible.
- **Wrap-flow alignment scaffolding**: implemented the previously-stubbed `Flow::Right { wrap: true }` branch in the turtle logic. Useful for non-text wrapping walks; text goes through the layouter path above.
- Add examples to uizoo: three new tables in both markdown and html tab -- a plain one, a left/center/right aligned one, and a numeric all-right-aligned one. They cover bold/italic/code/links/sub-sup/emoji/entities/strikethrough inside cells.

* minor cleanup; prefer `style` over `align` HTML tag

* Implement the `<details>`/`summary` widget within Html

* cleanup/improvement

* spacing and size consistency for details/summary header
2026-04-19 10:13:49 +02:00
Kevin Boos
047e9cba21
Add RowAlign::Center, per-row FinishedWalk support, and inline widget alignment (#1053)
* Add RowAlign::Center, per-row FinishedWalk support, and inline widget alignment

Closes #712

turtle.rs:
- Add RowAlign::Center variant for vertically centering walks within a row
- Add finish_row_center() that shifts shorter walks to the row's vertical midline
- Fix finish_row's current_row_walks_start() to use last finished row (not first)
- Add Cx2d::align_list_len(), shift_align_entries(), emit_turtle_walk_with_metrics()

draw_text.rs (draw_walk_resumable_with):
- Per-row path: multi-row wrapped text now emits one FinishedWalk per visual row
  with separate glyph instance batches, enabling RowAlign::Center per row
- Between rows: call turtle_new_line_with_spacing to trigger finish_row at each
  visual-row boundary
- Wrapped rows draw glyphs at turtle position (not layouter position) so glyph
  positions stay in sync with turtle tracking when pills inflate row height
- Remove shift_extra_height from allocation (caused turtle/glyph position divergence)

text_flow.rs:
- Fix wrap check to use matches!(Flow::Right { wrap: true, .. }) instead of
  equality against Flow::right_wrap() (broke wrapping with non-Top RowAlign)
- Account for inline_code padding in turtle allocation (fixes overlap bug)

* fix build for Linux / Windows
2026-04-18 10:30:04 +02:00
Kevin Boos
c7d7202551
Html/Markdown fixes: sub/superscript, table outlines/alignment, etc (#1051)
* draw_text: adopt outer many_instances batch on linux/windows

CodeEditor opens an outer raster batch via DrawText::begin_many_instances
before its glyph loop. The linux/windows branch of DrawText::draw_text
ignored self.many_instances and opened its own nested batch, which
resolved (via find_appendable_drawcall) to the same draw_item whose
`instances` Vec was already swapped out by the outer open, panicking on
unwrap in Cx2d::begin_many_instances.

Mirror what the other platform branch (and draw_rasterized_glyphs_abs)
already do: take self.many_instances on entry, track whether the active
raster batch is the outer one, and hand it back on exit so the caller's
end_many_instances finalizes it. Skip the !drew_raster_this_frame area
clear when the outer batch is still live.

* Html/Markdown fixes: sub/superscript, table outlines/alignment, etc

- **Sub/sup in HTML**: added a `y_shift_scales` stack on `TextFlow`, composed onto `temp_y_shift` in `draw_text`. `<sub>` pushes `+0.55`, `<sup>` pushes `-0.2` in html.rs
- **Sub/sup in Markdown**: now handles `MdEvent::InlineHtml` for `<sub>`/`<sup>` (case-insensitive), using the same stacks.
- **Space after `&amp;` (and other entities)**: the HTML lib's entity decoder now resets `last_non_whitespace` after truncate+push, so the whitespace-collapse check no longer drops the next real space.
- **Table column alignment (Markdown)**: `begin_table_cell` takes `align_x: f64`; tracks `Tag::Table` alignments and a per-row column index, passing each cell's `Alignment` through.
- **Table column alignment (HTML)**: `<td>`/`<th>` now honor `align="…"` and inline `style="text-align: …"` via new `cell_align_x` / `align_keyword_to_x` helpers in html.rs.
- **Per-row text alignment plumbing**: new `layout_align` field on `DrawText` is passed to the layouter, which already supports per-row alignment. `TextFlow` propagates a `cell_text_align_x` into it. This is the actual fix that makes cell alignment visible.
- **Wrap-flow alignment scaffolding**: implemented the previously-stubbed `Flow::Right { wrap: true }` branch in the turtle logic. Useful for non-text wrapping walks; text goes through the layouter path above.
- Add examples to uizoo: three new tables in both markdown and html tab -- a plain one, a left/center/right aligned one, and a numeric all-right-aligned one. They cover bold/italic/code/links/sub-sup/emoji/entities/strikethrough inside cells.

* minor cleanup; prefer `style` over `align` HTML tag
2026-04-18 10:29:50 +02:00
Admin
87863fe42f Add Qwen runtime and Windows CUDA build support 2026-04-17 11:44:43 +02:00
Kevin Boos
4a576a7add
draw_text: adopt outer many_instances batch on linux/windows (#1049)
CodeEditor opens an outer raster batch via DrawText::begin_many_instances
before its glyph loop. The linux/windows branch of DrawText::draw_text
ignored self.many_instances and opened its own nested batch, which
resolved (via find_appendable_drawcall) to the same draw_item whose
`instances` Vec was already swapped out by the outer open, panicking on
unwrap in Cx2d::begin_many_instances.

Mirror what the other platform branch (and draw_rasterized_glyphs_abs)
already do: take self.many_instances on entry, track whether the active
raster batch is the outer one, and hand it back on exit so the caller's
end_many_instances finalizes it. Skip the !drew_raster_this_frame area
clear when the outer batch is still live.
2026-04-17 08:25:28 +02:00
Kevin Boos
85fbea9b0f
Switch to SLUG/DrawGlyph font drawing stack (#1042)
* Switch to SLUG/DrawGlyph font drawing stack (via Codex)

* Improve same-frame new glyph caching and SLUG packed instances

* avoid performance regression by batch updating slug atlas cache

* TextFlow: separate SLUG glyph batches to avoid interleaving HTML text drawing

* SLUG optimization: append instead of a full clone each generation

* trying out codex perf fix for linux wayland

* Disable SLUG glyps on Linux for now

* Fix Linux SLUG rendering, warmup, and promotion behavior

- re-enable Linux SLUG through a separate Linux-only DrawText helper
  instead of bloating the normal DrawText shader path
- preserve widget text styling on Linux SLUG by syncing common DrawText
  state into the helper, including base colors, gradients, and interactive
  states such as hover, focus, down, active, pressed, drag, empty, and
  disabled
- keep normal Linux UI text on the raster/MSDF path and only switch to
  SLUG above the Linux cutoff, while still falling back cleanly when SLUG
  data is unavailable
- fix Linux SLUG glyph placement so promoted text uses the correct glyph
  origin, layout position, and atlas packing
- add progressive SLUG warmup on Linux by budgeting glyph generation and
  uploads across redraws and falling back to raster/MSDF until SLUG data
  is actually ready
- lazily register and prewarm the shared Linux SLUG helper so app startup
  and first-use latency stay low
- opt only the Linux SLUG helper into async GL shader compilation and use
  parallel shader compile support when available, avoiding the large
  startup and first-tab stalls seen before
- fix Linux runtime shader issues caused by copied widget text shaders and
  custom get_color logic by using a shared helper shader with the expected
  text-state inputs
- stabilize Linux SLUG promotion by preventing stale retained areas,
  cleaning up raster/helper ownership correctly during draw, and
  shadow-promoting the first ready SLUG frame before making it visible
- eliminate the visible SLUG handoff flicker so Linux text now switches
  from raster/MSDF to SLUG without freezes or noticeable visual artifacts
- add a dedicated UIZoo SLUG tab with side-by-side below-cutoff and
  above-cutoff examples, plus diagnostic cases for plain labels,
  gradients, custom text shaders, and glyph/color probes
- keep the final diff focused by removing unrelated formatting-only churn
  from the worktree during cleanup

* Tighten Linux SLUG promotion and helper sync

- make Linux SLUG promotion state local to each DrawText instance
  instead of using a global per-redraw gate
- cache Linux SLUG helper shader field intersections so helper state
  syncing avoids repeated per-draw allocations and linear membership checks
- harden the shadow-promotion fallback path so failed helper batching
  only takes a single raster fallback path
- preserve DrawText memory alignment after adding Linux SLUG bookkeeping

* try to fix emoji on Android

* emoji fix take 2

* uizoo example: allow touch/drag scroll. Don't panic in FileTree demo

* Fix emoji on Android

* Windows: async HLSL shader compile + extend SLUG helper path to Windows

Fixes two major performance issues on Windows that made uizoo unusable on
first launch:

1. **60-75s startup stall** caused by synchronous `D3DCompile` of ~30+
   SLUG-bearing text shader variants on the UI thread before the window
   could present.
2. **3-4s hang when opening the SLUG tab** caused by synchronous compile
   of the fat DrawTextSlug helper shader the first time a SLUG glyph was
   needed.

Also fixes a latent HLSL-only `CreateInputLayout` E_INVALIDARG crash
triggered by shaders with >26 instance inputs (exposed by DrawTextSlug).

`DrawTextLinuxSlug` → `DrawTextSlug` and all `linux_slug_*` /
`LinuxSlug*` symbols dropped their `Linux` prefix. Cfg guards expanded
from `target_os = "linux"` to `any(target_os = "linux", target_os = "windows")`
so Windows now:

- uses the same lean base `DrawText` shader (SDF/MSDF only, no SLUG
  curve-solver HLSL inlined)
- uses a separate `DrawTextSlug` helper shader for the SLUG path
- has the same progressive glyph-build budget and DPI cutoff
  (`default_slug_new_glyphs_per_redraw`, `default_slug_min_dpxs_per_em`
  in `fonts.rs` now match `OsType::Windows` alongside the Linux variants)
- falls back to raster/MSDF while the SLUG helper shader or its glyph
  data isn't yet ready

macOS/iOS/Android/WASM paths are untouched — they still use the fat
all-in-one `DrawText` shader via `cfg(not(any(linux, windows)))`.

Added `AsyncHlslCompile` in `d3d11.rs` and an `async_hlsl_compile` field
on `CxOs`. Shaders flagged `async_compile: true` (the SLUG helper) now
dispatch to a background thread per shader via `std:🧵:Builder`
(named `hlsl-compile-<id>` for debugging). Workers call `D3DCompile`
off the UI thread, write the resulting DXBC to the on-disk cache, and
send only a status result (not the bytes themselves — SLUG is ~240 KB
and ferrying it through the channel is wasteful) back via an mpsc
channel guarded by a `Mutex`.

`hlsl_compile_shaders` drains completed results at the top of each
call, constructs `CxOsDrawShader` objects on the main thread (the
bytes come from the cache-hit path in `CxOsDrawShader::new`), and
triggers `redraw_all()` so widgets whose shaders just became ready
get re-rendered. The existing `sh.os_shader_id.is_none()` guard in
`render_view` handles skipping the draw call while a shader is
pending.

Added `Cx::is_draw_shader_window_ready()` on Windows for the SLUG
helper's readiness check; on Windows it's simply
`os_shader_id.is_some()` since HLSL compile is either synchronous
(cache hit) or tracked via the async path.

Cold-start still compiled 30+ shaders synchronously (parallelized via
`std:🧵:scope`) which took ~5-10s because FXC's per-call speed is
the bottleneck and parallelism helps less than expected. Now
`hlsl_compile_shaders` partitions queued shaders by cache state:

- cache hit → sync path: disk read + D3D11 object creation, a few ms
- cache miss OR `async_compile: true` → async path: worker thread

On a fully cold cache, every shader is a cache miss → the window
presents on the first frame with no compile work on the UI thread.
Widgets fill in over the next ~1-2s as their shaders become ready.
On a warm cache every shader is a hit → instant startup as before.

Added `shader_bytes_cached()` for cheap existence checking of the
cache entries.

`d3d_compile_hlsl` now passes `D3DCOMPILE_SKIP_OPTIMIZATION`. FXC's
optimizer is what makes individual compiles burn hundreds of ms to
seconds on text shaders with loops; UI shaders don't benefit enough
from it to justify the cold-cache cost. If a specific shader is later
shown to be a runtime hotspot, the fix is to recompile it optimized
on a background thread and hot-swap, not to pay the cost upfront for
every shader.

Bumped `CACHE_KEY_VERSION` to 2 so pre-existing `.dxbc` blobs compiled
with the old flags are invalidated cleanly on upgrade.

`d3d11.rs` used its own `index_to_char(i) = i + 'A'` which produced
invalid HLSL semantic names (`[`, `\`, `]`, …) past 26 inputs, while
the HLSL generator in `shader_hlsl.rs` already used a correct
multi-character scheme (`A..Z, AA..AZ, BA..`). `CreateInputLayout`
returned E_INVALIDARG because the names didn't match. Replaced with
`makepad_script::shader_hlsl::index_to_semantic` so both sides of
the binding agree.

This was a latent bug — no existing shader had >26 instance inputs
until `DrawTextSlug` (which inherits many interactive-state fields
from Label-derived shaders). Linux/GLSL is unaffected because GLSL
binds by identifier, not semantic name.

- Hoisted `d3d_compile_hlsl`, `hlsl_cache_key`, and
  `get_or_compile_shader_bytes` out of `CxOsDrawShader::new` to module
  scope so they can be shared with the async worker.
- Compute `hlsl_cache_key` once per shader during partition and reuse
  at dispatch instead of recomputing.
- Scoped borrows in the async drain loop eliminate mapping/bindings
  clones.

- `platform/src/os/windows/d3d11.rs` — compile pipeline, async infra,
  semantic-name fix
- `platform/src/os/windows/windows.rs` — `async_hlsl_compile` field on
  `CxOs`
- `draw/src/shader/draw_text.rs` — rename `LinuxSlug*` → `Slug*`,
  expand cfg gates
- `draw/src/text/fonts.rs` — extend SLUG cutoff/budget defaults to
  Windows

No changes to macOS, iOS, Android, or WASM paths.
2026-04-16 22:50:48 +02:00
Alex
03a64a9406
fix(code_editor): honour East Asian Wide width in column_count (#1048)
`CharExt::column_count` was hard-coded to return 1 for every char. The
code editor's layouter advances x-position by this value per grapheme
(see `code_editor.rs:1217`), so CJK glyphs — which the text shaper draws
at ~2× the Latin monospace advance — overlap one another. Cursor
placement, selection rectangles, and wrap points suffer the same
off-by-half because they all read from `column_count`.

Match the Unicode East Asian Width property so Wide and Fullwidth
characters (plus common emoji that render at double-width) report 2
columns. Keeps a small literal match table instead of pulling in the
\`unicode-width\` crate, since only broad blocks are needed and perf on
the layout hot path matters.

Ranges covered:
  U+3000..U+30FF   CJK punctuation / Hiragana / Katakana
  U+3400..U+4DBF   CJK Unified Ideographs Extension A
  U+4E00..U+9FFF   CJK Unified Ideographs
  U+AC00..U+D7AF   Hangul Syllables
  U+F900..U+FAFF   CJK Compatibility Ideographs
  U+FF00..U+FF60   Fullwidth forms
  U+FFE0..U+FFE6   Fullwidth sign forms
  U+20000..U+2FFFF CJK Unified Ideographs Extensions B..F
  U+1F300..U+1F9FF Emoticons / symbols / transport / supplemental

Effect: Chinese/Japanese/Korean/emoji in code blocks render with correct
spacing in CodeView (and in any widget that layouts via CodeSession).
Latin-only workflows are unaffected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 22:00:54 +02:00
Kevin Boos
293804f044
Fix iOS touch hit-test and window geometry source (#1047)
Two independent iOS touch-input correctness fixes.

1. Hit-test: stop inflating widget bounds by touch radius

   `Cx::hits_with_options_and_test` was inflating every widget's
   clickable rect outward by `touch.radius` on all four sides, where
   `radius` comes from `UITouch.majorRadius` on iOS (and the Android
   equivalent). On real fingers this is 5-15pt, but the iOS Simulator
   synthesizes mouse clicks as touches with `majorRadius` ~25-40pt, so
   every button in the simulator captured clicks ~30pt outside its
   visible bounds — a click well above or below the Upload Avatar
   button still activated it.

   Remove the inflation in both TouchState::Start and TouchState::Stop
   and hit-test against the touch centroid only. This matches UIKit/
   AppKit native behavior; Apple's HIG and Material Design already
   require touch targets large enough that hidden inflation isn't
   needed. Apps that want explicit hit padding can still pass it via
   `HitOptions::margin`. `touch.radius` is still populated and
   delivered to apps that want it for visual feedback.

   Affected platforms: iOS, Android, web touch. Desktop mouse paths
   (MouseDown/Up) never touched this code.

2. Window geometry: read from MTKView, not UIScreen

   `IosApp::check_window_geom` was reading `inner_size` from
   `UIScreen.mainScreen.bounds`, which describes the *physical screen*
   rather than the app's drawing surface. On iPad Split View, Slide
   Over, Stage Manager, and multi-scene apps the window is a fraction
   of the screen, so `inner_size` could disagree with the MTKView's
   actual bounds — introducing a constant offset between layout and
   the touch coordinate space (UITouch `locationInView:` is always
   view-local).

   Read `bounds`, `contentScaleFactor`, and `safeAreaInsets` from the
   MTKView itself, falling back to UIScreen only during the early-init
   window before the view exists.

   Also use the `size` parameter delivered to
   `mtkView:drawableSizeWillChange:` directly (converting pixels→points
   via `contentScaleFactor` from the same view) rather than re-querying.
   UIKit gives us the authoritative new size synchronously with the
   resize callback; re-reading any other source can race by one layout
   pass during rotation or multitasking transitions.

   Extract the common WindowGeom construction / first-draw / update_geom
   / callback-dispatch tail into `apply_new_window_geom`.
2026-04-16 20:51:13 +02:00
Kevin Boos
93a5a875f3
Fix RTL/bidi text shaping panic and mis-layout (#1046)
Shaping a string containing right-to-left characters (Arabic, Hebrew,
etc.) could panic in shape_recursive with "byte range starts at N but
ends at M", and even when it didn't panic, the surrounding LTR text was
laid out in visually wrong positions.

- shaper: compute fallback byte ranges in shape_recursive in a
  direction-aware way. HarfBuzz emits clusters monotonically in the
  shaping direction (non-decreasing for LTR, non-increasing for RTL),
  so the "next logical cluster" lives visually after the current group
  for LTR and visually before it for RTL. Also collect glyph groups
  into an indexable Vec so we can look both forward and backward, and
  defensively fall back to rendering .notdef tofu rather than
  recursing on an inverted range if HarfBuzz ever produces unexpected
  non-monotonic output.

- shaper: run the Unicode Bidirectional Algorithm via the already-
  present unicode-bidi crate before shaping. Segment each input into
  visual runs with ParagraphBidiInfo, then shape each run in its
  resolved direction and append in visual order. This keeps an RTL
  chunk embedded in LTR text from stomping on the surrounding LTR
  layout. Full RTL support (joining quality, cursor/hit-testing in
  RTL runs) is still not wired up above the shaper.

- shaper: add an is_definitely_ltr fast path that short-circuits BiDi
  entirely when the input contains no characters in any RTL Unicode
  block. Uses str::is_ascii (SIMD) for the common ASCII case and
  falls back to a char-level range check, so ASCII / Latin / Greek /
  Cyrillic / CJK / emoji text pays no BiDi classification or vec-
  allocation cost on cache miss.

- shaper: cache the rustybuzz Feature conversion in shape_step with
  a one-slot content-keyed cache, so repeated shape calls with the
  same feature set (typically empty) don't rebuild the Vec each time.
2026-04-16 20:51:02 +02:00
Admin
6c8d210d9d Clean up MLX backend boundary config 2026-04-16 19:07:04 +02:00
Admin
12a0e1b135 Add CUDA multimodal exact path 2026-04-15 21:33:39 +02:00
Admin
4a562a2b51 Fix CUDA chat repeated prompt loop 2026-04-15 21:33:39 +02:00
Admin
eba806b819 Add CUDA exact chat windowing and kernels 2026-04-15 21:33:39 +02:00
poborin
51056831c5
Add table rendering to TextFlow engine for Markdown and Html widgets (#1032)
Implement streaming-compatible table support in the shared TextFlow
layout engine. Tables render incrementally as events arrive — no
buffering needed since pulldown_cmark provides column count upfront
via the header separator line.

- Add FlowBlockType::TableCell variant with SDF shader for cell
  borders and header backgrounds in DrawFlowBlock base definition
- Add begin/end table, row, and cell methods to TextFlow using
  turtle layout with equal-width column distribution
- Draw cell borders after row layout completes so all cells share
  the row's max height for uniform borders
- Replace 8 TODO stubs in markdown.rs with direct TextFlow calls
- Add table/thead/tbody/tr/th/td tag handling in html.rs with
  column counting via HtmlNode lookahead
- Skip whitespace-only text nodes inside HTML tables using the
  pre-computed all_ws flag
2026-04-15 11:48:14 +02:00
Kevin Boos
6102d4329b
Optimize text layout and PortalList widget performance (#1041)
* Android: fix nondeterminstic crashes when using a bad surface

Makepad apps on Android were randomly crashing within the `Cx::render_view`
callstack when the host Activity surface was recycled, e.g., on
background/foreground transitions, rotation, IME show/hide, etc.

Details of the change are generated below:
----

`SurfaceHolder.Callback.surfaceDestroyed` posted a fire-and-forget message
to the render thread and returned to Android immediately, leaving two
overlapping races:

1. The render thread could drain `SurfaceDestroyed` from the mpsc channel,
   call `destroy_surface()` (which does `eglMakeCurrent(NULL, NULL, NULL,
   NULL)` and nulls the EGL surface), then in the *same* `RenderLoop`
   iteration call `handle_drawing()` → `render_view()` and issue GL calls
   with no current EGL context.
2. Even when our Rust side was well-behaved, Android was free to recycle
   the underlying buffer queue the moment `surfaceDestroyed` returned to
   Java, while the render thread was still mid-frame against it.

**Layer 1 — Surface validity tracking.** Added `CxOs::surface_alive`,
flipped synchronously in `SurfaceCreated`/`SurfaceChanged`/`SurfaceDestroyed`
handlers, plus a `CxOs::has_drawable_surface()` helper that gates every
GL/Vulkan dispatch entry point: `main_loop`'s `handle_drawing()`,
`draw_pass_to_window_for_active_backend`, `draw_pass_to_texture_for_active_backend`,
`draw_pass_to_fullscreen`, and `eglSwapBuffers` in `present_window_for_active_backend`.

**Layer 2 — Synchronous Java↔Rust handshake.** `FromJavaMessage::SurfaceDestroyed`
now carries an `Arc<(Mutex<bool>, Condvar)>` ack channel. The JNI binding
blocks the Android UI thread on the condvar (2-second budget, well under
the 5-second ANR threshold) until the render thread confirms it has
released the surface. This is the same pattern `android.opengl.GLSurfaceView`
uses, and it closes the underlying-buffer-recycled-mid-frame race.

**Layer 3 — Defense-in-depth re-bind.** `draw_pass_to_fullscreen` now calls
a new fallible `try_make_current()` every frame, recovering from any GL
context drift caused by foreign code or our own teardown path, and
bailing cleanly if the bind fails instead of crashing inside the driver.

Verified all 5 gated entry points against the `openxr_render_loop` →
`openxr_handle_repaint` path:

- 4 entry points are unreachable in XR mode (XR uses its own swapchains
  and `xrEndFrame`, never `eglSwapBuffers` or the popup overlay path).
- `draw_pass_to_texture_for_active_backend` IS reachable (off-screen UI
  textures composited into the XR scene). To prevent these from being
  wrongly skipped in Vulkan+XR mode after the host surface is recycled,
  added an explicit XR escape hatch to `has_drawable_surface()`: when
  `in_xr_mode && openxr.session.is_some()`, return `true` based purely on
  `vulkan.is_some()`, ignoring the (intentionally nulled) `display.window`.
  This matches the existing `keep_xr_backend_alive` logic in the
  `SurfaceDestroyed` handler.

- `destroy_surface` is now idempotent (safe to call when already null).
- `make_current` asserts on null surface with a descriptive panic message
  instead of crashing inside EGL.
- Lifecycle handlers verify surface creation actually succeeded before
  flipping `surface_alive` to `true` (no false-positive ready signal).

Tested working on my OnePlus Open w/ Android 15.

-------------
* Other fixes: minor change to logging format to include level indicator
* Fix warning in cargo makepad android

* Fix Android platform errors, mostly no draw on start/resume

Also a small related optimization on iOS

------------

* Android: JNI method ID caching (`ndk_utils.rs`)
  * Rewrote the `call_method!` macro to cache `jmethodID` in a per-call-site `static AtomicPtr`. Previously, every JNI call allocated two `CString`s and called `GetObjectClass` + `GetMethodID` from scratch. Now these are resolved once and reused for all subsequent calls. Also deletes the local class reference after first resolution.

* Android: `to_java_update_tex_image` uses cached macro (`android_jni.rs`)
  * Replaced manual `GetObjectClass`/`CString::new`/`GetMethodID` calls with the now-cached `call_bool_method!` macro, eliminating per-frame JNI overhead for video texture updates.

* Android: Touch event coalescing (`android.rs`)
  * When draining pending messages before a RenderLoop frame, consecutive pure-Move touch events are now coalesced — only the last position is dispatched. Start/Stop events are never dropped. This reduces redundant event dispatch during active touch scrolling.

* Android: Black screen fix (`android.rs`)
  * Added `needs_first_draw` flag to `CxOs`. When a `RenderLoop` callback arrives but the surface isn't drawable, the flag is set. When the surface later becomes available, `redraw_all()` is called to ensure the first frame is painted. The flag is also set on `SurfaceDestroyed` so resuming from background always gets a guaranteed first frame.

* iOS: Remove unnecessary `passes_todo.clone()` (`ios.rs`)
  * Removed a redundant `Vec::clone()` in the popup pass draw loop — both the outer and inner loops borrow `passes_todo` immutably.

* Optimize text layout and PortalList widget performance

Text layout: eliminate redundant HarfBuzz reshaping (layouter.rs)
- Replace `can_fit()` reshaping of cumulative multi-word substrings (always cache misses) with summing pre-computed per-word widths
- Replace `fit()` reshaping with concatenation of cached per-word `ShapedText` results, adjusting cluster offsets
- Cache each word's `Rc<ShapedText>` in the Fitter constructor for reuse

PortalList: O(1) reusable item pool lookup (portal_list.rs)
- Change `reusable_items` from `Vec<WidgetItem>` (O(n) scan + O(n) remove) to `HashMap<LiveId, Vec<WidgetItem>>` (O(1) lookup + O(1) pop)

PortalList: skip touch cursor hit-testing (portal_list.rs)
- Gate `point_hits_interactive_item()` behind `!e.device.is_touch()` to skip expensive recursive widget-tree walk on touch devices where there is no cursor

* undo unnecessary change to inter-word/ligature width estimation

avoid problems with words'/ligatures' kerning
2026-04-15 11:47:47 +02:00
Kevin Boos
e66d1041cd
Fix emojis in Html by removing broken font loading optimization (#1044)
The font_member_is_needed_for_text() optimization pre-scanned text content
to decide whether to skip loading CJK/emoji font members. This was broken
in two ways:

1. The hardcoded Unicode ranges in is_emoji_char() were incomplete (e.g.,
   missing U+2B50 ), so emoji glyphs silently failed to render.

2. When members were skipped, font_ids.len() never matched
   expected_member_count, so is_font_family_complete() never returned true
   for i18n font families. This caused every draw call to hit the slow
   path — the opposite of the optimization's intent.

Fix: load all font family members unconditionally. The one-time load cost
is negligible, and the fast path (is_font_family_complete → early return)
now works correctly on subsequent draws.

Removed the now-unused helpers: font_member_is_needed_for_text(),
is_cjk_fallback_font_path(), is_emoji_fallback_font_path(),
resource_basename(), text_has_cjk(), is_cjk_char(), text_has_emoji(),
is_emoji_char(), and FontFamily::ensure_fonts_loaded_for_text().
2026-04-15 10:59:49 +02:00
Kevin Boos
007bf000a1
Ensure .notdef glyph for unsupported characters is visibly drawn (#1045)
* Fix emojis in Html by removing broken font loading optimization

The font_member_is_needed_for_text() optimization pre-scanned text content
to decide whether to skip loading CJK/emoji font members. This was broken
in two ways:

1. The hardcoded Unicode ranges in is_emoji_char() were incomplete (e.g.,
   missing U+2B50 ), so emoji glyphs silently failed to render.

2. When members were skipped, font_ids.len() never matched
   expected_member_count, so is_font_family_complete() never returned true
   for i18n font families. This caused every draw call to hit the slow
   path — the opposite of the optimization's intent.

Fix: load all font family members unconditionally. The one-time load cost
is negligible, and the fast path (is_font_family_complete → early return)
now works correctly on subsequent draws.

Removed the now-unused helpers: font_member_is_needed_for_text(),
is_cjk_fallback_font_path(), is_emoji_fallback_font_path(),
resource_basename(), text_has_cjk(), is_cjk_char(), text_has_emoji(),
is_emoji_char(), and FontFamily::ensure_fonts_loaded_for_text().

* Ensure .notdef glyph for unsupported characters is visibly drawn

When a character isn't in any font in the family (e.g., 🫪 U+1FAEA not in
the bundled NotoColorEmoji), the shaper exhausts all fallback fonts and
emits glyph id 0 (.notdef) from the last font tried. For bitmap-only fonts
like NotoColorEmoji (no glyf table), the .notdef has no outline, so the
rasterizer returns None and draw_glyph silently skips it — leaving
invisible blank space.

Fix: in shape_recursive, when all fallback fonts are exhausted and glyphs
remain unmapped (id == 0), reassign them to the primary font (IBM Plex
Sans), whose .notdef has visible contours (the standard "tofu" rectangle).
This is done at the point of emission rather than as a post-processing
scan, so the common case (all glyphs found) has zero overhead.
2026-04-15 09:28:21 +02:00
Kevin Boos
0136260f6a
Fully proper fix for black screen on Android start/resume (#1043)
* Another proper fix for black screen on Android start/resume

* Fix all Android surface artifacts on pause, resume, and cold start

* cleanup: remove java-level logging in MakepadActivity

* additional cleanup/improvements for android lifecycle stuff
2026-04-15 08:22:35 +02:00
Kevin Boos
3cf602a432
Windows: fix perf and flicker/stretch artifacts when resizing a window (#1036)
* WIP fixing performance issue on Windows OS when resizing app window

* Improved resize behavior (flickering/stretching) on Windows

* cleanup window-resize optimizations for Windows OS
2026-04-13 21:51:22 +02:00
Kevin Boos
113ab5c6ad
Fix Android platform errors, mostly no draw on start/resume (#1035)
* Android: fix nondeterminstic crashes when using a bad surface

Makepad apps on Android were randomly crashing within the `Cx::render_view`
callstack when the host Activity surface was recycled, e.g., on
background/foreground transitions, rotation, IME show/hide, etc.

Details of the change are generated below:
----

`SurfaceHolder.Callback.surfaceDestroyed` posted a fire-and-forget message
to the render thread and returned to Android immediately, leaving two
overlapping races:

1. The render thread could drain `SurfaceDestroyed` from the mpsc channel,
   call `destroy_surface()` (which does `eglMakeCurrent(NULL, NULL, NULL,
   NULL)` and nulls the EGL surface), then in the *same* `RenderLoop`
   iteration call `handle_drawing()` → `render_view()` and issue GL calls
   with no current EGL context.
2. Even when our Rust side was well-behaved, Android was free to recycle
   the underlying buffer queue the moment `surfaceDestroyed` returned to
   Java, while the render thread was still mid-frame against it.

**Layer 1 — Surface validity tracking.** Added `CxOs::surface_alive`,
flipped synchronously in `SurfaceCreated`/`SurfaceChanged`/`SurfaceDestroyed`
handlers, plus a `CxOs::has_drawable_surface()` helper that gates every
GL/Vulkan dispatch entry point: `main_loop`'s `handle_drawing()`,
`draw_pass_to_window_for_active_backend`, `draw_pass_to_texture_for_active_backend`,
`draw_pass_to_fullscreen`, and `eglSwapBuffers` in `present_window_for_active_backend`.

**Layer 2 — Synchronous Java↔Rust handshake.** `FromJavaMessage::SurfaceDestroyed`
now carries an `Arc<(Mutex<bool>, Condvar)>` ack channel. The JNI binding
blocks the Android UI thread on the condvar (2-second budget, well under
the 5-second ANR threshold) until the render thread confirms it has
released the surface. This is the same pattern `android.opengl.GLSurfaceView`
uses, and it closes the underlying-buffer-recycled-mid-frame race.

**Layer 3 — Defense-in-depth re-bind.** `draw_pass_to_fullscreen` now calls
a new fallible `try_make_current()` every frame, recovering from any GL
context drift caused by foreign code or our own teardown path, and
bailing cleanly if the bind fails instead of crashing inside the driver.

Verified all 5 gated entry points against the `openxr_render_loop` →
`openxr_handle_repaint` path:

- 4 entry points are unreachable in XR mode (XR uses its own swapchains
  and `xrEndFrame`, never `eglSwapBuffers` or the popup overlay path).
- `draw_pass_to_texture_for_active_backend` IS reachable (off-screen UI
  textures composited into the XR scene). To prevent these from being
  wrongly skipped in Vulkan+XR mode after the host surface is recycled,
  added an explicit XR escape hatch to `has_drawable_surface()`: when
  `in_xr_mode && openxr.session.is_some()`, return `true` based purely on
  `vulkan.is_some()`, ignoring the (intentionally nulled) `display.window`.
  This matches the existing `keep_xr_backend_alive` logic in the
  `SurfaceDestroyed` handler.

- `destroy_surface` is now idempotent (safe to call when already null).
- `make_current` asserts on null surface with a descriptive panic message
  instead of crashing inside EGL.
- Lifecycle handlers verify surface creation actually succeeded before
  flipping `surface_alive` to `true` (no false-positive ready signal).

Tested working on my OnePlus Open w/ Android 15.

-------------
* Other fixes: minor change to logging format to include level indicator
* Fix warning in cargo makepad android

* Fix Android platform errors, mostly no draw on start/resume

Also a small related optimization on iOS

------------

* Android: JNI method ID caching (`ndk_utils.rs`)
  * Rewrote the `call_method!` macro to cache `jmethodID` in a per-call-site `static AtomicPtr`. Previously, every JNI call allocated two `CString`s and called `GetObjectClass` + `GetMethodID` from scratch. Now these are resolved once and reused for all subsequent calls. Also deletes the local class reference after first resolution.

* Android: `to_java_update_tex_image` uses cached macro (`android_jni.rs`)
  * Replaced manual `GetObjectClass`/`CString::new`/`GetMethodID` calls with the now-cached `call_bool_method!` macro, eliminating per-frame JNI overhead for video texture updates.

* Android: Touch event coalescing (`android.rs`)
  * When draining pending messages before a RenderLoop frame, consecutive pure-Move touch events are now coalesced — only the last position is dispatched. Start/Stop events are never dropped. This reduces redundant event dispatch during active touch scrolling.

* Android: Black screen fix (`android.rs`)
  * Added `needs_first_draw` flag to `CxOs`. When a `RenderLoop` callback arrives but the surface isn't drawable, the flag is set. When the surface later becomes available, `redraw_all()` is called to ensure the first frame is painted. The flag is also set on `SurfaceDestroyed` so resuming from background always gets a guaranteed first frame.

* iOS: Remove unnecessary `passes_todo.clone()` (`ios.rs`)
  * Removed a redundant `Vec::clone()` in the popup pass draw loop — both the outer and inner loops borrow `passes_todo` immutably.
2026-04-13 21:51:09 +02:00
Kevin Boos
ccecc172e9
Avoid double-shutdown Event dispatch on WIndows (#1034)
Windows was issuing two shutdown events any time the window was closed,
so it no longer does that. Might've been my fault in a previous change,
not sure.
2026-04-13 21:50:55 +02:00
Admin
c9ada286a3 cuda otw 2026-04-13 11:09:08 +02:00
Admin
56d948d62f cuda exact chat reuse and decode tuning 2026-04-13 11:09:08 +02:00
Admin
7b81d7ef6b wip cuda long-context profiling 2026-04-13 11:05:49 +02:00
Admin
ceb1e384e8 cuda prefill metrics and chunked kernels 2026-04-13 11:05:49 +02:00
Admin
1ab6e6ed27 cuda exact prefill to ~87 tok/s 2026-04-13 11:05:49 +02:00
Admin
4b801ae74e Add FLUX warm pipeline and reference benchmarks 2026-04-13 11:01:13 +02:00
Admin
dc3baaf69d flux 1 works 2026-04-13 11:01:13 +02:00
Admin
3335c26f27 rotor quant for metal 2026-04-13 11:01:13 +02:00
Admin
93c013707a Document Rotor divergence against BF16 baseline 2026-04-13 11:01:13 +02:00
Admin
cc480e274e Add optional Rotor-style K-cache compression for Gemma 4 2026-04-13 11:01:13 +02:00
alanpoon
985cb4adb6
audio_input_panic_fix for macos (#1038) 2026-04-11 13:52:25 +02:00
Admin
b262f7bac3 Add CUDA NVFP4 backend and GPU decode path 2026-04-11 11:44:04 +02:00
Admin
d7520e837f mlx image opt 2026-04-11 09:51:37 +02:00
Admin
f1a2bec527 fix sutdio 2026-04-10 22:06:51 +02:00
Admin
3a90346eaa fix 2026-04-10 21:14:32 +02:00
Admin
5e7955770e mlx multimodal 2026-04-10 15:10:25 +02:00
Admin
4bb2190ea8 tabs 2026-04-10 14:48:17 +02:00
Admin
b12af23a7a mlx optimisations 2026-04-10 14:48:17 +02:00
Admin
ffc578c243 mlx working 2026-04-10 14:48:17 +02:00
Admin
32612cfe88 cleanup 2026-04-10 14:48:17 +02:00
Admin
c925d2a56b mlx otw 2026-04-10 14:48:17 +02:00
Admin
747ef9b2e8 mlx 5x->2x 2026-04-10 14:46:22 +02:00
Admin
db323ee42e mlx 2026-04-10 14:46:22 +02:00
Admin
fc5d960062 mlx otw 2026-04-10 14:46:22 +02:00
Admin
087952a638 mlx otw 2026-04-10 14:46:22 +02:00
Kevin Boos
126e848063
Android: fix nondeterminstic crashes when using a bad surface (#1030)
Makepad apps on Android were randomly crashing within the `Cx::render_view`
callstack when the host Activity surface was recycled, e.g., on
background/foreground transitions, rotation, IME show/hide, etc.

Details of the change are generated below:
----

`SurfaceHolder.Callback.surfaceDestroyed` posted a fire-and-forget message
to the render thread and returned to Android immediately, leaving two
overlapping races:

1. The render thread could drain `SurfaceDestroyed` from the mpsc channel,
   call `destroy_surface()` (which does `eglMakeCurrent(NULL, NULL, NULL,
   NULL)` and nulls the EGL surface), then in the *same* `RenderLoop`
   iteration call `handle_drawing()` → `render_view()` and issue GL calls
   with no current EGL context.
2. Even when our Rust side was well-behaved, Android was free to recycle
   the underlying buffer queue the moment `surfaceDestroyed` returned to
   Java, while the render thread was still mid-frame against it.

**Layer 1 — Surface validity tracking.** Added `CxOs::surface_alive`,
flipped synchronously in `SurfaceCreated`/`SurfaceChanged`/`SurfaceDestroyed`
handlers, plus a `CxOs::has_drawable_surface()` helper that gates every
GL/Vulkan dispatch entry point: `main_loop`'s `handle_drawing()`,
`draw_pass_to_window_for_active_backend`, `draw_pass_to_texture_for_active_backend`,
`draw_pass_to_fullscreen`, and `eglSwapBuffers` in `present_window_for_active_backend`.

**Layer 2 — Synchronous Java↔Rust handshake.** `FromJavaMessage::SurfaceDestroyed`
now carries an `Arc<(Mutex<bool>, Condvar)>` ack channel. The JNI binding
blocks the Android UI thread on the condvar (2-second budget, well under
the 5-second ANR threshold) until the render thread confirms it has
released the surface. This is the same pattern `android.opengl.GLSurfaceView`
uses, and it closes the underlying-buffer-recycled-mid-frame race.

**Layer 3 — Defense-in-depth re-bind.** `draw_pass_to_fullscreen` now calls
a new fallible `try_make_current()` every frame, recovering from any GL
context drift caused by foreign code or our own teardown path, and
bailing cleanly if the bind fails instead of crashing inside the driver.

Verified all 5 gated entry points against the `openxr_render_loop` →
`openxr_handle_repaint` path:

- 4 entry points are unreachable in XR mode (XR uses its own swapchains
  and `xrEndFrame`, never `eglSwapBuffers` or the popup overlay path).
- `draw_pass_to_texture_for_active_backend` IS reachable (off-screen UI
  textures composited into the XR scene). To prevent these from being
  wrongly skipped in Vulkan+XR mode after the host surface is recycled,
  added an explicit XR escape hatch to `has_drawable_surface()`: when
  `in_xr_mode && openxr.session.is_some()`, return `true` based purely on
  `vulkan.is_some()`, ignoring the (intentionally nulled) `display.window`.
  This matches the existing `keep_xr_backend_alive` logic in the
  `SurfaceDestroyed` handler.

- `destroy_surface` is now idempotent (safe to call when already null).
- `make_current` asserts on null surface with a descriptive panic message
  instead of crashing inside EGL.
- Lifecycle handlers verify surface creation actually succeeded before
  flipping `surface_alive` to `true` (no false-positive ready signal).

Tested working on my OnePlus Open w/ Android 15.

-------------
* Other fixes: minor change to logging format to include level indicator
* Fix warning in cargo makepad android
2026-04-10 08:13:14 +02:00
Kevin Boos
92006e12e3
Batch/buffer tooltip hover in/out actions to avoid flicker (#1029) 2026-04-10 08:12:50 +02:00
Ruben Daniels
ebe1cab1b7
fix(base64): handle single '=' padding in base64_decode (#1027)
The decoder only checked for double padding ('==') at input[len-2],
subtracting 1 output byte. Single padding ('=') at input[len-1] was
not handled, leaving 1 extra garbage byte in the decoded output.

This affected 2 out of 3 input lengths (any input where len % 3 == 2),
producing decoded output 1 byte longer than expected.

Fix: check input[len-1] for '=' first (subtract 1 byte), then check
input[len-2] for '=' (subtract another byte for double padding).

Added 7 roundtrip tests covering: no padding (3n bytes), single
padding (3n+2 bytes), double padding (3n+1 bytes), empty input,
lengths 1-20, all 256 byte values, and URL-safe alphabet.

Co-authored-by: prime intellect <prime@prime-intellects-Mac-Studio.local>
2026-04-09 20:05:45 +02:00
Kevin Boos
3d81877bf4
Avoid re-entrant borrows of the IOS_APP global (#1025)
* Avoid re-entrant borrows of the IOS_APP global

I noticed this was happening any time the IME on iOS was used,
so I restructured those usages of IOS_APP to avoid them.
I then noticed that it could happen in other places, so I refactored
those as well.

* Fix missing iOS plist entry
2026-04-09 14:45:37 +02:00
Kevin Boos
d12fa28a8e
Packaging directory-related fixes (for robius-packaging-commands to work) (#1024)
* Packaging directory-related fixes (to allow robius-packagin-commands to work)

* use proper resource loading for macOS packaged apps via NSBundle
2026-04-09 14:45:23 +02:00
Kevin Boos
7c25ded861
Flow right wrap fix (#1023)
* Fix `flow: Right` with `wrap: true`

This tiny math bug was causing widgets that got wrapped to the next line
in a `Flow: Right { wrap: true}` view to not get properly drawn
(the left side would get cut off).

* Expose `wrap_spacing` in `Layout` and splash script

This allows you to set the vertical spacing between widgets when
they wrap to the next line in a Right wrap flow layout.
2026-04-08 07:36:19 +02:00
Kevin Boos
7b5b05fb29
Fix mouse wheel scrolling on Linux Wayland (#1022)
Now the tab bar will scroll using all four mouse wheel directions
even on Wayland.
2026-04-08 07:36:04 +02:00
Kevin Boos
2374359993
Fix key repeat timer issue, and Event::Shutdown delivery (#1020)
Key repeat (holding down a key) did not work at all on Linux Wayland. The `RepeatInfo` event from the compositor was commented out, and all key events had `is_repeat: false` hardcoded.

- **`xkb_sys.rs`**: Added `xkb_state_get_keymap` FFI binding and a `key_repeats()` method on `XkbState` to check whether a key supports repeat (e.g., modifiers don't repeat).
- **`wayland_state.rs`**:
  - Added `KeyRepeatState` struct and `KEY_REPEAT_TIMER_ID` constant.
  - Added `key_repeat_rate`, `key_repeat_delay`, and `key_repeat` fields to `WaylandState`.
  - Handled the previously-ignored `RepeatInfo` event to store the compositor's repeat rate/delay.
  - On key press: start a one-shot timer with the repeat delay if the key supports repeat.
  - On key release / keyboard leave: cancel the repeat timer.
  - Added `handle_key_repeat_timer()` which fires `KeyDown(is_repeat: true)` and `TextInput` events, transitioning from the initial delay to a steady-state repeating timer.
- **`wayland_app.rs`**: Intercept the key repeat timer ID in the event loop and route it to `handle_key_repeat_timer()` instead of sending a generic `Timer` event.

- **`raw_input.rs`**: The evdev backend already received `KeyAction::KeyRepeat` from the OS but hardcoded `is_repeat: false` and didn't emit `TextInput` events. Fixed both.

- **`select_timer.rs`**: Fixed a pre-existing bug in `stop_timer` where removing a timer from the delta chain didn't adjust the successor's `delta_timeout`. This caused successor timers to fire early by the removed timer's delta. Also changed `update_timers` to use `pop_front()` instead of `stop_timer()` internally, since `select_time_used` already accounts for the removed timer's delta.

--------------

On Linux Wayland (and several other platforms), Makepad apps never received
`Event::Shutdown` when the window was closed via the client-side decoration
close button or when the app called `cx.quit()`.

When the CSD close button is clicked, it pushes `CxOsOp::CloseWindow`, which
is processed by `handle_platform_ops()`. When the last window is removed (or
`CxOsOp::Quit` is handled), this function returns `EventFlow::Exit`. However,
most backends did **not** call `Event::Shutdown` before exiting — only macOS
did it correctly.

Added `call_event_handler(&Event::Shutdown)` in the `handle_platform_ops() → Exit`
path for all affected backends, matching the existing macOS behavior:

- **Linux Wayland** (`linux_wayland.rs`) — added Shutdown call
- **Linux X11** (`linux_x11.rs`) — added Shutdown call
- **Windows** (`windows.rs`) — added Shutdown call
- **Linux Direct** (`linux_direct.rs`) — added Shutdown call
- **OpenHarmony** (`open_harmony.rs`) — added Shutdown call after main loop exit
  (this backend uses `self.os.quit` instead of `EventFlow::Exit`)

- **macOS** — already correct
- **Android** — Shutdown is delivered via `FromJavaMessage::Destroy`
- **iOS / tvOS / Web** — different lifecycle models where explicit shutdown
  doesn't apply (suspended by OS, or no reliable browser mechanism)
2026-04-08 07:35:47 +02:00
Kevin Boos
5e7d2a45a2
cargo-makepad: support builds that need cmake/bindgen (Android/iOS) (#1019)
* cargo-makepad: support proper NDK builds (and on iOS)

The newly-emerging `aws-lc-rs` crate is quite popular and is
gradually replacing `ring`, which means we need to support it,
which is especially difficult to get right on Android and iOS targets.

This changeset makes it really easy to build that crate as part of
your app, if desired. There's no cost to apps that don't use it.

Android: make the stripped NDK installation the default, and make the
`full-ndk` option actually install the FULL NDK, not just the full set
of prebuilts. Like the whole thing, including build tooling like cmake
and other libraries.

* cargo-makepad: make install-toolchain for android more robust

Overwrite an existing installation instead of erroring out
2026-04-08 07:35:29 +02:00
Kevin Boos
cc60726b35
Support scrolling while centered, both vertically and horizontally (#1017)
* Support scrolling while centered, both vertically and horizontally

The previous turtle logic didn't allow you to center-align a view
while still making it scrollable. This small fix supports that now,
meaning that you can have:
* a vertically-centered view (`Align: { y: 0.5 }`) that is y-scrollable
* a horizontally-centered view (`Align: {x: 0.5 }`) that is x-scrollable

Also added some simple examples of this to `uizoo`

* fix iOS build
2026-04-07 09:32:47 +02:00
Kevin Boos
3e0330b239
Smooth scroll the tab bar to the selected tab, if not visible (#1016)
* Dock: add touch support for Tab/Tab bar interactions (scroll, drag/drop)

Details below:

1. **Finger-based tab drag-and-drop via long press** (tab.rs, android.rs, ios.rs):
   - On touch devices, tab dragging now requires a long press before moving,
     distinguishing it from scroll gestures.
   - Added internal drag-and-drop support for Android and iOS backends,
     synthesizing Drag/Drop/DragEnd events from touch move/up, matching the
     existing Linux X11/Wayland approach.

2. **Finger-based drag-scrolling through the tab bar** (tab.rs, tab_bar.rs):
   - A finger down + move (without long press) on a tab now scrolls the tab bar
     horizontally instead of initiating a tab drag.
   - Includes flick-to-scroll with velocity and decay for natural momentum.
   - Touch tab selection is deferred to finger-up and only fires on a clean tap
     (no long press, no scroll gesture), so scrolling/dragging doesn't
     accidentally select tabs.

3. **Horizontal scroll input for tab bar** (scroll_bar.rs):
   - When `use_vertical_finger_scroll` is enabled on a horizontal scroll bar,
     both horizontal (trackpad) and vertical (mouse wheel) scroll inputs are
     accepted, so trackpad users can scroll the tab list in either direction.

* fix drag/drop on macOS by using internal drag logic.

Fix ghost tab on dock to be much cleaner in terms of behavior

* Cleanup dock tab drag&drop behavior

Make platforms consistent. Switch macOS to internal drag item tracking
instead of OS-native (just for the dock for now).

Ensure ghost tab that gets drawn is consistently hidden (instantly)
upon being dropped in an invalid target zone.

* Smooth scroll the tab bar to the selected tab, if not visible

This animation does a lot to help the user track where tabs are.
Previously, without this, tabs would get lost in a lengthy tab bar
because you could select a tab and not realize which one was selected
as there was no visual indication that a non-visible tab was chosen.
This was especially strange when a new tab is programmatically selected,
as you couldn't tell where you were in the tab bar.

Now, if you select a tab that is far beyond the visible bounds of the
dock tab bar, it will auto-scroll to it with a smooth animation.
Also, if you click on a tab that is partially visible, it'll scroll
just enough to make that tab fully within the tab bar view.
Basically it's just like any IDE's tab bar now.
2026-04-06 23:00:23 +02:00
Kevin Boos
fa68f930fa
Show "ghost" dock tab during drag animation. Fix drag-n-drop rules, target zones, etc (#1015)
* Dock: add touch support for Tab/Tab bar interactions (scroll, drag/drop)

Details below:

1. **Finger-based tab drag-and-drop via long press** (tab.rs, android.rs, ios.rs):
   - On touch devices, tab dragging now requires a long press before moving,
     distinguishing it from scroll gestures.
   - Added internal drag-and-drop support for Android and iOS backends,
     synthesizing Drag/Drop/DragEnd events from touch move/up, matching the
     existing Linux X11/Wayland approach.

2. **Finger-based drag-scrolling through the tab bar** (tab.rs, tab_bar.rs):
   - A finger down + move (without long press) on a tab now scrolls the tab bar
     horizontally instead of initiating a tab drag.
   - Includes flick-to-scroll with velocity and decay for natural momentum.
   - Touch tab selection is deferred to finger-up and only fires on a clean tap
     (no long press, no scroll gesture), so scrolling/dragging doesn't
     accidentally select tabs.

3. **Horizontal scroll input for tab bar** (scroll_bar.rs):
   - When `use_vertical_finger_scroll` is enabled on a horizontal scroll bar,
     both horizontal (trackpad) and vertical (mouse wheel) scroll inputs are
     accepted, so trackpad users can scroll the tab list in either direction.

* fix drag/drop on macOS by using internal drag logic.

Fix ghost tab on dock to be much cleaner in terms of behavior

* Cleanup dock tab drag&drop behavior

Make platforms consistent. Switch macOS to internal drag item tracking
instead of OS-native (just for the dock for now).

Ensure ghost tab that gets drawn is consistently hidden (instantly)
upon being dropped in an invalid target zone.
2026-04-06 23:00:13 +02:00
Kevin Boos
f2c02e878e
Add ellipsis text truncation support (text_overflow + max_lines) (#1014)
* Add ellipsis text truncation support (text_overflow + max_lines)

Re-implement ellipsis truncation for text that overflows its container,
following conventions from CSS (text-overflow), Android (TextOverflow), and
Flutter (TextOverflow). This was supported in Makepad 1.0 but removed in 2.0.

Full summary below:

-----------------------

- Add `max_rows: Option<usize>` and `ellipsis: bool` fields to `LayoutOptions`
- Implement `apply_ellipsis_truncation()` post-processing step that:
  - Detects when text was truncated (by max_rows or single-line overflow)
  - Shapes the "…" (U+2026) glyph using the same font family
  - Removes trailing glyphs from the last visible row to make room
  - Trims trailing whitespace before the ellipsis for clean appearance
  - Appends the ellipsis glyph(s) to the last row
  - Recalculates the text bounding box
- Add early-exit in `layout_by_word()` and `layout_by_grapheme()` when
  `max_rows` is exceeded, avoiding unnecessary layout work for long texts
- Add `is_truncated: bool` field to `LaidoutText` for consumer detection
- Fix pre-existing bugs in `LayoutOptions` Hash/PartialEq: `wrap` and
  `line_spacing_scale` were missing, which could cause stale cache hits

- Add `TextOverflow` enum with `Clip` (default) and `Ellipsis` variants
- Add `max_lines: usize` and `text_overflow: TextOverflow` live properties
  on `DrawText`, passed through to `LayoutOptions`
- Register `TextOverflow` in the script module for DSL access
- Resolve `Fit` width max bounds when ellipsis/max_lines is active, so
  text layout knows the width constraint even for Fit-sized containers

- **Label** (widgets/src/label.rs): Add top-level `max_lines` and
  `text_overflow` properties, forwarded to `draw_text` in `draw_walk()`
- **TextFlow** (widgets/src/text_flow.rs): Same top-level properties,
  forwarded before each text draw call
- **Html / Markdown**: Inherit from TextFlow via `#[deref]` automatically
- Add `..mod.text` to widget prelude (widgets/src/lib.rs) so `Ellipsis`
  and `Clip` are accessible in all widget DSL

- Make `FitBound::eval_width()` and `eval_height()` public (draw/src/turtle.rs)
  so DrawText can resolve Fit max bounds during layout

```rust
// Simple single-line ellipsis
Label {
    width: Fill
    max_lines: 1
    text_overflow: Ellipsis
    text: "Long text gets truncated…"
}

// Multi-line with ellipsis
Label {
    width: Fill
    max_lines: 3
    text_overflow: Ellipsis
    text: "Wraps up to 3 lines, then truncates…"
}

// Also works via draw_text for any widget with DrawText
Button {
    draw_text +: { max_lines: 1, text_overflow: Ellipsis }
}
```

See the demos I newly added to the `uizoo` example too.

* Fix TextFlow widget-level ellipsis for multi-run styled text (like Html)

The per-run forwarding of max_lines/text_overflow to DrawText caused each
styled run (bold, italic, etc.) to independently truncate with its own
ellipsis, producing double "……" artifacts in Html/Markdown content.

- Add `lines_drawn` and `content_truncated` fields to track visual lines
  across all text runs within a single TextFlow
- Compute per-run `max_rows` based on remaining visual lines instead of
  blindly forwarding the widget's `max_lines` to every DrawText call
- Handle continuation runs (starting mid-line) correctly: they get +1
  row allowance since their first row shares the current visual line
- Skip further text runs once a run reports `is_truncated` (ellipsis drawn)
- Skip non-continuation runs when no visual lines remain

- `draw_walk_resumable_with` now returns `(usize, bool)`: row count and
  whether the layout was truncated, so TextFlow can track state across runs

- Add three Html ellipsis examples: 1-line, 2-line styled, and emoji+styled
2026-04-06 16:29:24 +02:00
Kevin Boos
ab006d0e25
Dock: add touch support for Tab/Tab bar interactions (scroll, drag/drop) (#1013)
Details below:

1. **Finger-based tab drag-and-drop via long press** (tab.rs, android.rs, ios.rs):
   - On touch devices, tab dragging now requires a long press before moving,
     distinguishing it from scroll gestures.
   - Added internal drag-and-drop support for Android and iOS backends,
     synthesizing Drag/Drop/DragEnd events from touch move/up, matching the
     existing Linux X11/Wayland approach.

2. **Finger-based drag-scrolling through the tab bar** (tab.rs, tab_bar.rs):
   - A finger down + move (without long press) on a tab now scrolls the tab bar
     horizontally instead of initiating a tab drag.
   - Includes flick-to-scroll with velocity and decay for natural momentum.
   - Touch tab selection is deferred to finger-up and only fires on a clean tap
     (no long press, no scroll gesture), so scrolling/dragging doesn't
     accidentally select tabs.

3. **Horizontal scroll input for tab bar** (scroll_bar.rs):
   - When `use_vertical_finger_scroll` is enabled on a horizontal scroll bar,
     both horizontal (trackpad) and vertical (mouse wheel) scroll inputs are
     accepted, so trackpad users can scroll the tab list in either direction.
2026-04-06 16:29:13 +02:00
Kevin Boos
e2cf5592c9
TextInput: support single-line horizontal scrolling when text overflows (#1012)
* TextInput: support single-line horizontal scrolling when text overflows

When a single-line TextInput's text content is wider than its visible area
(due to Fill, Fixed, or parent-constrained Fit width), the text now
automatically scrolls horizontally to keep the cursor visible.

- Add `scroll_x` tracking with auto-scroll-to-cursor logic
- Push a clip rect for all TextInput modes (not just multiline) to prevent
  text from bleeding outside widget bounds
- Layout single-line text without max_width constraint so overflow is
  detectable via `size_in_lpxs.width`
- Handle mouse wheel/trackpad scroll events for single-line horizontal
  scrolling (maps both axes to horizontal)
- Account for `scroll_x` in IME position and selection rect calculations
- Add `Turtle::set_width()` and `Cx2d::compute_max_width_from_ancestors()`
  (width counterparts to existing height methods)
- Add UIZoo examples: Fill width, Fixed width, and Fit-in-container

* a bit more cleanup, no need for clip size to be an option
2026-04-06 16:28:35 +02:00
Admin
c9cabd2554 remove logs 2026-04-05 11:36:11 +02:00
Admin
3968a4aaba stdin 2026-04-05 11:30:53 +02:00
Admin
0f0ef763d6 llama works 2026-04-05 11:30:53 +02:00
Admin
d8883d1783 llama works atleast 2026-04-05 11:30:53 +02:00
Admin
73544cbf60 churn 2026-04-05 11:28:47 +02:00
Admin
641a158ec5 churn 2026-04-05 11:28:47 +02:00
Admin
25df7febe0 driveable 2026-04-05 11:28:47 +02:00
Admin
c6906d2674 driveable 2026-04-05 11:28:47 +02:00
Admin
a2f032ecc7 drivable 2026-04-05 11:28:47 +02:00
Admin
a21f8f480c drivable 2026-04-05 11:28:47 +02:00
Admin
26cd960c48 iterating 2026-04-05 11:28:47 +02:00
Admin
a9d6172f8c otw 2026-04-05 11:28:47 +02:00
Kevin Boos
17eb90842d
TextInput: explicitly support multiline mode with parent-relative max height (#1011)
* TextInput: support multiline mode with proper scrolling

* ScrollBar integration with mouse wheel, scrollbar handle drag,
  and the correct way of dealing with `handled_y` propagation,
  which basically means that scrolling can be handled by the TextInput
  as a child, or not and left to the parent.
* Only auto-scroll to the cursor when it actually moves, not on every redraw
* `is_multiline` controls wrapping too, which makes more sense.
   Now a single-line mode TextINput shouldn't wrap the text
* Allow setting `text` in the Splash DSL (make it `#[live]`)

Also added some examples to the uizoo demo: empty, pre-filled, read-only, toggle
between multi and single line.

* minor cleanup for TextInput multiline/scrolling

* Explicitly support multiline TextInput with Relative max height bounds

* more cleanup

* TextInput: support cascading parent-relative max height bounds

This was needed in Robrix, specifically a fairly common case in which
a TextInput should expand to Fit its content, but should not exceed
a certain percentage of the height of its parent.

This builds on the initial relative min/max Fit bounds that Eddy added
a while back, but weren't directly handled by TextInput, whcih itself
has special demands because it has to start internally
wrapping/scrolling.

* fix merge artifacts
2026-04-03 19:17:15 +02:00
Kevin Boos
b869208cea
TextInput: support multiline mode with proper scrolling (#1010)
* TextInput: support multiline mode with proper scrolling

* ScrollBar integration with mouse wheel, scrollbar handle drag,
  and the correct way of dealing with `handled_y` propagation,
  which basically means that scrolling can be handled by the TextInput
  as a child, or not and left to the parent.
* Only auto-scroll to the cursor when it actually moves, not on every redraw
* `is_multiline` controls wrapping too, which makes more sense.
   Now a single-line mode TextINput shouldn't wrap the text
* Allow setting `text` in the Splash DSL (make it `#[live]`)

Also added some examples to the uizoo demo: empty, pre-filled, read-only, toggle
between multi and single line.

* minor cleanup for TextInput multiline/scrolling

* Explicitly support multiline TextInput with Relative max height bounds

* more cleanup
2026-04-03 19:09:48 +02:00
Kevin Boos
a00ae30dd0
TextInput: reflow text upon widget resize (width changes) (#1009)
Fixes a minor bug in which the entered text within a TextInput widget
would not get its layout recalculated if the width changed, meaning
that text could get cutoff on the right side of the widget.

Now we ensure that the text layout gets re-done (and the cached value
is not incorrectly used) if the width has changed since the last layout.
2026-04-03 11:30:25 +02:00
Kevin Boos
ce7da33f1d
Fix PortalList smooth scroll to handle all possible cases, and support scroll offset (#1008)
* Fix PortalList smooth scroll once and for all

* more cleanup, another small fix
2026-04-02 10:16:23 +02:00
Kevin Boos
1cda404284
Fix behavior of PortalList::at_end() to be fully correct (#1007)
* Fix behavior of `PortalList::at_end()` to be fully correct

This has been buggy for a long time, now it is flawless.

* cleanup, add some clarifying comments
2026-04-02 08:31:33 +02:00
Kevin Boos
10c33aca95
Fix text wrapping breaking before trailing punctuation marks (#1006)
This was causing punctuation marks to wrap to the next line
by themselves instead of sticking with the previous word,
but that looks really strange/wrong.

---------------

Unicode word boundary segmentation (UAX#29) treats punctuation as
separate segments from words, causing the text layouter to wrap
punctuation like `.` `,` `;` `)` to a new line by itself.

Added `merge_segments_for_line_breaking()` that post-processes word
boundary segments before width measurement, following standard
line-breaking conventions (UAX#14 / CSS Text Module Level 3):
- Trailing/closing punctuation (`. , : ; ! ? ) ] }` etc.) merges into
  the preceding segment (no break before).
- Opening punctuation (`( [ {` etc.) merges into the following segment
  (no break after).
- Consecutive punctuation chains correctly (e.g., `):` stays with the
  preceding word).
2026-04-02 08:31:20 +02:00
Kevin Boos
7bcacc33f8
Properly animate caption bar when entering/exiting macOS fullscreen (#1005)
add handlers for "will enter/exit fullscreen" instead of just handling
"did" enter/exit, in order to properly animate. Otherwise it looks
janky for a split second where the traffic light buttons are on top of
the old app content before it refreshes.
2026-04-02 08:31:10 +02:00
Kevin Boos
0f2939945c
Expose window chrome button bounding box in WindowGeom (#1001)
* Expose window chrome button bounding box in `WindowGeom`

This allows apps that wanna draw something in the title/caption bar
to do so in a proper way without potentially drawing over the native
window chrome buttons (on macOS, the traffic light buttons).
Without this it'd be pretty tough to figure that out.

This also auto-sets the caption bar height to be tall enough such that
the window chrome / traffic light buttons are perfectly vertically-centered
in the middle of the caption bar. This was needed on macOS to prevent
things from looking janky as hell on newer macOS versions, which changed
the default size of the traffic chrome buttons.
It'll also be useful for drawing things in the caption bar on linux
or windows too.

Full change set:

- `widgets/src/window.rs`: Hide the caption bar on `LinuxWindow` when
  `!custom_window_chrome` (X11 — WM provides native decorations) directly in
  `sync_caption_bar_state`, removing the need for apps to do this manually.
- `event/window.rs`: Add `window_chrome_buttons: Rect` to `WindowGeom` —
  the bounding box of the OS/app-drawn window chrome buttons in logical window
  coordinates (top-left origin). Non-zero on macOS (traffic lights), Windows
  (min/max/close), and Wayland with `custom_window_chrome`. Zero on all other
  platforms (X11, LinuxDirect, mobile, web). Documented with per-platform
  details and guidance on how to use it for caption-bar layout margins.
- `macos_window.rs`: Add `traffic_lights_geom()` — queries all three
  traffic-light buttons via `standardWindowButton:`, converts their frames
  to Makepad's coordinate system via `convertRect:fromView:`, and returns
  the bounding box as a `Rect`.
- `win32_window.rs`: Populate `window_chrome_buttons` with the right-aligned
  138×29 px bounding box of the three Makepad-drawn caption buttons.
- `linux_wayland.rs`: Populate `window_chrome_buttons` in the
  `WindowGeomChange` handler when `custom_window_chrome: true`, using the
  same right-aligned 138×29 px layout.
- `cx_api.rs`: Add `update_caption_bar_height_script_value()` to push a
  measured height into `mod.widgets.CAPTION_BAR_HEIGHT` on the script heap.
- `window.rs` (DSL): Declare `mod.widgets.CAPTION_BAR_HEIGHT = 27.0` in the
  `script_mod!` block and change `caption_bar.height` from the hardcoded `27`
  to `(mod.widgets.CAPTION_BAR_HEIGHT)`. On `WindowGeomChange`, derive the
  default caption bar height from `window_chrome_buttons` using equal
  top/bottom padding (`pos.y * 2 + size.y`) and trigger a script reapply.
- `platform/src/lib.rs`: Export `LinuxWindowParams`, `WindowGeom`,
  and `SafeAreaInsets`.

* Fix calculation of title bar height based on buttons

ensure dynamic override actually propagates via Rust code

* clearly define system-calculated caption bar height vs manual override

* Fix window drag move bounds to match the caption bar area

remove excess debug logs
2026-04-01 23:11:37 +02:00
Admin
4fc9a06b06 variable weight fonts 2026-04-01 12:19:03 +02:00
Admin
3f2baab26d xr llama and slug 2026-04-01 12:19:03 +02:00
Admin
cb3b36d241 llama + xr 2026-04-01 12:19:03 +02:00
Admin
95c822450b fixup 2026-04-01 12:19:03 +02:00
Admin
f6cf4e5c13 refactor 2026-04-01 12:19:02 +02:00
Admin
5f20c2b6da xr works 2026-04-01 12:19:02 +02:00
Admin
c8280ee338 testing x client 2026-04-01 12:19:02 +02:00
Admin
1c7835fca9 working emitters 2026-04-01 12:19:02 +02:00
Admin
96bbe681d8 otw 2026-04-01 12:19:02 +02:00
Admin
e343aa00d4 xr networking 2026-04-01 12:19:02 +02:00
Admin
fcc003814e refactor 2026-04-01 12:19:02 +02:00
Kevin Boos
02704b27a9
PortalList: fix drag scrolling over widgets that handle events/hits (#1002)
Previously, PortalList's drag scrolling straight up didn't work
when the initial FingerDown event (touch/tap/click) landed on a widget
that is "interactive", meaning it could handle events. Not sure when that
concept was introduced, but it's kinda flawed given that all widgets
just defaulted to being `true` (always interactive). But imo that goes
against the ethos of simple event handling based on ordering of calls to
`handle_event()`, not to mention the whole `capture_overload` thing.

I think this is the solution that we've always wanted. The PortalList itself
now tracks when it is scrolling (and only starts a scroll once it is sure
enough finger/mouse movement has occurred, `TAP_COUNT_DISTANCE`),
and it does not deliver these interactive events to child widgets
while it is scrolling. This will make things a lot easier for the app dev too,
since that's how iOS and Android work too.

Details of changes to `portal_list.rs`:

- Always enter `ScrollState::Drag` on FingerDown regardless of whether
  the touch point is over an interactive widget. A `committed` flag and
  `drag_scroll_threshold` (defaulting to `TAP_COUNT_DISTANCE`) gate
  when scroll deltas actually apply, preventing micro-scrolling during
  taps/clicks on interactive items.
- Suppress event forwarding to child widgets once a drag scroll commits
  (finger moves past threshold), so children don't receive stale
  interaction events during scrolling.
- Suppress event forwarding when a finger-down/click arrives while a
  scroll animation (flick, pulldown, etc.) is in progress, so tapping
  to stop a scroll doesn't also activate a child widget.
- Add configurable `drag_scroll_threshold` property to PortalList.
2026-04-01 08:40:06 +02:00
Kevin Boos
507bcf35d1
Choose sane defaults for platform-specific title/caption bar config (#1000)
Primarily on Linux, ensure that we show the title/caption bar
and draw it within Makepad (i.e., client-side drawing) if the
DE/WM doesn't show it by default.
This should make things behave as expected on Linux X11 and Wayland
both.
2026-04-01 00:42:29 +02:00
Kevin Boos
3a6499b70d
Fix title bar, captions label centering, and windows buttons (#999)
no app-level overrides are needed now.

1. on Windows, the windows buttons now behave and are drawn
   just like all other apps -- proper bg coloring on hover and down,
   and the right sizing.

2. and for the caption label, it is centered properly (by accounting
   for the size of the windows_buttons button set), and then when the
   window is too narrow, it is left-aligned in the remaining space
   to ensure that it stil looks good.
2026-03-31 08:58:06 +02:00
Kevin Boos
f99dee329c
Remove busy-wait loops on Linux (x11 and wayland) (#998)
Tested working with Robrix and a few makepad examples
2026-03-31 08:57:51 +02:00
Kevin Boos
191ac72bf9
Introduce knowledge of device screen bounds/"safe inset areas" (#990)
* Introduce knowledge of device screen bounds/cutous/"safe inset areas"

Tested working on iOS, implemented for Android but not yet tested.

The approach may need to be improved, because it currently restricts
the whole app window to being fully within the safe areas.
We may not necessarily want that, or if we do, then we probably also
need to support setting the base color of the reserved system areas
(beneath the app bounds and above in the notification bar area).

* Use metal scissor rect to prevent SVGs/icons from being mis-drawn in safe areas

This prevents anything from being accidentally drawn in the safe
inset areas when the pass clear_color is transparent. Of course,
we can still draw the pass clear_color in those areas.

* Workaround: apply a scissor rect within safe inset area

Only apply it to clip any DrawSvg/DrawVector-specific draw calls
within the safe inset area.

This is unfortunately still just a hacky solution, because if we
actually do want to draw svg/vectors within that safe inset area,
then we won't be able to.

* Properly fix gpu artifacts when rendering SVGs

The `DrawSvg` vertex shader had a GPU fringe expansion pass designed for `fill_gpu()` mode, where fringe vertices encode per-vertex normals in the `v` and `stroke_dist` fields. SVG rendering used `fill_gpu()`, which produces **coincident-vertex fringe triangles** (body and outer fringe at the same CPU position, expanded on the GPU). These zero-area triangles caused **Metal GPU rasterization artifacts** — stray fragments appearing at unexpected screen positions.

**`draw/src/svg/render.rs`** — Switch SVG fill from `fill_gpu()` to `fill()`. Pre-computed fringe produces vertices at physically different positions (no coincident vertices).

**`draw/src/shader/draw_svg.rs`** — Remove the GPU fringe expansion code from the vertex shader. With pre-computed fringe, the `v` and `stroke_dist` fields are constants (`1.0` and `0.0`), not per-vertex normals. The expansion code was misinterpreting `v=1.0` as a horizontal normal, corrupting vertex positions.

**`libs/apple_sys/src/lib.rs`** — Added `MTLScissorRect` struct (unused now but available for future use).

**`src/home/rooms_sidebar.rs`** — Changed shadow offset from `vec2(1.0, 0.0)` to `vec2(0.0, 10.0)` so the `RoundedShadowView` shadow only draws below the header, eliminating the gray line at the top of the screen (issue 1).

* Expose safe area inset padding to app, don't forcibly apply it to root window

* Fixed safe area insets padding, with support for rotation

We now make these values available to the app dev (see below)
instead of forcibly inserting them as padding on all root windows.

This will allow each app to choose how and when they want to apply said pad values
(or if they want to at all) in an easy way, both at the Splash level
or more dynamically/programmatically at the Rust level.

Required quite a few changes to how things work in the iOS platform plumbing,
also described below in the generated summary:

On iOS and Android, Makepad apps render content behind device cutouts (Dynamic Island, camera notch), home indicators, and rounded screen corners because the framework has no awareness of safe area insets.

Added platform-level safe area inset querying on iOS and Android, exposed the values through both the Splash DSL (`mod.widgets.SAFE_INSET_PAD_*`) and Rust (`cx.display_context.safe_area_insets`), and ensured they update correctly on device rotation.

**New types:**
- `UIEdgeInsets` struct in `libs/apple_sys` for Objective-C interop
- `SafeAreaInsets` struct in `platform/src/event/window.rs` (top/right/bottom/left in logical points)
- Added `safe_area_insets` field to `WindowGeom` and `DisplayContext`

**iOS (`platform/src/os/apple/ios/`):**
- Query `[UIView safeAreaInsets]` from the MTKView in `check_window_geom()`
- Added `viewSafeAreaInsetsDidChange` callback on `MakepadViewController` to detect inset changes on rotation
- Populate `display_context` before `Event::Startup` so values are available during app script initialization
- Fixed MTKView setup: removed redundant `addSubview:` (conflicted with `setRootViewController:`) and added autoresizing mask — both required for safe area propagation on rotation

**Android (`platform/src/os/linux/android/`):**
- Added `SafeAreaInsets` variant to `FromJavaMessage` and corresponding JNI function
- Java side (`ResizingLayout.onApplyWindowInsets`): queries `WindowInsets.Type.systemBars() | displayCutout()` and sends insets to Rust (converted from px to dp)
- Added `safe_area_insets` field to `CxOs`, populated on `SafeAreaInsets` message and included in `WindowGeom` construction
- Added `surfaceOnSafeAreaInsets` native method to `MakepadNative.java`

**Splash DSL variables (`widgets/src/lib.rs`):**
- `mod.widgets.SAFE_INSET_PAD_TOP`
- `mod.widgets.SAFE_INSET_PAD_BOTTOM`
- `mod.widgets.SAFE_INSET_PAD_LEFT`
- `mod.widgets.SAFE_INSET_PAD_RIGHT`
- Values read from `display_context` at widget module initialization (during `Event::Startup`)
- Updated on the script heap via `Cx::update_safe_inset_script_values()` on `WindowGeomChange`

**Rotation support:**
- Added `pending_script_reapply` flag on `Cx` — set when safe area insets change, checked at the end of the platform event loop iteration
- Fires a deferred `LiveEdit` event to re-evaluate and re-apply all Splash widget definitions with updated inset values
- Implemented in both iOS and Android event loops

**StackNavigationView fix (`widgets/src/stack_navigation.rs`):**
- Full-screen stack views now position at `max(safe_area_insets.top, parent_rect.pos.y)` instead of hardcoded `y: 0`, respecting both mobile safe areas and desktop title bars

**All other platforms:**
- Added `..Default::default()` to all `WindowGeom` constructors (macOS, Windows, Linux X11/Wayland/Direct, web, tvOS, OpenHarmony) so the new `safe_area_insets` field defaults to zeros
2026-03-31 08:57:36 +02:00
Admin
b5f2562768 cleanup 2026-03-30 09:54:53 +02:00
Admin
966c8fda96 cleanup 2026-03-30 09:52:31 +02:00
Admin
2bb0fe88cc cleanup 2026-03-30 09:22:56 +02:00
Admin
27220ca062 lz4 opt 2026-03-30 01:06:33 +02:00
Admin
b99ce4fe48 missing 2026-03-30 00:55:55 +02:00
Admin
7fb8f420a4 lz4 wire protocol 2026-03-30 00:55:33 +02:00
Admin
cfd39ecc72 cleanup 2026-03-29 23:33:47 +02:00
Admin
a0732c853c cleanup 2026-03-29 23:21:47 +02:00
Admin
eddabcf8ca cleanup 2026-03-29 22:57:11 +02:00
Admin
d0785c5212 cleanup 2026-03-29 22:53:22 +02:00
Admin
ebcf0d58ac cleanup 2026-03-29 22:47:40 +02:00
Admin
3a29497923 optimize 2026-03-29 22:12:33 +02:00
Admin
bc70aa5ed7 optimisations 2026-03-29 21:46:25 +02:00
Admin
f8ffda732a optimisations 2026-03-29 21:36:19 +02:00
Admin
0fdc86b528 optimisations 2026-03-29 21:01:06 +02:00
Admin
d83941cf7a optimisations 2026-03-29 20:27:52 +02:00
Admin
4056a99c14 optimisations 2026-03-29 18:46:50 +02:00
Admin
afeadc8650 optimisations 2026-03-29 18:42:48 +02:00
Admin
ac3ed67fe6 optimisations 2026-03-29 18:32:46 +02:00
Admin
fcc98d6d69 fixing tracing 2026-03-29 18:12:16 +02:00
Admin
3e7fb23e95 fixing tracing 2026-03-29 18:06:14 +02:00
Admin
cf1b38f9d0 deptmap tweaks 2026-03-29 15:47:27 +02:00
Admin
d596d15d9e depth align trying 2026-03-29 11:10:52 +02:00
Admin
c352bf75e8 clean maps lock 2026-03-28 22:56:30 +01:00
Admin
efb78e542f refactor 2026-03-28 21:45:46 +01:00
Admin
bc64ac1164 fix refactor 2026-03-28 21:45:27 +01:00
Admin
ebdd3fe530 optimising 2026-03-28 20:11:51 +01:00
Admin
a495095c9a optimising 2026-03-28 20:03:37 +01:00
Admin
0cc533660e optimising 2026-03-28 19:56:09 +01:00
Admin
cf0b9d0153 working refactor 2026-03-28 18:46:43 +01:00
Admin
282a9a34e2 working refactor 2026-03-28 17:58:49 +01:00
Admin
29ca115f14 actually working alignment 2026-03-28 16:36:27 +01:00
Admin
5112d37ebd cleanup 2026-03-28 13:22:52 +01:00
Admin
2819866999 contour map 2026-03-28 12:03:05 +01:00
Admin
0088feb734 contour map 2026-03-28 12:02:56 +01:00
Admin
83fd90015f auto alignment working 2026-03-27 16:31:02 +01:00
Admin
83a9fa2017 xr room mapping 2026-03-27 13:51:54 +01:00
Admin
a517b9cb42 fix xr UIs 2026-03-26 15:25:55 +01:00
Admin
d2853799f9 wrist ui 2026-03-26 15:25:55 +01:00
wyenox
6da8ad2f23
fix openxr compile error on non-vulkan android builds (#989) 2026-03-26 08:25:25 +01:00
Kevin Boos
afbca7d466
Additional opptimizations for windows shader compilation (#988)
* Additional opptimizations for windows shader compilation

`hlsl_compile_shaders` was called unconditionally after every draw event, even on
frames where no new shaders needed compilation. Added an early return:

```rust
if self.draw_shaders.compile_set.is_empty() {
    return;
}
```

The loop previously collected `compile_set` into a temporary `Vec` before
iterating, in order to release the borrow on `compile_set` so the loop body
could mutate other `draw_shaders` fields. This caused a heap allocation and a
full copy of all indices on every compilation batch.

`std::mem::take` atomically replaces `compile_set` with an empty `BTreeSet` and
returns ownership of the original — no intermediate allocation, no copy, and no
separate `.clear()` needed at the end:

```rust
let compile_set = std::mem::take(&mut self.draw_shaders.compile_set);
for draw_shader_id in compile_set { ... }
// no .clear() needed
```

Previously `shader_cache_dir()` was an inner function called inside
`CxOsDrawShader::new`, meaning it ran once **per shader** on every compilation.
Each call performs two syscalls: `env::var("LOCALAPPDATA")` and
`fs::create_dir_all`. With N shaders compiling on first launch, this was 2N
unnecessary syscalls.

`shader_cache_dir()` is now a module-level function called **once** before the
loop in `hlsl_compile_shaders`, and the resulting `Option<&Path>` is passed into
`new` as a parameter.

The HLSL source already lives in `cx_shader.mapping.code`. The previous code
cloned it into an owned `String` before passing it to `new`, even though all
downstream uses (hashing, `D3DCompile`, cache I/O, error printing) only need a
`&str`. Changed the parameter type to `&str` and restructured the loop body into
a block scope so the immutable borrow on `cx_shader` ends before the mutable
reborrow — eliminating the clone entirely.

`CxOsDrawShader::new` already took `&UniformBufferBindings` by reference. The
clone existed only because the immutable borrow on `cx_shader` had to be released
before the mutable reborrow. The same block-scope restructuring from fix #4
resolves this: `&cx_shader.mapping.uniform_buffer_bindings` is now passed
directly.

This field was written once on construction and **never read** — the only reader
was the O(n) deduplication scan removed in the previous round of fixes. It held
a full copy of each shader's HLSL source for the entire lifetime of the
application. At tens of KB per shader and dozens of shaders, this was megabytes
of permanently retained dead storage. The field is gone.

* More shader optimizations on windows

Properly get the Local AppData directory instead of using the
env var %LOCALAPPDATA, which may not always be there.
Now we do it with `SHGetKnownFolderPath(FOLDERID_LocalAppData)`,
which is canonically correct.

We also cache the directory path itself.
2026-03-26 08:25:01 +01:00
Admin
788d7a42c4 xr test 2026-03-26 00:32:24 +01:00
Kevin Boos
0d13952005
Cache shader compilation on windows to avoid long UI hangs (#987)
On Windows, a large app like Robrix freezes for 10–20+ seconds after login while sync begins.
Profiling (`sc.user_aux.etl` from Visual Studio Performance Profiler) showed:

| Module | Exclusive CPU samples | % of total |
|---|---|---|
| `d3dcompiler_47.dll` | 24,239 | **76.76%** |
| `robrix.exe` | 2,715 | 8.60% |

A single thread (TID 20308) consumed **27.9 seconds of CPU** over the 35-second trace.
Every other robrix thread combined used under 2 seconds.

The butterfly call graph confirmed: `robrix.exe → d3dcompiler_47.dll` with 25,337
inclusive hits (80.24%). The UI was blocked the entire time.

In `makepad/platform/src/os/windows/windows.rs`, the main Win32 event loop calls:

```rust
if self.need_redrawing() {
    self.call_draw_event(time_now);
    self.hlsl_compile_shaders(&d3d11_cx);  // blocks here
}
```

`hlsl_compile_shaders` iterates over every shader in `compile_set` and calls
`CxOsDrawShader::new`, which calls `D3DCompile` (from `d3dcompiler_47.dll`)
**synchronously on the UI thread** for each unique shader. After login, many
new UI panels render for the first time, flooding `compile_set`. `D3DCompile`
is a full software HLSL→DXBC compiler with no OS-level cache — it is CPU-bound
and cannot yield.

This affects all makepad apps on Windows, not just Robrix.

**File changed:** `makepad/platform/src/os/windows/d3d11.rs`

Added a disk-based shader bytecode cache so that `D3DCompile` is only called
once per unique shader source, on first launch. Subsequent launches load the
pre-compiled DXBC bytecode directly, skipping `D3DCompile` entirely.

**Specific changes:**

1. `CxOsDrawShader` struct: changed `pixel_shader_blob` and `vertex_shader_blob`
   field types from `ID3DBlob` to `Vec<u8>`. These fields were stored but never
   read after construction, so there is no behavioral difference.

2. `compile_shader` (inner fn): changed return type from `ID3DBlob` to `Vec<u8>`,
   copying the blob bytes out before returning.

3. Three new inner helper functions added to `CxOsDrawShader::new`:
   - `hlsl_cache_key(hlsl: &str) -> u64` — FNV-1a 64-bit hash of the HLSL
     source string, stable across Rust versions, used as the cache key.
   - `shader_cache_dir() -> Option<PathBuf>` — resolves
     `%LOCALAPPDATA%\makepad\d3d11_shader_cache\`, creating it if needed.
     Returns `None` gracefully if `LOCALAPPDATA` is unset or the directory
     cannot be created, in which case compilation proceeds as before.
   - `get_shader_bytes(...)` — checks for a cached `<hash>_vs.dxbc` /
     `<hash>_ps.dxbc` file; on a cache miss, compiles via `D3DCompile` and
     writes the result to disk before returning.

- **First launch:** all shaders compile as before; each VS/PS blob is written to
  `%LOCALAPPDATA%\makepad\d3d11_shader_cache\<hash>_vs.dxbc` and `<hash>_ps.dxbc`.
- **Subsequent launches:** bytecode is read from disk; `CreateVertexShader` /
  `CreatePixelShader` / `CreateInputLayout` are called directly with the cached
  bytes — `D3DCompile` is never invoked.
- **Cache invalidation:** the cache key is the FNV-1a hash of the HLSL source,
  so entries automatically become stale (and are recompiled + re-cached) whenever
  the shader source changes.
- **Failure safety:** file I/O errors are silently ignored — a failed write means
  the cache is just skipped next time, and a failed read falls through to
  recompilation.
2026-03-26 00:03:59 +01:00
Admin
014732ecf7 optimising quest renderpath with physics 2026-03-25 23:29:41 +01:00
Admin
f868b8f001 optimising quest renderpath with physics 2026-03-25 22:44:16 +01:00
Admin
b15a346d8a optimising quest renderpath with physics 2026-03-25 22:44:16 +01:00
Admin
78296f515b optimising quest renderpath with physics 2026-03-25 22:44:16 +01:00
Admin
2820fa6bf5 optimising quest renderpath with physics 2026-03-25 22:44:16 +01:00
Admin
5e04f6fb06 optimising quest renderpath with physics 2026-03-25 22:44:16 +01:00
Admin
b6fa66ae41 optimising quest renderpath with physics 2026-03-25 22:44:16 +01:00
Kevin Boos
66075ff67f
Improve font parsing and text drawing perf with a hybrid caching approach (#986)
* fix windows build by adding missing consts to windows-rs

* Improve font parsing and text drawing perf with a hybrid caching approach

* Reset rustybuzz face cache when cloning FontFace

* fix improper row decorations, back to working properly
2026-03-25 21:28:50 +01:00
Kevin Boos
8583c07a84
fix windows build by adding missing consts to windows-rs (#984) 2026-03-25 21:28:39 +01:00
Admin
988d28505c foveation 2026-03-25 18:20:38 +01:00
Admin
6431464aa1 better physics 2026-03-25 17:39:00 +01:00
Admin
d997a1b05a multiview 2026-03-25 17:38:59 +01:00
Admin
7a102e5cf4 finally 2026-03-25 17:38:59 +01:00
Admin
0fc2114f61 finally runs 2026-03-25 17:31:52 +01:00
Admin
3f7b0a1bac xr otw 2026-03-25 17:31:52 +01:00
Sabin Regmi
0cff3fd7f6
ft makepad_test (#974)
* ft makepad_test

* Improve run handling, manifest parsing, and stdout newline

Replace dynamic free-port lookup with an ephemeral localhost SocketAddr in test runtime and remove the unused find_free_listen_address helper. Ensure headless stdout messages end with a newline. Simplify send_to_app error handling and add a test that queued bootstrap messages are delivered once an app socket connects. Substantially enhance process_manager: unify cargo flag parsing, parse Cargo.toml to determine package/bin targets, resolve the correct binary name for direct stdio runs, and build the cargo/build+exec script from the resolved args. Add unit tests for manifest parsing and script generation and adjust related call sites.

* test harness

* Preserve test attrs; return Vec for gateway binds

In the test macro (libs/makepad_test/macros/src/lib.rs) preserve wrapper-only attributes (ignore and should_panic) on the generated wrapper test while removing them from the inner function. Added Attribute import, is_wrapper_only_test_attr helper, adjusted attribute filtering and emission, and added unit tests to verify attribute placement and expansion.

In the hub (studio/hub/src/hub.rs) change gateway_bind_candidates to return a Vec<SocketAddr> instead of an iterator and special-case ephemeral port 0 to preserve ephemeral binding; otherwise collect the range of candidate ports into a Vec. Added tests to validate candidate behavior. Also minor formatting/whitespace tweaks and a small IPv6 formatting adjustment.

* Add visible Studio mode and remote client

Enable running UI tests visibly through a running Makepad Studio. Adds a new makepad-network dependency and studio_remote client (libs/makepad_test/src/studio_remote.rs) and integrates it into the runtime via a TestConnection enum. Introduces visible-mode tooling: env vars (MAKEPAD_TEST_VISIBLE, MAKEPAD_TEST_STUDIO, MAKEPAD_TEST_STUDIO_MOUNT, MAKEPAD_TEST_STARTUP_DELAY_MS, MAKEPAD_TEST_ACTION_DELAY_MS, MAKEPAD_TEST_KEEP_OPEN_MS), pacing/delays after actions, and pause-before-shutdown. Splits startup into start_headless_app/start_visible_app, clears existing visible builds before launching, and updates tests, docs (GUIDE.md, README.md), and selector/runtime minor cleanups/formatting.
2026-03-25 16:09:03 +01:00
Sabin Regmi
e309f9c34e
Redraw overlay draw_list on open/close (#978)
Explicitly redraw the overlay widget draw_list when opening and closing Modal and PopupNotification. This makes the overlay visible immediately on the first open (before the overlay content has refreshed or established a reusable draw area) and ensures the previous frame isn't left visible too long on close. Keeps existing background redraws intact.
2026-03-25 15:17:41 +01:00
Sabin Regmi
5fc97da197
Introduce UI constants and tweak studio layout (#983)
* Introduce UI constants and tweak studio layout

Consolidate and adjust studio UI sizing, spacing and styling across desktop widgets.

Key changes:
- studio/desktop/src/app_ui.rs: Add STUDIO_HEADER_HEIGHT and StudioDock; use constant for various header/caption heights; reorganize PaneToolbar into grouped Views, adjust spacing/margins, refine caption label styling, and apply custom draw_text/draw_bg for tabs.
- studio/desktop/src/desktop_file_tree.rs: Add STUDIO_FILE_TREE_ROW_HEIGHT and STUDIO_FILE_TREE_NODE_HEIGHT; switch hardcoded row/node heights to constants, adjust padding and pass node_height to FileTree; update DesktopFileTree::ROW_HEIGHT.
- studio/desktop/src/desktop_log_view.rs: Increase LogEmptyItem height, adjust padding/alignment and label text style; update DesktopLogView::EMPTY_ROW_HEIGHT.

Why: unify header/row sizing via constants, improve spacing and visual consistency, and centralize small styling tweaks for easier future adjustments.

* Add TerminalCloseableTab and TerminalAddTab

Define two new tab templates in app_ui.rs: TerminalCloseableTab (closeable terminal tab style with custom close button sizing and colors) and TerminalAddTab (compact "+" add-tab style with centered text and custom background/hover colors). Wire them into the StudioDock templates and switch the terminal_add DockTab to use TerminalAddTab. Update app_backend.rs to insert terminal tabs using TerminalCloseableTab instead of the generic CloseableTab.

* Standardize UI border colors and add draw_bg style

Replace explicit border color values with theme.color_u_hidden for border_color and border_color_2, and set active borders to a subtle bg tint (theme.color_bg_app * 0.92) to reduce visual prominence. Add a new draw_bg style block (with colors and the same hidden/active border settings) and apply the same border adjustments to related UI elements to unify border appearance across the app UI.

* Refactor and restyle app UI components

Add new reusable styles and refine layout/spacing in app_ui.rs. Introduces LogToolbarToggle and SidebarFilterInput styles and applies them to the log toolbar and file tree filter respectively, tightening visual consistency. Adds StudioTerminalView with padding and uses it in TerminalPane. Adjusts several sizes and spacings (log filter width 232 -> 216, clear button 24 -> 20, spacing 6 -> 4, adds 10px spacer View), and updates paddings for buttons. Refactors caption toggles into a shared CaptionChromeToggle (size/icon_walk/border_radius/colors tweaked) and defines CaptionSidebarToggle/CaptionPanelToggle as specializations that set their SVG icons. These changes are primarily visual/layout polish and consolidation of repeated style definitions for maintainability.

* Adjust close button size, margin and colors

Refine the close button styling: add a close_button block and standardize its size to 11x11, update margins (left: 1.0, right: 7.0) and unify draw_button colors/hover/active states to updated hex values. These tweaks align spacing and visual states across tab elements for a cleaner, more consistent UI.

* Adjust app background and empty text colors

Tweak background state multipliers for theme.color_bg_app to slightly brighter values (color: 0.82, hover: 0.88, focus: 0.92, down: 0.85, empty: 0.82 vs previous 0.78/0.86/0.9/0.82/0.78). Also switch empty text colors from theme.color_label_outer_off to theme.color_label_inner_inactive (hover matches), leaving color_empty_focus as theme.color_label_outer. These changes improve contrast and visual consistency for empty and interactive app UI states.
2026-03-25 15:17:23 +01:00
Sabin Regmi
43ba50e59a
Small Studio UX Nits (#982)
* Add animated sidebar toggle and splitter APIs

Add an animated, persistent sidebar toggle and supporting splitter APIs/UI.

- New icon resource: studio/desktop/resources/icons/icon_sidebar_toggle.svg
- UI: replace hidden caption bar with a visible caption that includes a sidebar toggle button (CaptionSidebarToggle), layout adjustments and related controls.
- App behavior: add SidebarAnimation struct and App fields to track animation state and next-frame. Handle button actions, next-frame stepping, and window drag queries to avoid initiating window drag over the toggle.
- Add App methods to query/set the workspace root splitter position, start/step sidebar animations, toggle sidebar (remember/restore width), and sync tab-bar visibility for mounts.
- Persist mount sidebar restore width by adding sidebar_restore_width to MountState and initializing default.
- Make save_state pub(super) so App can save when animation finishes.
- Widgets: expose splitter position and set_splitter_align on Dock and Splitter to allow programmatic width changes and redrawing.

These changes improve UX by providing a smooth animated sidebar hide/show, remembering user width per mount, and ensuring the dock UI updates correctly during mount/tab changes.

* Persist sidebar_restore_width in mount state

Add an Option<f64> sidebar_restore_width to PersistedMountStateRon and wire it through loading and collection so the sidebar width is saved and restored. Include tests to verify a round-trip serialization/deserialization preserves the value and that missing legacy data defaults to None for backward compatibility.

* Sync run preview splitter state

Hide and restore the Run preview column based on whether run tabs exist. Added run_panel_split_restore to AppData to remember the last editor_split ratio per mount, plus a helper (run_preview_splitter_is_collapsed) and a new method sync_run_preview_splitter that collapses the preview when there are no runs and restores the previous ratio when runs reappear (defaults to Weighted(0.62)). Called sync_run_preview_splitter from relevant places: after creating/ensuring run tabs, when clearing build tabs, when closing run tabs, and at startup to initialize each mount. Also added /.cocoindex_code/ to .gitignore.

* Add bottom panel toggle with animation

Introduce a bottom panel toggle UI and animation support. Adds a new icon resource and CaptionPanelToggle button in the caption bar, moves/adjusts caption layout, and wires the button to toggle the bottom panel. Adds a TerminalShellPane, bottom_terminal_tab, and integrates bottom_panel_tabs into the workspace layout. Implements BottomPanelAnimation, animation helpers (panel_animation_progress, start/step logic), workspace splitter height getters/setters, and toggle/select helpers (toggle_bottom_panel, select_bottom_terminal_panel). Persists mount bottom_panel_restore_height in MountState and persisted state with tests updated. Also updates drag/tab behavior to account for the new bottom terminal tab and routes animation next-frame events.

* Refactor reflow_resize and add wrap test

Rewrite reflow_resize to simplify reflow logic and improve handling of growing/shrinking rows and scrollback. The change always captures the old grid state, builds logical (re-wrapped) scrollback lines, and then branches on whether the new height is greater, less, or equal to the old height. On growth it may pull rows from the logical scrollback when the terminal was full, preserve content when a custom scroll region is present, and correctly update cursor, high-water, saved cursor and bottom_trimmed_rows. On shrink it chooses how many bottom rows to push into scrollback based on cursor position, trimmed rows, and content below the cursor, and trims scrollback to max_scrollback. Also adjust cursor bounds and pending_wrap handling. Add a test (visible_wrapped_prompt_rows_do_not_reflow_on_width_growth) to ensure visible wrapped prompt rows do not get reflowed when the width increases.
2026-03-25 14:12:57 +01:00
Kevin Boos
770e9cfbdd
Fix stack nav to make it more like a true stack (#980)
* Removed `remove_all(view_id)` from `push_view()` to allow
  the same view ID on the stack multiple times. Most apps will
  need somethin like this, otherwise they'd have to have a weird
  statically-known set of fixed views that are eligible to be
  on the stack. Robrix, at the least, doesn't have that.
* Fixed `show()` to force-reset the animator state, which was needed
  to make a fresh animation always play (even when animating in "reused" views)
* Reset `state` to `Inactive` in `show()`, otherwise it'll never transition to `Active`
2026-03-24 19:52:37 +01:00
Kevin Boos
a6ea8a662b
Fix shader compilation bug on linux and window positioning (#979)
TL;DR: the shader compiler needed explicit casts in for loop bounds,
and the window positioning was messed up, causing the app-level title bar
to overlap with the native OS-level title bar (which means you couldn't
see or press the window chrome buttons)

--------

here's an AI-generated summary of the changes, for more details:

On OpenGL ES 3.0 targets (Linux/EGL, Android), shaders containing `for` loops
over `uint` variables failed to compile with a GLSL type-error. The shader
compiler emitted the loop header as:

```glsl
for(uint i = 0; i < 4; i++) { … }
```

The integer literals `0` and `4` are of type `int` in GLSL. GLSL ES 3.0 forbids
implicit casts between `int` and `uint`, so the initialiser and the comparison
both produce a compile error. The other shader backends (Metal/WGSL/Rust) do not
have this restriction, so no corresponding arm existed for GLSL.

**`platform/script/src/shader_control.rs`** — Add a `ShaderBackend::Glsl` arm to
`handle_for_1` that wraps both loop bounds in an explicit constructor call for the
loop variable's type:

```glsl
for(uint i = uint(0); i < uint(4); i++) { … }
```

This satisfies GLSL ES 3.0's strict no-implicit-cast rule and matches the
behavior already implemented for the WGSL backend (which uses typed variable
declarations for the same reason).

**`platform/src/os/linux/opengl.rs`** — Gate the helper functions
`shader_source_hash` and `shader_source_preview` (and their call-sites) behind
`#[cfg(target_os = "android")]`. These functions are only referenced from
Android-specific shader-cache code paths; without the attribute the compiler
emits dead-code warnings on every other Linux/OpenGL build.

- `platform/script/src/shader_control.rs`
- `platform/src/os/linux/opengl.rs`

----------------------------------------------------------------------------

On GNOME (and likely other modern WMs), restoring a saved window position would
consistently produce two visual artifacts:

1. **No WM title bar visible** — the client area was rendered where the title bar
   should appear.
2. **Black bar at the bottom** — the bottom portion of the window surface was not
   covered by rendered content.

The root cause was two related issues in `xlib_window.rs`:

**Issue 1 — `XMoveWindow` called after `XMapWindow` (races with WM reparenting)**

After `XMapWindow`, the window manager asynchronously reparents the client window
into a decoration frame. If `XMoveWindow` is called after reparenting has occurred,
the coordinates are interpreted relative to the WM frame rather than the root
window. For example, calling `XMoveWindow(client, 23, 89)` after GNOME reparents
places the client 89 px from the top of the WM frame. Since the title bar is only
~37 px tall, the client ends up 52 px below the title bar, and its bottom edge
extends 52 px *beyond* the bottom of the WM frame. GNOME responds by resizing or
repositioning the client, producing the rendering artifacts described above.

**Issue 2 — No `USPosition` hint set**

Without the `USPosition` flag in `WM_NORMAL_HINTS`, GNOME ignores the position
provided to `XCreateWindow` and applies its own smart-placement algorithm. This
meant the application relied entirely on the post-map `XMoveWindow` call described
above, which was itself broken.

**`platform/src/os/linux/x11/x11_sys.rs`** — Add the standard `XSizeHints` flag
constants:

- `USPosition` (`1 << 0`) — user-specified x, y
- `USSize` (`1 << 1`) — user-specified width, height
- `PPosition` (`1 << 2`) — program-specified position
- `PSize` (`1 << 3`) — program-specified size

**`platform/src/os/linux/x11/xlib_window.rs`** — Two changes in `XlibWindow::init()`:

1. Before calling `Xutf8SetWMProperties`, populate an `XSizeHints` struct with
   `flags = USPosition | PPosition` (and `x`/`y` set to the requested coordinates)
   when a position was provided. Pass this struct as the `WM_NORMAL_HINTS` argument
   instead of the previous `ptr::null_mut()`. This tells GNOME/Mutter to honor the
   requested position rather than running its own placement heuristic.

2. Move the `XMoveWindow` call to *before* `XMapWindow`. At that point the window is
   still a direct child of the root window, so the coordinates are unambiguously
   root-relative. This eliminates the race with WM reparenting entirely.
2026-03-24 19:52:21 +01:00
Admin
d1f3ca7f73 xr otw 2026-03-23 22:26:59 +01:00
Admin
e0e73f9ea6 xr otw 2026-03-23 22:26:41 +01:00
Admin
06c3e1c0d1 xr otw 2026-03-23 20:52:29 +01:00
Admin
2b677c6da0 compiles 2026-03-23 12:56:39 +01:00
Admin
b9287c3888 no drawdepth 2026-03-23 10:23:17 +01:00
Admin
dea06dd282 cleanup 2026-03-23 10:12:37 +01:00
Admin
e888e946ab android borked 2026-03-22 17:28:18 +01:00
Admin
f9ac0d651e tree 2026-03-22 13:39:01 +01:00
Admin
8ec45d41a0 cleanup xr 2026-03-22 12:24:04 +01:00
Admin
84ec5394ba fix xr lib 2026-03-21 15:24:19 +01:00
Admin
2f3260f214 demo 2026-03-21 11:39:27 +01:00
Sabin Regmi
a0351cf90e
Some small web nits from my crazy experiement (#972)
* Schedule loader removal after presented frame

Introduce loader_after_presented_frame_id and add schedule/cancel helpers to remove the canvas loader after a presented animation frame. Cancel any pending requestAnimationFrame when the loader is removed or conditions change, and update update_startup_loader to use the new scheduling logic (remove loader only after seen animation frame and quiet frames threshold). This prevents premature removal and visual glitches while preserving the existing fallback timer.

* small fonts should be used when --profile=small

* dont include large fonts on small profile + fix compress serving

* Refactor window initialization and caption sync

Extract sync_caption_bar_state and sync_caption_title from ensure_initialized and call them before the initialized early-return so caption bar state and title are kept in sync even when the widget is re-applied. ensure_initialized still performs the original one-time setup (pass, depth texture, demo frame), but runtime chrome (VR button and caption visibility/title) is now updated up-front to avoid stale UI state.

* fix --bindgen

* Set imports.env in wasm import patches

Add additional string replacements to ensure imports.env = env is injected into generated JS for different formatting variants of the __wbg_get_imports(...) call. This makes the patch robust to variations like missing const or spacing so the env object is always attached to the wasm imports.

* Revert "Set imports.env in wasm import patches"

This reverts commit 37933234d36b4ee867690cf167474638e81febf7.

* Revert "fix --bindgen"

This reverts commit cb236b202b92a46eda3cb1ee4c678bdf6507081a.

* Reapply "fix --bindgen"

This reverts commit 27df4175bd1889222b928ffb68213aa865d1c839.

* Reapply "Set imports.env in wasm import patches"

This reverts commit 23695b4fa646d8f1b7a31a3fc27eb92f5bce0cf8.

* fix xr compilation error
2026-03-21 11:23:16 +01:00
Kevin Boos
d63676590f
Fix windows build: add missing constants in vendored windows-rs bindings (#975)
* Fix windows build: add missing constants in vendored windows-rs bindings

* fix more build errors and warnings in windows-rs vendored copy
2026-03-21 11:22:27 +01:00
Kevin Boos
4fa866bf4a
Fix svg draw shaders (#976)
* Fix windows build: add missing constants in vendored windows-rs bindings

* fix more build errors and warnings in windows-rs vendored copy

* Fix SVG parsing and vector drawing
2026-03-21 11:22:02 +01:00
Kevin Boos
291e9365a2
cargo makepad: fix android SDK installation (unzip step) on linux (#977)
* Fix windows build: add missing constants in vendored windows-rs bindings

* fix more build errors and warnings in windows-rs vendored copy

* Fix SVG parsing and vector drawing

* Fix erroneous unzip glob pattern that doesn't work on normal linux

* try another approach: unzip everything, cp needed dirs
2026-03-21 11:21:52 +01:00
Admin
0aa7118f78 protocol 2026-03-20 10:09:40 +01:00
Admin
d50d15f16b protocol 2026-03-20 09:42:35 +01:00
Admin
2cff94d01b fix android screencap to studio 2026-03-20 09:03:50 +01:00
Kevin Boos
be1229492d
Restore optional serde derives that were removed in Makepad 2.0 (#973) 2026-03-19 20:07:45 +01:00
Kevin Boos
7e51266f5b
Ensure that the Html <br> tag obeys current line spacing settings (#971)
Without this, Html blocks that have both `<br>` and `\n` newlines
(or even just soft line wraps) look quite janky, with poor vertical spacing.
2026-03-19 20:07:28 +01:00
Kevin Boos
7134504e82
Fixed several issue in text drawing, layout, and text flow. (#970)
These are needed to support better formatting of Html code
that mixes multiple different styles together, e.g., inline code
next to normal code, or inline code within a blockquote or a heading.

Full summary of changes:

**File:** `draw/src/shader/draw_text.rs`

Added `#[live(0.0)] pub top_drop: f32` to `TextStyle`. This is a vertical offset expressed as a fraction of font size — positive values shift text downward. It's useful for aligning baselines when mixing fonts with different vertical metrics (e.g., a code font rendered inline with regular text).

**File:** `draw/src/shader/draw_text.rs`

When `temp_y_shift != 0`, the extra shift pixels are now added to `allocate_height()` and the emitted walk rect. This prevents containers (blockquotes, etc.) from clipping the descenders (g, p, q, y) of vertically-shifted text.

**File:** `widgets/src/text_flow.rs`

After selecting the appropriate text style (normal/bold/italic/fixed), `draw_text.temp_y_shift` is now set from that style's `top_drop` value. This allows each style variant to specify its own vertical offset, since `TextFlow` uses a single shared `DrawText` instance for all text rendering.

**File:** `draw/src/turtle.rs`

New public method to mutate `layout.padding.left` after a turtle has been created.

**File:** `widgets/src/text_flow.rs`

After drawing the bullet/number marker, the actual cursor position is now measured and `set_padding_left()` is called so that wrapped continuation lines align with the text after the marker, rather than being over-indented by the estimated `font_based_padding` (which was `2.5 * font_size`).

Additionally, the hardcoded `draw_text(cx, " ")` spacer after the marker was replaced with `walk_margin(cx, self.list_item_marker_pad)` for precise pixel-based control.

**File:** `widgets/src/text_flow.rs`

Added `#[live(5.0)] list_item_marker_pad: f64` — a configurable spacing (in pixels) between the list item marker (bullet/number) and the content text that follows it.
2026-03-19 20:07:13 +01:00
Admin
785b823751 fix android screencap to studio 2026-03-19 13:59:42 +01:00
Admin
840b8b5cb3 fix android screencap to studio 2026-03-19 13:57:55 +01:00
Admin
2914c74b79 fix studio 2026-03-19 12:42:27 +01:00
Admin
d5e73849a5 fix studio 2026-03-19 11:54:38 +01:00
Admin
02411768e1 fix studio 2026-03-19 11:42:20 +01:00
Admin
23e1973ac9 fix studio 2026-03-19 11:24:51 +01:00
Admin
bb24dbd50a studio ws fix 2026-03-19 10:42:55 +01:00
Admin
5a4bef21d6 cleanup 2026-03-19 10:16:38 +01:00
Admin
4c7b806524 helmet with real envmap 2026-03-18 01:29:59 +01:00
Admin
6cd202a553 remove dep 2026-03-18 01:02:57 +01:00
Admin
52afaf022b remove dep 2026-03-18 00:06:32 +01:00
Admin
1a0f01ce89 vulkan debugging 2026-03-17 23:59:07 +01:00
offline-ant
1fe83a5c6c
Fix Mat4f::mul to compute a*b instead of b*a (#966)
Mat4f::mul(a, b) was computing b*a due to transposed summation
indices in the multiplication loop. All call sites (glTF TRS
composition, view-projection, MVP chains, scene hierarchy) are
written expecting standard a*b order, and transform_vec4 uses
standard column-major M*v convention.

Fix: swap the operand bindings so the existing index pattern
produces the correct a*b result.

Add regression test mat4_mul_order that verifies:
- Scale(2)*Translate(5,7) yields tx=10 (scaled translation)
- transform_vec4 on result*(1,1,0,1) yields (12,16)

Co-authored-by: ant <ant@offline.click>
2026-03-17 19:43:02 +01:00
Admin
987f21bea4 cef 2026-03-17 19:00:28 +01:00
Admin
3803e091de vulkan video textures 2026-03-17 19:00:28 +01:00
Admin
efb38f2f89 vulkan video textures 2026-03-17 19:00:28 +01:00
Admin
91f0873847 vulkan video textures 2026-03-17 19:00:28 +01:00
Admin
72269c0da2 not bad 2026-03-17 19:00:28 +01:00
Admin
543ae72c34 not bad 2026-03-17 19:00:28 +01:00
Admin
c3585c7b98 not bad 2026-03-17 19:00:28 +01:00
Admin
a7c5942177 ok planes 2026-03-17 19:00:28 +01:00
Admin
a8f5877687 quite good 2026-03-17 19:00:28 +01:00
Admin
36a1a6b62a almost good 2026-03-17 19:00:28 +01:00
Admin
49750e5d54 almost good 2026-03-17 19:00:27 +01:00
Admin
c1cee9c216 almost 2026-03-17 19:00:27 +01:00
Admin
debd9f33ed almost 2026-03-17 19:00:27 +01:00
Admin
a2a3347144 whoa 2026-03-17 19:00:27 +01:00
Admin
c3ad851992 almost reasonable 2026-03-17 19:00:27 +01:00
Kevin Boos
94711aa64b
fix SVG handling to support SVG files with fill "none" (#961)
Also fix splash example icon paths
2026-03-16 20:44:17 +01:00
Kevin Boos
a9fe1a8090
Proper widget tree fix (#956)
* Fix macos platform build failures related to PR#946

* Properly fix the new breadth-first widget tree lookup
2026-03-16 19:27:32 +01:00
Admin
1cf51b3261 fix 2026-03-16 16:13:48 +01:00
Sabin Regmi
2b79d27b44
fix macos, ios and wasm compilation (#957) 2026-03-16 15:18:33 +01:00
Admin
53fa2aa86b it compiles 2026-03-15 16:06:36 +01:00
Admin
6658d6515f slow voxels 2026-03-15 15:36:30 +01:00
Admin
b070eb7477 bricks 2026-03-15 13:35:58 +01:00
Admin
64b1a7c74b vulkan back 2026-03-15 13:20:49 +01:00
Admin
92e751ffde rapier vendored 2026-03-15 09:52:45 +01:00
Admin
da93a47467 physics 2026-03-15 09:52:45 +01:00
Admin
8ee34e0e84 otw 2026-03-15 09:52:45 +01:00
Admin
057035243b works again 2026-03-15 09:52:44 +01:00
Admin
0525556ceb works again! 2026-03-15 09:52:44 +01:00
Admin
3e3f4fd2e0 zbuf back 2026-03-15 09:52:44 +01:00
Admin
4db21794fe fixup 2026-03-15 08:49:21 +01:00
Jason Yau
f4279de855
Fix windows build errors again (#953)
* regenerate windows-rs to export D3D11_DEPTH_WRITE_MASK_ZERO constant

* fix build errors

---------

Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-03-14 15:26:55 +01:00
Sabin Regmi
51f23402b5
Expose WEB URL and Location Handling (#941)
* Add web URL/location and history handling

Sync browser location and history with the WASM app on the Web platform.

- Cx API: add default CxOsApi methods browser_update_url and browser_history_go and Cx wrappers to call them.
- Web/WASM IPC: add ToWasmLocationChange, FromWasmBrowserUpdateUrl, FromWasmBrowserHistoryGo structs for message passing and register them in init.
- Web JS: emit_location_change on popstate, and implement FromWasmBrowserUpdateUrl and FromWasmBrowserHistoryGo to update history (push/replace/back/forward/go).
- Cx web runtime: add normalize_web_pathname, split_web_location and update_web_location_state helpers; handle incoming ToWasmLocationChange to update internal state and signal events; implement browser_update_url and browser_history_go to forward requests to JS and update internal location state.

These changes enable SPA-style URL updates, history navigation, and app-side reactions to browser location changes while avoiding redundant updates.

* Handle hashchange and improve URL parsing

Add a window "hashchange" listener to emit location changes and trigger the wasm pump so fragment navigation updates are handled. Update FromWasmBrowserUpdateUrl to construct URLs relative to the current full location (window.location.href) so fragment- and relative-only updates resolve correctly. Tighten split_web_location parsing to treat '/', '?', and '#' as path delimiters after a scheme, ensuring queries and fragments are detected when extracting the path.

* fix wasm run without --release

* LTO off in small profile
2026-03-14 12:13:28 +01:00
Sabin Regmi
7ab7f324b3
Use fallback cell size when glyphs missing (#942)
Replace unwraps when querying the first glyph with safe handling and provide a fallback monospace cell size if no glyph is available (e.g. on wasm while fonts are still loading). Computes a reasonable width/height from the current font_size (width = 0.6 * font_size, height = font_size) so the editor can render on first draw instead of aborting. Keeps existing cell_size and cell_offset_y calculations based on the chosen dimensions.
2026-03-14 12:13:04 +01:00
Jason Yau
f3178e65a6
Fix windows build error (#944)
* regenerate windows-rs to export missing functions

* fix compilation errors on windows

* import WS_EX_TOOLWINDOW from windows-rs instead of customizing the constant

---------

Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-03-14 12:12:50 +01:00
Sabin Regmi
5a6eacd7ef
ft floating panel on macos (#945) 2026-03-14 12:12:35 +01:00
offline-ant
fe5cd60087
platform: rework custom MSE playback sessions (#946)
Co-authored-by: ant <ant@offline.click>
2026-03-14 12:12:21 +01:00
offline-ant
8189d8eab2
compositor: add projected quad and offscreen surface primitives (#947)
* draw: add draw-list transform helpers for projected composition

* draw: unroll rounded box shadow sampling in SDF shader

* platform: honor per-draw depth_write across backends

* compositor: add projected quad and offscreen surface primitives

---------

Co-authored-by: ant <ant@offline.click>
2026-03-14 12:12:05 +01:00
Kevin Boos
1feaac5494
Fix HtmlLink attribute init to handle any attr order (#948) 2026-03-14 12:11:42 +01:00
Kevin Boos
2ebeb20262
fix tooltip by force-drawing the draw list upon show/hide (#950)
Without this, the tooltip will not necessarily be shown upon its
first call to `show()`, especially if the CalloutTooltip wrapper
is driving it.
2026-03-14 12:11:32 +01:00
offline-ant
7dd4597e83
Fix PulseAudio input stream dedupe (#951)
* Fix PulseAudio input stream dedupe

* Fix PulseAudio input callback userdata cast

* Fix PulseAudio threaded mainloop waits

---------

Co-authored-by: ant <ant@offline.click>
2026-03-14 12:11:18 +01:00
Kevin Boos
f337fcd9be
Support image rotation in degrees (#952)
This was previously a feature but was lost in the 2.0 migration
2026-03-14 12:11:01 +01:00
wyenox
67d6ed2906
Fix and optimize widget search to return the shallowest match in the tree (#949) 2026-03-14 07:49:32 +01:00
Admin
f040a2f2a6 hands back! 2026-03-12 16:57:25 +01:00
Admin
bca911513d xr otw 2026-03-12 15:51:30 +01:00
Sabin Regmi
2e900f48af
Clippy Fixes Only (#939)
* clippy fixes on draw

* more clippy fixes

* more clippy fixes

* more small nits
2026-03-12 14:59:41 +01:00
Sabin Regmi
3242b11afc
Windows Blur, System Composition (#940)
* Initial plan

* feat(platform): add window visuals API and M1 backdrop wiring

Co-authored-by: wheregmis <26774729+wheregmis@users.noreply.github.com>

* feat(uizoo): add M2 GlassPanel widget and demo tab

Co-authored-by: wheregmis <26774729+wheregmis@users.noreply.github.com>

* blue example

* Apply backdrop intensity and transparency

macOS: apply visuals.backdrop_intensity to the NSVisualEffectView alpha (clamped) so backdrop intensity affects the effect view.

Windows: honor visuals.backdrop_intensity by computing an alpha-packed accent color, toggle WS_EX_LAYERED for transparent windows, call SetLayeredWindowAttributes and DwmExtendFrameIntoClientArea when needed, and set the AccentPolicy accordingly (enable blur for transparent or non-none backdrops, set gradient_color based on intensity, and set accent_flags depending on DWM attribute result).

Tests: update expected backdrop_intensity in a unit test from 4.0/1.0 to 0.25 and assert a platform op is emitted. These changes ensure backdrop intensity and transparency settings are respected across platforms.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wheregmis <26774729+wheregmis@users.noreply.github.com>
2026-03-12 14:57:09 +01:00
Admin
61a70d0ebd xr 2026-03-12 13:30:40 +01:00
Admin
0c80201e66 makepad splash 2026-03-12 09:49:30 +01:00
Admin
1bad158945 fixup 2026-03-12 09:39:47 +01:00
Admin
7895cd34fc fixup 2026-03-12 09:39:16 +01:00
Admin
cde02e2e48 fix 2026-03-11 18:15:12 +01:00
Sabin Regmi
bba8868caf
Optimize font loading and add SharedBytes (#938)
Introduce selective font loading and a SharedBytes abstraction with mmap support to reduce memory and IO overhead when handling fonts.

Key changes:
- Add SharedBytes (with MappedBytes using memmap2) and stats; switch FontData to SharedBytes and update loader/loader tests.
- Platform: add memmap2 dependency (non-wasm), new platform API helpers get_resource_abs_path and get_resource_font_bytes to prefer mmap'ed files and fall back to owned bytes.
- Font family/load changes: ensure_fonts_loaded_for_text and update_font_definitions now accept optional text to load only needed fallback fonts (CJK/emoji) based on text content and resource basename heuristics.
- Avoid eager loading of heavy bundled fallback fonts (LXGWWenKai*, NotoColorEmoji.ttf) when loading all script resources.
- Add env override MAKEPAD_TEXT_ATLAS_SIZE for text atlas size parsing and tests; add related parsing helpers and tests.
- Minor fixes: safer transmute for font_face data slice and several unit tests to validate behavior.

Overall this reduces unnecessary font resource reads/mmap usage and allows tuning text atlas size via environment.
2026-03-11 16:25:35 +01:00
Admin
feb1ecd55f dock fix 2026-03-11 10:19:58 +01:00
Admin
a6eb9906a0 hiccup 2026-03-11 10:19:58 +01:00
Admin
5170eed9af working 2026-03-11 10:19:58 +01:00
Sabin Regmi
ffbeadc173
Hotreloading Nits (#936)
* Add wasm server_manager and ownership guard

Introduce a new wasm server_manager module that implements WasmServerOwnershipGuard to manage per-workspace lock files, PID/port ownership, and safe replacement of stale or live servers. Integrate the guard into compile::run by preparing and activating the guard before starting the HTTP server; start_wasm_server now accepts the guard, returns a Result, checks bind success, activates the guard, and propagates thread join errors. The server manager includes platform-specific PID handling, port-probing, lock read/write/remove helpers, and unit tests exercising startup scenarios and lock lifecycle. Also add mod declaration and apply minor formatting/refactor cleanups across compile.rs (error formatting, whitespace, and logging improvements).

* Add startup mutex and port occupant diagnostics

Rename server_manager into top-level module and add startup mutex handling and improved diagnostics. Introduces atomic lock writes (write_file_atomically), a StartupMutexGuard with configurable timeouts/polling, and logic to detect/recover stale startup locks to prevent concurrent wasm run startups. Extends ServerManagerProbes with describe_port_occupant and implements platform-specific detection (lsof on Unix, netstat/tasklist on Windows) to provide clearer errors when ports are occupied. Updates imports (main.rs, compile.rs, mod.rs) and adds unit tests covering startup lock behavior and unknown-occupant error text.

* Improve hot-reload logging and delivery

Add clearer logging and error handling for wasm hot-reload events. Introduces hot_reload_display_name to extract a user-friendly file name, logs when a hotreload is detected, and checks tx.send result to avoid panics when the watcher channel is closed. broadcast_hot_reload_event now logs whether events were skipped (no /$watch clients) or sent and includes the delivered client count with proper pluralization.

* Check lock PID owns port and add startup timestamp

Require that a lock's PID both be alive and actually own the server port to be considered a live lock (classify_lock_state now takes listen_addr). Add parsing of a started_at timestamp in startup locks and use now_unix_millis to age out stale startup locks even if the PID is alive. Extend ServerManagerProbes with now_unix_millis and pid_owns_port, implement port_occupant_info and PortOccupant to centralize occupant detection/refinement for unix and windows, and refactor describe_port_occupant to use it. Update tests and MockProbes to exercise PID ownership checks and startup-lock aging; add tests for PID reuse (treated as stale if it doesn't own the port) and for startup lock aging.

* Hot reload: dedupe sites, add logging

Avoid duplicate ScriptMod sites during hot reload and improve diagnostics. collect_compiled_sites_for_file now tracks seen ScriptModKey values to prevent pushing duplicates. handle_cx_live_edit counts processed files and logs how many overrides were applied and from how many changed files. forward_hot_reload_fs_event logs each detected file and reports if the watcher channel is closed. Minor import cleanup and a helper hot_reload_display_name were added for nicer log output.

* shared core for reload

* Warn and fallback to unminified JS on write error

When writing the minified JS file fails, log a warning and fall back to copying the original JS file instead of returning an error. This avoids failing the build on IO/write errors (e.g. permissions or disk issues) while preserving the previous behavior of using the unminified copy when reading fails. No change to successful minification path.
2026-03-10 12:47:38 +01:00
Kevin Boos
c344dcd4fb
Support hiding a dock tabs bar (#937) 2026-03-10 08:00:44 +01:00
offline-ant
c44b456ebc
studio/runview: wait for app-ready before Wayland swapchain bootstrap (#935)
Co-authored-by: ant <ant@offline.click>
2026-03-09 20:07:03 +01:00
offline-ant
5407f085e7
studio/runview: fix Wayland bootstrap and control-text input (#934)
* studio/runview: fix Wayland stdin-loop bootstrap

* wayland: ignore control text input commits

* tools: ignore generated python bytecode

---------

Co-authored-by: ant <ant@offline.click>
2026-03-09 19:34:49 +01:00
Admin
609655aa5c wasm hotloading 2026-03-09 19:34:00 +01:00
Admin
39d107a09b wasm hotloading 2026-03-09 19:26:08 +01:00
Sabin Regmi
9cf8128f15
WASM Binary Optimization + Splitting (#925)
* wip

* Handle wasm data segments and manage brotli artifacts

Add full support for parsing, encoding and rewriting Wasm data segments: introduce WasmDataSegmentKind (Active/Passive), helpers to encode varints and const i32 exprs, and functions to rewrite the data section or replace a section payload. Update wasm_split_data_segments to preserve passive segments and data.count (section 12), only extract active segments into a separate split blob, and adjust segment counting and tests accordingly.

Bump brotli dependency to 8.0 in tools, and add remove_brotli_artifact to remove leftover .br files when brotli compression is disabled. Call this cleanup in cargo_makepad build/copy paths and when removing split data, and add .bin MIME mapping and print mapping for .bin in the server output. Tests updated to cover passive segments and data_count preservation.

* Support split data v2 and wasm rebuild

Add support for a new split data format (version 2) and the ability to rebuild a WASM module with its data section. wasm_bridge.js: parse v1/v2 split blobs, return {version, segments}, add varint encode/decode helpers, encode split-data section payloads, implement rebuild_split_wasm, fetch-and-instantiate logic to fetch both wasm and split blobs and handle v1 preloaded splits or rebuild for v2. wasm_strip.rs: bump split data version to 2, include segment kind and memory_index in encoded split data, preserve passive segments, update encoding/decoding and tests, and return the updated segment count. tools/cargo_makepad/src/wasm/compile.rs: separate target spec directories for threaded vs single builds (threads/single).

* basic splitting

* Instantiate secondary Wasm module in threads

Store and expose a compiled secondary WebAssembly module from the primary module, and ensure worker contexts (Web Worker and AudioWorklet) instantiate that secondary module before running thread entrypoints. Changes: save _secondary_module on primary_wasm in wasm_bridge, include secondary_module in WasmWebBrowser info, and add async instantiate_secondary logic + awaits in audio_worklet and web_worker so the secondary module is instantiated with {env, primary: primary_wasm.exports} prior to initializing stack/TLS or starting execution. This ensures the secondary module can import primary exports in threaded contexts and prevents race conditions by waiting for instantiation to complete.

* more cleanups

* more cleanup

* wip

* remove double wasm pump

* cleanup

* Cold-first auto wasm split with fallback

Add a cold-only function-splitting mode and automatic fallback to preserve startup-safe behavior. Introduces wasm_split_functions_cold() and a split_auto flag to run a cold-first pass that moves defer-safe cold functions to a secondary wasm; if no useful cold candidates are found the build falls back to the normal startup-path function split so the app still gets a secondary payload.

Changes include: update to CLI help text, new split_auto handling in WasmConfig, AutoSplitOutcome variants, compile logic to prefer cold-only splits then fall back to the regular split, adjusted logging for automatic mode, and README wording clarifications. Files touched: README.md, libs/wasm_strip/src/wasm_strip.rs, tools/cargo_makepad/src/main.rs, tools/cargo_makepad/src/wasm/compile.rs, tools/cargo_makepad/src/wasm/mod.rs.

* Tune brotli compression parameters

Adjust brotli settings in tools/cargo_makepad/src/wasm/compile.rs: increase buffer size from 4KB to 64KB, lower quality from 12 to 11, and raise window size from 22 to 24. This balances throughput and compression ratio for larger wasm artifacts—bigger buffer and window can improve compression effectiveness while slightly reducing CPU cost by lowering quality.

* Add optional wasm-opt optimization flag

Introduce an optional --wasm-opt option that runs Binaryen's wasm-opt -Os on built wasm when available. Adds a wasm_opt flag to WasmConfig (default false), parses the CLI option, and integrates a try_wasm_opt helper that writes a temp wasm, invokes wasm-opt, reads back the optimized output and prints size/reporting while gracefully falling back on errors. The wasm-opt step runs before the existing split/strip pipeline. Also updates CLI help text, minor Cargo.toml formatting changes, and removes a vendor UPSTREAM.md reference.

* update readme

* Shorten split exports to $s/$p and use base62

Rename split-related exports/imports to shorter identifiers and compress numeric indices using base62. Changes: export/table slot prefix changed from "__mp_split_table"/"__mp_split_slot_*" to "$s" and the primary import namespace from "primary" to "$p" across wasm_bridge, audio_worklet, and web_worker. Introduces encode_base62 in wasm_strip to emit $f/$t/$m/$g names with base62-encoded indices, and updates primary/secondary module generation and tests accordingly. Also includes minor JS formatting/whitespace cleanups and small safety/compatibility tweaks during WebAssembly instantiation.

* Preload WASM modules and set cache headers

Inject modulepreload links into generated HTML (conditionally including wasm_bridge and bindgen when bindgen is enabled, otherwise preloading web_gl only) to improve module loading. Also change served asset Cache-Control from max-age=0 to max-age=86400 (1 day) for both brotli-compressed and uncompressed responses to enable client caching and reduce repeated fetches.

* Minify JS when copying before brotli

Add a lightweight JS minifier and apply it in cp_brotli for .js files. Introduces minify_js which strips line/block comments, collapses unnecessary whitespace, preserves strings/escaped characters and simple regex literals (heuristic), and removes empty lines. cp_brotli now attempts to read and minify .js input and write the minified output to the destination (falling back to cp on read error), then continues to optionally brotli-compress the result. This reduces payload size prior to compression with a small, simple minification step.

* Format web.js and disable XR capability checks

Apply consistent JS formatting (spacing, brace placement, object literal spacing, and minor whitespace cleanups) across platform/src/os/web/web.js. Replace the previous XR capability detection with a no-op (query_xr_capabilities now returns Promise.all([])) and remove the await call in load_deps so XR checks are not performed during startup. Also includes minor non-functional tweaks (timers, audio worklet messaging formatting, fetch call spacing, and various input/keyboard handler cleanups). Overall changes are primarily stylistic with the notable behavior change of disabling XR capability probing.

* Add WASM no-cache headers; remove web video arms

Set Cache-Control to "no-store, must-revalidate" (plus Pragma/Expires) for .wasm responses while keeping max-age=86400 for other assets; wire these into the HTTP response headers. Also remove the explicit VideoSource::InMemory and VideoSource::Filesystem match arms from the web platform code (they previously logged errors and emitted VideoDecodingError events).
2026-03-09 18:52:49 +01:00
Admin
fbde8812a3 linux filechange 2026-03-09 18:34:05 +01:00
Admin
bdf760b58b wasm media 2026-03-09 18:00:25 +01:00
Admin
7a9274ebf8 wasm media 2026-03-09 17:43:54 +01:00
Admin
612f3b9cd8 hotreloading 2026-03-09 17:25:14 +01:00
Admin
e23a8b3f26 fix uid 2026-03-09 16:42:43 +01:00
offline-ant
cceb499765
studio/runview: fix Wayland stdin-loop bootstrap (#933)
Co-authored-by: ant <ant@offline.click>
2026-03-09 09:24:54 +01:00
Admin
928521e994 mipmapping 2026-03-09 09:18:33 +01:00
Admin
def4c5f6e6 fix script mods 2026-03-08 18:20:07 +01:00
Admin
e79e374375 live reloading 2026-03-08 17:25:27 +01:00
Admin
38d08cab0b bootstrap change 2026-03-08 13:36:58 +01:00
Admin
ba51dceee4 undo integer attribute change 2026-03-08 13:11:57 +01:00
offline-ant
7c7d91b8ad
platform: video/camera subsystem with media plugin architecture (#929)
Video playback: extended API with volume, playback rate, seek ranges,
buffered ranges, can_play_type, audio-only mode. Unified player wrapping
native backend (AVPlayer/GStreamer/MediaFoundation) with software fallback.
YUV shader pipeline (BT.601/709/2020, NV12 biplanar, rotation).

Camera: V4L2 backend (Linux), expanded NDK Camera2 (Android), AVCapture
stream refactor (iOS/macOS) with shared session architecture. NV12
zero-copy paths on iOS (CVMetalTextureCache) and Android (AImage planes).
Camera preview modes (texture/native/auto).

Video encoding: H264 hardware encode on Apple (VideoToolbox) and Android
(MediaCodec). Camera-to-encoder pipeline with pixel buffer passthrough.

Media plugin system: externalized codec implementation via MediaPlugin
trait. MsePlayer, VideoFrameDecoder, MediaVideoEncoder, SoftwareVideoPlayer
interfaces. Runtime codec capability query and merge.

Includes camera example app.

Co-authored-by: ant <ant@offline.click>
2026-03-08 13:10:58 +01:00
offline-ant
c22cf23cf0
platform: popup window API with X11/Wayland support (#911)
- Add popup window type for context menus and dropdowns
- Wayland: xdg_popup with grab for compositor-driven dismiss
- X11: override-redirect windows with pointer grab
- Explicit-close semantics: app must handle PopupDismissed
- Fix Wayland crash on repeated context menu open/close
- Emit WindowClosed before PopupDismissed in PopupDone

Co-authored-by: ant <ant@offline.click>
2026-03-08 13:10:45 +01:00
offline-ant
814fe4d9cd
platform: native mobile selection handles (#930)
* platform: popup window API with X11/Wayland support

- Add popup window type for context menus and dropdowns
- Wayland: xdg_popup with grab for compositor-driven dismiss
- X11: override-redirect windows with pointer grab
- Explicit-close semantics: app must handle PopupDismissed
- Fix Wayland crash on repeated context menu open/close
- Emit WindowClosed before PopupDismissed in PopupDone

* platform: native mobile selection handles

iOS: custom UIView selection handles with UIPanGestureRecognizer (all versions),
UITextSelectionDisplayInteraction for native highlights (iOS 16+),
MakepadSelectionRect for UITextInput protocol.

Android: custom SelectionHandleView with GradientDrawable oval, touch drag
listeners, JNI bridge for handle drag events.

Widgets: TextFlow clipboard action integration (show on touch up with selection,
hide on touch down/focus lost, TextCut handler), PortalList select-all and
clipboard actions, selection bounding rect computation for popup positioning.

Includes text_selection example app.

---------

Co-authored-by: ant <ant@offline.click>
2026-03-08 12:15:31 +01:00
Admin
cb87a70b74 terminal selection 2026-03-08 11:53:36 +01:00
Admin
3d7852d0b3 remove dep 2026-03-08 11:43:23 +01:00
Lutz
3e8e09fbe9
fix: Single-slot UInt/SInt instance attributes render incorrectly (#922)
Co-authored-by: Lutz Paelike <lutz.paelike@ehealthafrica.org>
2026-03-08 11:26:52 +01:00
offline-ant
04dea089bd
platform: small fixes and quality improvements (#928)
- Android log levels: map log! macro levels to Android log priorities
  (ERROR=6, WARN=5, INFO=4). Some devices suppress DEBUG by default.
- cargo-makepad android: show help text instead of panicking on missing
  or invalid subcommand.
- Android build: discover .class files dynamically instead of hardcoding
  ~25 individual paths. Add Java 8 source/target flags.
- Remove deprecated AsyncTask import from MakepadNetwork.java.
- Studio stdout: add newline after JSON messages for JSON-lines parsing.
- Studio stdout: skip profiler timing in stdout mode to avoid overhead.
- Script thread: fix panic on empty call stack in call_has_me/call_has_try.
- Cursor: reset to Default on FingerHoverOut in TextFlow and TextInput.

Co-authored-by: ant <ant@offline.click>
2026-03-08 11:24:18 +01:00
offline-ant
27b910ff6c
platform: cycle tap count after triple-click so fast double-clicks keep working (#931)
process_tap_count incremented without bound. TextInput handles
tap_count 2 (select word) and 3 (select all) but ignored counts
>= 4. Rapid repeated double-clicks produced tap counts of 4, 5, 6...
hitting the _ => {} fallthrough and doing nothing.

Cycle the counter back to 1 after reaching 3 (1->2->3->1->2->3).

Co-authored-by: ant <ant@offline.click>
2026-03-08 11:22:12 +01:00
Admin
ec505b27f0 script api for slide panel 2026-03-08 10:57:03 +01:00
Admin
a1a9c38004 fix physics stability 2026-03-08 10:44:56 +01:00
Admin
24a9b0627d remove apprs 2026-03-08 10:28:40 +01:00
Admin
923a554e49 fixing 2026-03-08 09:20:34 +01:00
Admin
b64a83fa15 oops. uids 2026-03-08 08:42:49 +01:00
Jason Yau
5d81bf9490
Add IME Support for Linux (X11) (#926)
* regenerate windows-rs to export ImmAssociateContext

* fix ime popup window still shown when TextInput has no focus

* fix ime popup window still shown when TextInput has no focus for macos

* fix ime himc state

* IME support for linux

---------

Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-03-07 15:50:05 +01:00
Admin
9124a025af exr 2026-03-07 15:01:34 +01:00
Admin
bc6da37276 fix 2026-03-07 11:54:59 +01:00
Admin
e61e3bfbce Harden widget tree refresh and require explicit widget uids 2026-03-07 11:23:22 +01:00
Kevin Boos
15d39cca74
TextInput: actually use color_empty_hover/focus in draw_text shader (#927) 2026-03-07 09:46:44 +01:00
Admin
df1b267569 fix ime runview 2026-03-07 02:07:44 +01:00
Admin
5db0aa3686 oops 2026-03-06 19:15:09 +01:00
Admin
7c5c0edeba fix shader compiler 2026-03-06 18:49:24 +01:00
Admin
a7d471a8ad fix ime 2026-03-06 17:24:51 +01:00
Admin
42a45913d6 drumroll 2026-03-06 17:21:17 +01:00
Admin
f00f4c0560 fix shader compiler state clobber 2026-03-06 17:21:17 +01:00
Admin
9a8020fec0 almost sshader 2026-03-06 17:21:17 +01:00
Admin
7a3cc93921 cleanup ds 2026-03-06 17:21:17 +01:00
Admin
0846e575b0 split f32 + uniformbuffers 2026-03-06 17:21:17 +01:00
Jason Yau
06fb9ee8eb
Fix: IME Popup Window Appears Without TextInput Focus (#924)
* regenerate windows-rs to export ImmAssociateContext

* fix ime popup window still shown when TextInput has no focus

* fix ime popup window still shown when TextInput has no focus for macos

---------

Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-03-06 16:50:01 +01:00
Sabin Regmi
713628a89f
Load script resources per-handle (avoid global loads) (#923)
Replace broad cx.load_all_script_resources() calls with a targeted cx.load_script_resource(handle) to only request the specific resource needed. Refactor script resource loading (platform/src/script/res.rs) by extracting load_script_resource_impl(handle, crate_manifests) and exposing load_script_resource(handle); keep load_all_script_resources() by iterating per-handle. Improve wasm handling: resolve web_url per-resource, set explicit error states when missing, and fire async HTTP requests safely. Remove an early call to load_all_script_resources() from web startup. Additional changes: serve precompressed .br files in the local wasm dev server (with COOP/COEP headers when threaded), add wasm-specific font/theme script entries for widgets, and update various callers (draw shaders, widgets, math_view, gltf/view_splat, image) to use the per-handle loader. These changes reduce unnecessary global loads and limit network/file operations to only required resources.
2026-03-06 16:01:07 +01:00
offline-ant
d19f99d1ef
selection: clipboard delegate, primary selection, mobile handles (#912)
* selection: clipboard delegate, primary selection, mobile UI, handles, accessibility

Level 0: Route clipboard through Makepad instead of arboard. Custom
ClipboardDelegate in havishell forwards set_text/get_text/clear through
Makepad's CopyToClipboard and pending paste state. Fix Wayland serial
constraint by queuing CopyToClipboard when no serial is available.

Level 1: Primary selection (Linux). Add CxOsOp::SetPrimarySelection,
Wayland zwp_primary_selection_device_manager_v1 protocol bindings, X11
PRIMARY atom handling. HAVI stores selection text in SharedDocumentSelection
and calls set_primary_selection on change.

Level 2: Mobile clipboard actions UI. Long-press selects word and shows
native clipboard toolbar. TextCopy/TextCut events return selection text.

Level 3: Selection handle API. Add CxOsOp Show/Update/HideSelectionHandles,
Event::SelectionHandleDrag, and HAVI integration for handle drag events.
Platform stubs for all backends.

Level 4: Accessibility plumbing. Add CxOsOp::AccessibilityUpdate with
type-erased Box<dyn Any + Send> payload. HAVI implements
notify_accessibility_tree_update to forward accesskit::TreeUpdate through
Makepad. Platform no-op stubs.

* wayland: handle primary selection data_offer child objects

---------

Co-authored-by: ant <ant@offline.click>
2026-03-06 12:17:25 +01:00
Sabin Regmi
5028a602d2
Remove debug title update in redraw handler (#919)
Remove a leftover debug call that set the document title when handling ToWasmRedrawAll in platform/src/os/web/web.rs. This avoids an unnecessary DOM update used only for debugging and cleans up the redraw handler.
2026-03-06 12:16:24 +01:00
Sabin Regmi
60039d4ee9
Consider Logo or Control primary on wasm32 (#921)
Add a wasm32-specific branch in KeyModifiers::is_primary so the primary modifier is treated as true when either `logo` or `control` is set on WebAssembly targets. This preserves expected web behavior where Meta/Command or Control can act as the primary key. Also tighten the non-Apple cfg to exclude wasm32 explicitly to avoid overlapping cfg matches.
2026-03-06 12:16:08 +01:00
Lutz
4ff3be2f20
fix: iOS app crashes on device rotation (#920) 2026-03-06 12:15:52 +01:00
Admin
35792db27f fix 2026-03-06 09:27:43 +01:00
Admin
d5f198d126 fix 2026-03-06 09:26:02 +01:00
Admin
83c628eaa8 mb3d test scene 2026-03-05 23:47:21 +01:00
Admin
08cc3f44af m3d concluded 2026-03-05 23:45:21 +01:00
Admin
9e1d37d6ef optimisation adaptive ao 2026-03-05 23:45:21 +01:00
Admin
6c465092da baseline 2026-03-05 23:45:21 +01:00
Admin
1924bc2f6b cleanup 2026-03-05 23:45:21 +01:00
Admin
c9bd068a70 parity! 2026-03-05 23:45:21 +01:00
Admin
4207f0d4e9 nearly there 2026-03-05 23:45:21 +01:00
Admin
3b0f5a5c40 almost 2026-03-05 23:45:21 +01:00
Admin
394ee17864 shadows 2026-03-05 23:45:21 +01:00
Admin
8c3f839ee3 base correct 2026-03-05 23:45:21 +01:00
Admin
8ee09a8466 improving.. 2026-03-05 23:45:21 +01:00
Admin
00ce91ab5e right direction 2026-03-05 23:45:21 +01:00
Admin
7f313a6261 mb3d experiment 2026-03-05 23:45:21 +01:00
Sabin Regmi
8e3ae8ea1c
Add --no-threads option and runtime thread checks (#918)
Introduce a single-threaded wasm build mode and add defensive runtime handling for missing wasm threading support.

- CLI: add --no-threads flag and WasmConfig.threads to control threaded vs single-threaded builds. Parse and strip wasm-specific options before forwarding build/run args.
- Build: select target features and RUSTFLAGS based on threading; omit atomics/bulk-memory features for single-threaded builds. Adjust generated server instructions to require COOP/COEP only for threaded builds.
- Dev server: conditionally include COOP/COEP headers when serving threaded wasm artifacts.
- JS runtime (platform/src/os/web/web.js): guard audio worklet startup and thread creation on wasm._has_thread_support; make alloc_thread_stack return null with clear console warnings when required exports or alignment are missing; pass allocated thread_info to workers.

These changes enable building and running a single-threaded wasm variant without COOP/COEP server requirements and improve runtime resilience when threading features are unavailable.
2026-03-05 20:53:20 +01:00
admin
8b515338a2 fix splitter fingerdown 2026-03-04 15:13:21 +01:00
admin
4bc5cec4cb tweakra 2026-03-04 15:00:52 +01:00
admin
923a9c091b terminal error 2026-03-04 14:32:42 +01:00
Admin
dbf82942c6 vfs smoothness 2026-03-04 13:56:58 +01:00
Admin
5a89cc27f1 possible terminal glitch fix 2026-03-04 13:39:27 +01:00
Admin
8d65fdb31f fix path 2026-03-04 11:38:29 +01:00
Admin
650767daa8 fix path 2026-03-04 11:36:01 +01:00
Admin
79d844450d dnd 2026-03-04 10:31:14 +01:00
Admin
4ad0be2a69 move draw svg with turtle 2026-03-04 10:25:19 +01:00
Admin
47f906c340 widget tree refactor 2026-03-04 10:16:33 +01:00
Admin
7adc274be7 widget tree refactor 2026-03-04 09:52:17 +01:00
Admin
adc773d764 fix scrolling 2026-03-04 09:22:15 +01:00
Admin
4a87f4ca31 fix scrolling 2026-03-04 09:16:48 +01:00
Admin
9ea5dc9471 fix scrolling 2026-03-04 09:14:45 +01:00
Admin
b8d431bf8d enter repeat 2026-03-04 08:42:38 +01:00
Admin
f9e68f8650 fix: revert is_repeat check, fix batched TextInput newlines
Made-with: Cursor
2026-03-04 08:18:41 +01:00
Admin
051f48de84 terminal window resizing stable 2026-03-04 00:25:32 +01:00
Admin
b30788f8cd terminal fidgetting 2026-03-03 22:47:29 +01:00
Admin
45ecd5f006 terminal fidgetting 2026-03-03 22:44:59 +01:00
Admin
2d0d853ffe terminal fidgetting 2026-03-03 22:43:41 +01:00
Admin
704d2a7a5f terminal fidgetting 2026-03-03 22:39:28 +01:00
Admin
8d0694b78f widget tree change 2026-03-03 22:12:11 +01:00
Admin
05238ee39d widget tree change 2026-03-03 21:22:58 +01:00
Admin
96c3992c94 fixup terminal height glitching 2026-03-03 21:05:00 +01:00
Admin
ecc2ad6086 Fix terminal resize garbling for TUIs by anchoring grid to top
Made-with: Cursor
2026-03-03 20:22:05 +01:00
Admin
d7c69e49bb widget tree fixup 2026-03-03 20:19:26 +01:00
Admin
c0125ba11d widget tree fixup 2026-03-03 20:19:06 +01:00
Admin
cb92b61b66 terminal otw 2026-03-03 17:47:24 +01:00
Admin
eeec57f459 terminal wrangling 2026-03-03 13:22:06 +01:00
Admin
98547946dd terminal wrangling 2026-03-03 13:21:53 +01:00
Admin
b8626a0b40 fix 2026-03-03 09:43:45 +01:00
Admin
81483d4555 y flip webgl rendertartet 2026-03-03 09:42:54 +01:00
Admin
0f3864a44a rename network 2026-03-03 09:18:19 +01:00
Admin
17d86d145e remove origin from widget tree 2026-03-03 08:55:01 +01:00
offline-ant
b7d573eaf5
turtle: rename BeginTurtle/EndTurtle to BeginClip/EndClip, add push/pop_clip_rect (#907)
Rename AlignEntry::BeginTurtle/EndTurtle to BeginClip/EndClip — these
entries control GPU clip rect stacking, not turtle lifecycle.

Add push_clip_rect/pop_clip_rect to Cx2d: lightweight API for manual
clip rect control without creating a full turtle. The existing
clip_and_shift_align_list pass intersects nested clip rects and writes
draw_clip into draw call instances.

Co-authored-by: ant <ant@offline.click>
2026-03-03 08:53:50 +01:00
offline-ant
1b800c4c8e
text: add letter/word spacing, RTL, features, y_offset, variations to shaper (#908)
Co-authored-by: ant <ant@offline.click>
2026-03-03 08:53:26 +01:00
offline-ant
91c6ea5859
texture: add set_data_u32 for resize-safe updates (#910)
Add Texture::set_data_u32(cx, width, height, data) that replaces pixel
data and dimensions in one call. Unlike put_back_vec_u32, this also
updates width/height, making it safe for image sources that change
resolution (animated images, lazy-loaded placeholders).

Co-authored-by: ant <ant@offline.click>
2026-03-03 08:52:54 +01:00
Admin
ee8e75b713 find_child 2026-03-03 08:44:18 +01:00
Admin
e163f5d11a find_child 2026-03-03 08:38:55 +01:00
Admin
c545c2259e find_child 2026-03-03 08:37:28 +01:00
Admin
4edce27d8c weakref widget tree 2026-03-02 22:22:30 +01:00
Admin
4f38c0c324 finall fix render glitch 2026-03-02 20:16:29 +01:00
Admin
e64e606caf add studio proto 2026-03-02 12:46:52 +01:00
Admin
5d6472c2a0 add studio proto 2026-03-02 12:30:33 +01:00
Admin
5b27043eed fix paste in terminal 2026-03-02 12:18:26 +01:00
Admin
b437899d82 remote control 2026-03-02 12:07:59 +01:00
Admin
462fd3dcec remote working 2026-03-02 10:40:33 +01:00
Admin
2b06c8846a filter proto 2026-03-02 09:32:23 +01:00
Admin
0d353ba303 unsupress fs events 2026-03-01 23:22:34 +01:00
Admin
524ac9186d cleanup 2026-03-01 23:15:39 +01:00
Admin
0d96511a11 studio tweaks 2026-03-01 21:58:08 +01:00
Admin
6c17b5aac8 fixup studio backend 2026-03-01 21:12:24 +01:00
Admin
1ef52d6553 wasm 2026-03-01 20:22:11 +01:00
Admin
4542da2d17 logs 2026-03-01 11:26:27 +01:00
Admin
53971390b2 logs 2026-03-01 11:23:04 +01:00
Admin
fe028654af logs 2026-03-01 10:51:46 +01:00
Admin
c47697bc2e logs 2026-03-01 10:47:46 +01:00
Admin
f844171542 logs 2026-03-01 10:43:26 +01:00
Admin
30115c6e4f logs 2026-03-01 10:40:47 +01:00
Admin
ad546ec237 logs 2026-03-01 10:38:01 +01:00
Admin
b6c45297f4 fix 2026-03-01 10:28:49 +01:00
Admin
1632445402 fix 2026-03-01 10:24:21 +01:00
Admin
01a4985e86 backend proto splitoff 2026-03-01 10:12:00 +01:00
Admin
6712047ece fixes 2026-02-28 23:41:15 +01:00
Admin
9c4dcebdc6 fix better errors 2026-02-28 23:19:25 +01:00
Admin
79ac8d834f makepad studio old 2026-02-28 22:42:57 +01:00
Admin
051557c569 studio2 2026-02-28 22:30:21 +01:00
Admin
084c3df5bc better vfs threading 2026-02-28 22:29:48 +01:00
Admin
0bffb98f28 studio2 otw 2026-02-28 21:27:26 +01:00
Admin
52ed7f698b studio2 otw 2026-02-28 17:22:50 +01:00
Admin
a8599a4b22 fix 2026-02-28 12:48:06 +01:00
Admin
6c8716a121 add cx module 2026-02-28 12:35:13 +01:00
Admin
952e6134a9 plaintext error 2026-02-28 11:40:11 +01:00
offline-ant
0ce6ec05e8
Apple metal and compile updates (#903)
Merge actool partial Info.plist into main plist for iOS 15 icon support.

Co-authored-by: ant <ant@offline.click>
2026-02-28 11:36:48 +01:00
offline-ant
d544b7335b
ios: use CVPixelBuffer-first GL/Metal bridge path (#904)
Co-authored-by: ant <ant@offline.click>
2026-02-28 11:17:48 +01:00
offline-ant
3b2e5730ae
trigger redraw on Screenshot request in windowed mode (#905)
dispatch_studio_msg pushed to screenshot_requests but never
triggered a redraw. The GL readback that captures the screenshot
only runs during the render path, so the request was never
serviced in windowed backends (Wayland, X11, macOS).

Co-authored-by: ant <ant@offline.click>
2026-02-28 11:17:35 +01:00
offline-ant
445e74de24
platform: add custom studio app message events (#906)
Co-authored-by: ant <ant@offline.click>
2026-02-28 11:17:10 +01:00
offline-ant
f0907d6782
Fix missing app icons on iOS 15 by adding classic idiom entries to asset catalog (#902)
The Contents.json only had a single universal+platform entry, which is
only recognized by iOS 16+. Add classic per-idiom entries (iphone, ipad,
ios-marketing) so actool compiles both sets into Assets.car. iOS 15
falls back to the idiom-based entries.

Co-authored-by: ant <ant@offline.click>
2026-02-28 11:16:59 +01:00
offline-ant
975e766c1a
Font loading: defer registration to draw-time, add fast-path completeness check (#901)
Move font family registration out of on_custom_apply (script apply time)
and into ensure_fonts_loaded (draw time). This avoids redundant work when
the same FontFamily is applied to hundreds of widgets during a frame.

ensure_fonts_loaded now has a fast path that checks is_font_family_complete()
and returns immediately when all expected members are already registered.
The slow path calls load_all_script_resources() to progress pending loads
before falling back to update_font_definitions().

Other changes:
- FontFamilyDefinition gains expected_member_count to distinguish partial
  from complete registrations.
- set_font_family_definition skips cache eviction when the definition or
  cached family is already equivalent.
- load_font_family now clones the definition instead of removing it,
  allowing re-loads after cache eviction without losing the definition.
- Loader::font_family_definitions and Layouter::loader are now pub(crate)
  to support the completeness query from Fonts.

---

Review observations (not yet addressed):

1. STALE FAMILY ON RE-APPLY (medium risk): If a FontFamily is re-applied
   with different members but the same object index (same family_id), the
   fast path in ensure_fonts_loaded will see the old definition as
   "complete" and never call update_font_definitions with the new members.
   Fix: compare current member handles against stored definition, or set a
   dirty flag in on_custom_apply that forces one refresh.

2. UNNECESSARY LAYOUT CACHE FLUSH (medium risk): Layouter::set_font_family_definition
   unconditionally clears cached_params and cached_results even when
   Loader::set_font_family_definition short-circuits as unchanged. During
   partial-load states ensure_fonts_loaded may call update repeatedly with
   identical partial definitions, flushing the text layout cache each time.
   Fix: propagate a changed bool from Loader and only clear when true.

3. COMPLETENESS IGNORES CACHE-ONLY STATE (low risk): is_font_family_complete
   only checks font_family_definitions, not font_family_cache. If a family
   was already loaded into cache and its definition consumed, completeness
   returns false. The new early-return in set_font_family_definition for
   cache-match mitigates this in practice but the invariant is fragile.
   Fix: also check font_family_cache in is_font_family_complete, or ensure
   definitions are always retained (which this diff partly does by switching
   from remove to get+clone in load_font_family).

Co-authored-by: ant <ant@offline.click>
2026-02-28 10:29:36 +01:00
Julián Montes de Oca
ecb5e7ffae
Video Widget: Support on More Platforms (#867)
* Add support for Video widget on WASM

* Add support for Video widget on Linux

* Linux video:  GStreamer appsink pipeline, GL texture upload, accurate seeking

* Add support for Video widget on Windows

* Harden cross-platform video playback and error handling

* Cleanup video playback logs

* Restore wasm builds

* Restore linux builds

* Restore linux builds

* Remove unused import
2026-02-27 18:52:49 +01:00
Admin
8b6ccc0d85 centralised resource cache 2026-02-27 16:55:05 +01:00
offline-ant
e1d3653de2
iOS: app icons, fullscreen, iOS 15 support, GL texture fix, NSLog logging (#897)
Co-authored-by: ant <ant@offline.click>
2026-02-27 16:42:47 +01:00
offline-ant
af684756c9
Font optimization: reduce atlas 64MB→4MB, use static data for builtins (#898)
Co-authored-by: ant <ant@offline.click>
2026-02-27 16:39:38 +01:00
offline-ant
855f52f5cc
deduplicate script resources by file path (#899)
file_resource and crate_resource now check if a resource with the same
abs_path already exists before creating a new entry. Returns the existing
handle instead of reading the same file multiple times.

Before: 54 resource entries, same font files loaded up to 15 times each
(427MB of duplicate heap data). After: ~10 unique entries, each file
loaded once (~50MB).

Co-authored-by: ant <ant@offline.click>
2026-02-27 16:38:00 +01:00
Admin
82fb692b05 override play 2026-02-27 10:28:00 +01:00
Admin
dd4c79b1e1 add get_color to draw_glyph 2026-02-27 09:39:21 +01:00
offline-ant
fdc918a890
iOS: GL render bridge (EAGL+IOSurface) and ios build command (#894)
* iOS: add GL render bridge (EAGL+IOSurface), fix linking, fix warnings

- Add EaglRenderBridge for iOS (GLES 3.0 context sharing textures with
  Metal via IOSurface), mirroring macOS CglRenderBridge
- Add iOS GlRenderBridge inner field and Cx methods
  (create_gl_render_bridge, create_gl_render_bridge_texture, restore_gl_context)
- Widen IOSurface support from macos-only to macos/ios/tvos in apple_sys
  and metal.rs (CxOsTexture fields, update_shared_texture, etc.)
- Expose metal_device() accessor on IosApp
- Replace removed SSLSetEnableCertVerify with SSLSetSessionOption
  (kSSLSessionOptionBreakOnServerAuth) to fix iOS linker error
- Remove unused apple_util::* imports in ios.rs and ios_app.rs
- Fix iOS deployment target from 26.0 to 17.0 in cargo_makepad

* cargo-makepad: add apple ios build command, expose IosBuildResult fields

---------

Co-authored-by: ant <ant@offline.click>
2026-02-27 08:40:41 +01:00
offline-ant
f71bdf48f7
android: add launch splash themes to cargo-makepad manifests (#895)
Co-authored-by: ant <ant@offline.click>
2026-02-27 08:40:26 +01:00
offline-ant
4f34bce7ee
cargo-makepad: isolate android/apple target dirs from desktop builds (#896)
Android defaults to target/android/, apple to target/apple/.
Prevents cross-platform builds from invalidating each other's caches.

Co-authored-by: ant <ant@offline.click>
2026-02-27 08:40:14 +01:00
Admin
04e4c51ab8 implicit io markers 2026-02-27 08:36:46 +01:00
Admin
deaf6303d7 studio2 otw 2026-02-26 21:07:27 +01:00
offline-ant
b3cec2dd2d
cargo-makepad android: use crate name for Rust .so lookup (#893)
Co-authored-by: ant <ant@offline.click>
2026-02-26 20:00:04 +01:00
Admin
8ac86f0666 fix up voice build on non tahoe 2026-02-26 15:22:40 +01:00
Julián Montes de Oca
0675c98cc8
Restore StackNavigation widget, add examples to ui-zoo (#892) 2026-02-26 13:54:36 +01:00
alanpoon
7bee85bb3e
agent_acp (#888) 2026-02-26 11:36:12 +01:00
offline-ant
8549e70668
gl_render_bridge: fix Android EGL wiring and reset GL state on restore (#889)
Fix EGL context initialization on Android to properly wire up the GL
render bridge display/context/surface.

Add GL state reset in restore_gl_context on Linux/Android to prevent
state leakage from external GL consumers (e.g. Servo) back into the
Makepad render pipeline.

Co-authored-by: ant <ant@offline.click>
2026-02-26 11:35:54 +01:00
offline-ant
31f2a41038
cargo-makepad: cross-platform icon build pipeline and desktop packaging (#890)
Refactor app icon handling into a unified app_icon module that replaces
the old window_icon.rs. Build-time icon generation produces platform-
native formats (ICO with multiple sizes for Windows, ICNS for macOS,
multi-resolution PNGs for Linux/Wayland/X11).

Add `cargo makepad desktop` subcommand for desktop packaging with
automatic icon detection from MAKEPAD_APP_ICON_PATH env var, or from
Cargo package metadata.

Resolve binary names from [[bin]] targets in Cargo.toml so .app bundles,
.exe outputs, and APK labels use the correct name instead of defaulting
to the package name.

Use llvm-rc for Windows .res generation (cross-compilation compatible)
with absolute link paths for reliable resource embedding.

Co-authored-by: ant <ant@offline.click>
2026-02-26 11:33:26 +01:00
offline-ant
a00db2bf04
FOOTGUN FIX: panic on nil return from script_mod! in from_script_mod (#891)
When script_mod! is used with 'let app = startup() do ...' but the
block omits the final 'app' expression, the module returns nil. This
silently creates an App with an empty WidgetRef -- no window, no UI,
no error. The app runs indefinitely doing nothing.

Panic with an actionable message instead of silently succeeding.

Co-authored-by: ant <ant@offline.click>
2026-02-26 11:32:47 +01:00
Admin
8b42b20198 restore merge script 2026-02-26 10:09:28 +01:00
Admin
9cf7fbb8b7 new merge script 2026-02-26 10:08:25 +01:00
Admin
813844becc counter splash example 2026-02-26 10:08:25 +01:00
Admin
22ebf59337 text layout
\
2026-02-26 10:08:22 +01:00
offline-ant
690be7c85d
widgets: add Label visible live property support (#885)
Co-authored-by: ant <ant@offline.click>
2026-02-26 08:24:29 +01:00
Kevin Boos
3db5766d33
Fix circle view (#887)
* Fix `CircleView` shader, support true circle shader behavior

Add CircleView examples w/ variety to `uizoo`

* splash example: fix popup notification behavior
2026-02-26 08:17:49 +01:00
admin
ddca609ad8 api 2026-02-25 17:14:39 +01:00
Kevin Boos
d1737883da
FIx Icon, improve to support IconRotated, expose geom in draw_svg (#883)
* Fix draw_svg to support a rotated Icon. Add `IconRotated` widget.

Fix icon resource paths in `splash` example

* Expose geometry in draw_svg to make icon/svg rotation more efficient

simplifies the code too
2026-02-25 13:05:14 +01:00
offline-ant
8f3451c5cb
restore set_window_icon lost during rebase of #877 (#880)
The rebase squash into 26318769 used the early draft of window_icon.rs,
dropping the OnceLock-based global setter added in the fixup commit.

Restore from pre-rebase commit 83bf5d62:
- add static GLOBAL_ICON: OnceLock<WindowIcon>
- add pub fn set_window_icon(icon: WindowIcon)
- refactor default_window_icon() to check global override first
- re-export set_window_icon from platform lib.rs

Co-authored-by: ant <ant@offline.click>
2026-02-25 13:05:00 +01:00
Sabin Regmi
ec983c982a
Remove left margin from window caption label (#884)
* Remove left margin from window caption label

Delete the hardcoded Inset{left: 100} margin on the caption Label in widgets/src/window.rs so the label can be centered by the parent layout. This cleans up alignment and removes an unnecessary offset in the window header.

* Use window title in caption; format button click

Apply the configured window title to the window caption and make a small UI code cleanup.

- examples/splash: set window.title to "Splash Example" and reformat the tooltip button click check to a multiline expression for readability.
- widgets/src/window.rs: import label::* and update ensure_initialized to copy cx.windows[window_id].create_title into the caption_label when non-empty so the window chrome shows the configured title.
2026-02-25 13:04:46 +01:00
Admin
33eeb0d244 little glitches in script engine 2026-02-24 23:32:56 +01:00
Admin
8aab18f4d9 warnings 2026-02-24 21:43:35 +01:00
Admin
677d2ba934 cleanup warnings 2026-02-24 21:40:08 +01:00
Admin
d75499e215 edmx 2026-02-24 21:40:08 +01:00
Admin
688da80e11 fix gc issues 2026-02-24 21:40:08 +01:00
Kevin Boos
c3cd1f034a
Add missing callout tooltip file (#882) 2026-02-24 19:59:44 +01:00
Kevin Boos
c0082cc323
Fix Tooltip behavior and structure. Add CalloutTooltip widget (#881)
The CalloutTooltip is a fancier wrapper atop Tooltip that allows
the user to display a tooltip with a callout triangle that points
at a particular widget, making it clearer what the tooltip
corresponds to. It also supports a custom text color, background color,
positioning suggestion (top, right, left, bottom), full text wrapping,
and dynamically resize and re-orient itself to fit within the app screen.
2026-02-24 19:43:06 +01:00
offline-ant
1519ca78cc
add cross-platform GL render bridge for external GL consumers (#878)
New GlRenderBridge API lets external code (e.g. servo in havi) render
via GL into a makepad-displayable texture with zero-copy.

Platform backends:
- Linux/Android: wraps existing EGL context (EglRenderBridge)
- Windows: ANGLE EGL on D3D11 device via mozangle (AngleRenderBridge)
- macOS: standalone CGL 3.2 Core bridged to Metal via IOSurface (CglRenderBridge)

Cx methods: create_gl_render_bridge, create_gl_render_bridge_texture,
restore_gl_context -- all platform-dispatched via cfg.

Co-authored-by: ant <ant@offline.click>
2026-02-24 17:32:08 +01:00
offline-ant
b14e9cf45f
fix(wayland): negate scroll axis values to match Makepad convention (#875)
Wayland wl_pointer::Axis values are defined as motion-event vectors:
positive vertical = downward on screen. This means positive values
represent content sliding down under the pointer, i.e. the viewport
moving UP.

Makepad's internal scroll convention is positive = viewport moves DOWN.
This matches X11 (button 4/up maps to negative, button 5/down maps to
positive) and macOS (which negates scrollingDeltaY for the same reason).
The web backend also follows this via browser deltaY semantics.

The Wayland backend was passing axis values through without negation,
producing reversed scroll on all Wayland sessions. Negate at the
accumulator stage, consistent with how winit handles the same mismatch
(explicit comment: "Wayland sign convention is the inverse of winit").

The ignored AxisRelativeDirection event is unrelated -- it is a hint
telling clients whether the compositor applied natural-scrolling
inversion to the axis values, intended for widgets like volume sliders
that should track physical finger direction. The axis values themselves
already have natural scrolling applied by the compositor.

Co-authored-by: ant <ant@offline.click>
2026-02-24 17:31:04 +01:00
offline-ant
2631876982
Add cross-platform window icon support (#877)
Add WindowIcon and WindowIconBuffer types to platform/src/window.rs with
RGBA8 pixel buffer data. Embed a default 64x64 Makepad icon generated at
runtime (dark rounded rect with white M glyph).

Platform backends:

- Windows: CreateIcon from RGBA->BGRA data, set on WNDCLASSEXW.hIcon
- X11: XChangeProperty with _NET_WM_ICON atom (RGBA->ARGB u32 array)
- macOS: NSBitmapImageRep + NSImage, setApplicationIconImage on NSApp
- Wayland: vendor xdg-toplevel-icon-v1 protocol, regenerate bindings,
  bind wl_shm + xdg_toplevel_icon_manager_v1 globals, create shm buffer
  icon when compositor supports it, silent fallback to app_id otherwise.
  Use configurable create_app_id field (default "Makepad").

Codegen: extend tools/wayland_codegen to generate xdg toplevel_icon
bindings into libs/linux/wayland-protocols/src/xdg.rs.

Co-authored-by: ant <ant@offline.click>
2026-02-24 17:30:43 +01:00
Admin
7813788fcd text cast in set_text 2026-02-24 17:12:54 +01:00
Admin
81c3dc5dd9 fix android sdk install issues 2026-02-24 17:06:21 +01:00
Admin
f37bb78aaf fix linux 2026-02-24 16:49:11 +01:00
Admin
94c459cee9 add splash test/set text 2026-02-24 16:33:36 +01:00
Admin
35f01b85a0 ssl sockets 2026-02-24 16:28:21 +01:00
Admin
9d18257fd1 add source to components 2026-02-24 14:40:08 +01:00
Admin
7816d8da43 network refactor 2026-02-24 14:04:37 +01:00
Admin
ca17ee8baa splice out a makepad-network crate 2026-02-23 23:21:31 +01:00
Admin
7752acdf13 dropshadows back 2026-02-23 21:09:52 +01:00
Admin
5486630d21 splat 2026-02-23 19:15:37 +01:00
Admin
aa056e8053 fix dx11 memleak 2026-02-23 18:30:26 +01:00
Admin
7dc1249b4f fix up uizoo 2026-02-23 18:00:19 +01:00
offline-ant
ddbf066580
platform: unified StudioToApp dispatch and control channel (#874)
* platform: unified StudioToApp dispatch and control channel

Shared dispatch_studio_msg() in cx_shared.rs handles all common
StudioToApp variants (input, clipboard, screenshot, widget dump, kill).
Each stdin backend resolves window_id for mouse events, then delegates.

Control channel (web_socket.rs) enables StudioToApp dispatch in windowed
apps. Event loops poll it alongside their native event sources.

WindowGeomChange events are emitted on stdin geometry updates.

* fix RunView Y-flip on Linux: flip in shader instead of CPU readback

---------

Co-authored-by: ant <ant@offline.click>
2026-02-23 17:46:41 +01:00
offline-ant
d2b0513138
defer aux-channel accept until binary starts running (#873)
accept_host_endpoint() has a 10s timeout. Calling it at spawn time
fails for any build that takes longer to compile. Defer to when
"Running " appears in stderr, which is when the binary actually
starts and connects to the aux channel.

build_server: match+return instead of expect on spawn failure.

Co-authored-by: ant <ant@offline.click>
2026-02-23 17:46:07 +01:00
offline-ant
751e8d994d
macOS IOSurface render texture support for havishell (#872)
Co-authored-by: ant <ant@offline.click>
2026-02-23 17:45:54 +01:00
offline-ant
cd90738d8d
fix Studio crash on child process failure (#871)
build_client: log instead of panic on closed channel.

Co-authored-by: ant <ant@offline.click>
2026-02-23 17:45:40 +01:00
offline-ant
c9e36d14c8
fix: map atan2 to atan in GLSL shader backend (#870)
Co-authored-by: ant <ant@offline.click>
2026-02-23 17:45:22 +01:00
offline-ant
759eeec03c
widgets: expose Label and View fields for external composition (#869)
Co-authored-by: ant <ant@offline.click>
2026-02-23 17:44:49 +01:00
offline-ant
adfa3dbace
wayland: edge-resize, scroll acceleration, seat v5 binding (#868)
Co-authored-by: ant <ant@offline.click>
2026-02-23 17:44:34 +01:00
Admin
a8135d72e2 fix windows 2026-02-23 17:22:39 +01:00
Admin
1b38aad46f tunnel 2026-02-23 16:55:20 +01:00
Admin
00c64eb965 message refactor
;
2026-02-23 16:46:48 +01:00
Admin
887763fcf9 message coalescing 2026-02-23 16:08:52 +01:00
Admin
6fa70b19ce studio<>app proto cleanup 2026-02-23 15:54:59 +01:00
offline-ant
431aa095dc
Add HideWindowButtons/ShowWindowButtons platform op (#866)
Co-authored-by: ant <ant@offline.click>
2026-02-23 15:19:40 +01:00
Julián Montes de Oca
6bc8fde866
Video Widget: Support on MacOS & iOS (#863)
* Add macOS video playback support using AVPlayer + CVMetalTextureCache

Extends the Video widget from Android-only to also support macOS with
hardware-accelerated, zero-copy video rendering through Metal. Uses
AVPlayer for decoding and CVMetalTextureCache to map decoded frames
directly to MTLTextures without CPU copies.

Key changes:
- New AppleVideoPlayer module (AVPlayer + CVPixelBuffer → MTLTexture pipeline)
- FFI bindings for CVMetalTextureCache, CMTime, and related Apple APIs
- Video frame polling integrated into macOS paint cycle
- TextureFormat::VideoRGB ungated from Android-only to all platforms
- Video widget made cross-platform (TextureHandleReady wait is Android-only)
- Fixed shader aspect ratio bug (&&→|| for non-positive dimension check)
- Added video demo to uizoo and scratchpad examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add iOS video playback support

* Enhance video playback functionality with seek support and current position tracking

Key changes:
- Added `seek_video_playback` operation to allow seeking to specific timestamps.
- Introduced `current_position_ms` field in `VideoTextureUpdatedEvent` for tracking playback position.
- Implemented seeking functionality across iOS, macOS, and Android platforms.
- Updated Video widget to support new controls and indicators for seeking.

* Improve error handling for unsupported texture pixel formats

* Make volume icon drawing  use fixed width

* Make play/pause drawing use fixed width

* Remove video from scratchpad

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:19:21 +01:00
admin
c6aa6675b1 readme 2026-02-23 08:06:39 +01:00
admin
f47a027964 fix linux windows build 2026-02-23 07:53:05 +01:00
admin
68222399e3 tweakray 2026-02-22 20:30:14 +01:00
admin
b6dea262e2 automated click animation 2026-02-22 13:34:00 +01:00
admin
251156b233 refactor remote and screenshot proto 2026-02-22 10:57:51 +01:00
admin
e3bf60cf96 runview ticks 2026-02-21 16:53:42 +01:00
admin
617b5ba7e7 theme cleanup 2026-02-21 15:46:30 +01:00
admin
ec56475706 theme cleanup 2026-02-21 15:40:12 +01:00
admin
2d8754eea9 fix up theme hook 2026-02-21 15:21:27 +01:00
admin
4ac3391dc2 terminal viewport 2026-02-21 14:55:22 +01:00
Admin
882f97b5b5 gpu profiling 2026-02-21 13:29:30 +01:00
Admin
e76b4a1469 make splats faster 2026-02-21 12:40:37 +01:00
Admin
5b2e29fb74 make splats faster 2026-02-21 12:37:26 +01:00
Admin
adb988a8aa downshift hiccups fixed 2026-02-21 11:51:33 +01:00
Admin
2665d0023b profiler 2026-02-21 11:30:59 +01:00
Admin
051b17a980 profiler 2026-02-21 10:37:25 +01:00
offline-ant
209c660226
Fix some build issues + upgrade android to r28 ndk. (#862)
* fix: add **/* glob to Linux NDK unzip to match subdirectories

`*` alone doesn't match `/` on Linux unzip builds with WILD_STOP_AT_DIR,
causing only top-level files to be extracted. Adding `**/*` ensures bin/,
lib64/, sysroot/ subdirectories are included.

* Auto-bundle NDK shared library deps (e.g. libc++_shared.so) into APK

When libmakepad.so has NEEDED entries for shared libraries provided by
the NDK sysroot (like libc++_shared.so from C++ dependencies), those
libraries were not being included in the APK, causing runtime dlopen
failures on device.

Add bundle_ndk_shared_deps() which uses the NDK's llvm-readelf to scan
libmakepad.so for NEEDED entries, then copies any matching .so files
from the NDK sysroot base lib dir into the APK. System libraries
(present in the API-level subdirectory) are excluded since they are
provided by the Android OS at runtime.

The detection is general-purpose and not hardcoded to any specific
library name.

* fix: make SYS_GETTID arch-conditional (186 on x86_64, 178 on aarch64)

The constant was hardcoded to 178 (correct for aarch64 but maps to
query_module on x86_64), causing Android's seccomp filter to kill the
process immediately on x86_64 emulators.

* Upgrade Android NDK from r25 to r28, fix x86_64 seccomp crash

cargo_makepad:
- sdk.rs: NDK version 25.2.9519653 → 28.2.13676358, download URLs r25c → r28b
- sdk.rs: Update NDK_IN extract paths for all platforms (Windows, macOS, Linux)
- sdk.rs: macOS NDK extraction is now host-aware (darwin-aarch64 for Apple Silicon)
- compile.rs: MacosAarch64 uses native darwin-aarch64 prebuilt (no more Rosetta)
- compile.rs: ndk_prebuilt_dir() split for MacosX64 vs MacosAarch64

platform:
- libc_sys.rs: Make SYS_GETTID arch-conditional (186 on x86_64, 178 on aarch64)
  Was hardcoded to 178 which maps to query_module on x86_64, causing
  Android seccomp to kill the process on x86_64 emulators
- android.rs: Add pub display field on CxOs, pub make_current() on CxAndroidDisplay
  for Servo embedding support

* Fix GLSL struct constructor crash on Android GLES drivers

The r28 script-based shader backend (shader_glsl.rs) generates GLSL
struct constructor syntax in the vertex/fragment main() unpack code,
e.g.: vb_geom = QuadVertex(vec2(packed_geometry_0.x, ...));

Some GLES drivers (notably Android emulator ANGLE/SwiftShader) reject
this with 'Structure constructor arguments do not match structure
fields', even though it's valid per the GLES 3.0 spec.

This is a regression from r28. The old r25 shader compiler
(generate_glsl.rs) used VarUnpacker::unpack_var which generated direct
swizzle assignments (vb_geom.xy = packed_geometry_0.xy), never struct
constructors in the unpack path. The r25 branch is not affected.

Fix: generate per-sub-field assignments for struct-typed variables in
the main() geometry/instance/varying unpack code, matching the r25
behavior. Struct constructors in user shader function bodies are
unaffected.

* fix(android): Proper fullscreen support on API 30+ and FullscreenWindow/NormalizeWindow ops

Android platform changes:
- Add CxOsOp::FullscreenWindow handler: sets os.fullscreen flag and calls JNI setFullScreen
- Add CxOsOp::NormalizeWindow handler: clears os.fullscreen and exits fullscreen

MakepadActivity.java:
- onCreate: On API 30+, switch from legacy Theme.NoTitleBar.Fullscreen to
  Theme_DeviceDefault_NoActionBar to avoid FLAG_FULLSCREEN conflict
- onCreate: Call setDecorFitsSystemWindows(false) and set
  LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS before setContentView so SurfaceView
  is laid out at y=0 from the start
- Refactor setFullScreen into applyFullScreen with proper WindowInsetsController
  API on API 30+ (hide/show statusBars + navigationBars)
- Legacy path (pre-API 30) uses full immersive sticky flags
- ResizingLayout.onApplyWindowInsets: Return WindowInsets.CONSUMED to prevent
  system bar insets from offsetting child SurfaceView

* Fix Android touch radius DPI scaling and revert aggressive fullscreen inset handling

- Divide touch.radius by dpi_factor alongside touch.abs for correct touch sizing
- Return insets instead of consuming them in ResizingLayout so child views get proper insets
- Remove premature setDecorFitsSystemWindows/cutout mode override; let apps opt in via FullscreenWindow

* Make DrawImage image_scale/image_pan fields pub

Needed by external crates (e.g. servo makepad_shell) that create custom
widgets using DrawImage and need to flip the Y axis for GL render textures.

---------

Co-authored-by: ant <ant@offline.click>
2026-02-21 10:30:56 +01:00
Admin
a98c49dadd sortable drawcalls 2026-02-21 10:05:07 +01:00
Admin
e734c3f1f6 fix studio reload 2026-02-21 08:58:31 +01:00
Admin
f38e030a1e fix studio reload 2026-02-21 08:54:58 +01:00
Admin
f6bafb9a82 splats without sort yet 2026-02-21 08:43:52 +01:00
Admin
74059d928c drawcall fixing 2026-02-21 06:50:39 +01:00
Admin
7cc23e477e fix 2026-02-20 23:58:48 +01:00
offline-ant
5532faabfc
Add Servo embedding support: render texture API, EGL access, Image Y-flip (#861)
Three minimal changes to support embedding Servo as a WebView:

1. windowing_backend.rs: Make opengl_cx field pub (was pub(super))
   Servo needs EGL display/context/platform handles to create a shared
   GL context. egl_platform and egl_platform_display have no EGL query
   to retrieve them — they must come from whoever created the display.
   Matches Makepad convention (Cx fields are pub, no accessors).

2. opengl.rs: Add Cx::create_gl_render_texture(width, height)
   Eagerly allocates a RenderBGRAu8 texture and returns (Texture, gl_id).
   Needed because Makepad allocates GL textures lazily during render,
   but Servo needs the GL texture ID at init to attach to its FBO.
   No existing API for eager allocation (update_render_target and
   cx.os.gl() are both pub(crate)).

3. image.rs: Add is_render() Y-flip in Image widget
   FBO render targets are Y-up in OpenGL; Makepad is Y-down.
   CachedView handles this via sample2d_rt shader, but Image widget
   was untested with render textures. set_texture() accepts them but
   displays upside-down. Fixes a genuine gap — no existing Makepad
   code puts RenderBGRAu8 into an Image widget.

Co-authored-by: ant <ant@offline.click>
2026-02-20 16:36:53 +01:00
Admin
6fdcbc923b portal list unstuck 2026-02-20 16:05:31 +01:00
Admin
e423ba59e5 drawcall ordering and git ui 2026-02-20 15:47:56 +01:00
Julián Montes de Oca
88abfedd24
Restore IME support (#860)
* Restore IME support

* Remove unused voice handling methods from Window
2026-02-20 14:06:02 +01:00
Admin
ebd3a6dd6d fix overstripping 2026-02-20 10:30:25 +01:00
Admin
0eda8b1a99 voice 2026-02-20 10:16:18 +01:00
Admin
4581ed7bc3 cleanup 2026-02-20 10:04:49 +01:00
Admin
dc157d9853 voice input final 2026-02-20 09:59:22 +01:00
Admin
bfbf83e7b9 0.6s 2026-02-20 08:57:53 +01:00
Admin
6c800b6319 0.9s 2026-02-20 08:43:08 +01:00
Admin
c7e3a46a8e 1.5s 2026-02-20 07:52:58 +01:00
Admin
d599904ea4 1.6s 2026-02-20 01:01:21 +01:00
Admin
8490ef12e3 1.6s 2026-02-19 23:44:33 +01:00
Admin
ef7bdb09ac 1.6s 2026-02-19 23:37:16 +01:00
Admin
af9e8d8616 metal otw 2026-02-19 22:58:15 +01:00
Admin
a18c1f15d6 metal otw 2026-02-19 22:11:16 +01:00
Admin
32d7c14b29 metal otw 2026-02-19 21:21:30 +01:00
Admin
e14f84ee5d make voice optional 2026-02-19 21:16:57 +01:00
Admin
6335a3e660 metal otw 2026-02-19 21:00:49 +01:00
Admin
408b6273d8 metal otw 2026-02-19 20:49:08 +01:00
Admin
4d475a6b88 build fixups 2026-02-19 18:15:34 +01:00
Admin
e56080623f metal otw 2026-02-19 18:15:34 +01:00
Admin
6a6e39f933 metal otw 2026-02-19 18:15:34 +01:00
Julián Montes de Oca
2b250ff622
Restore video widget (#857) 2026-02-19 16:43:41 +01:00
Admin
909dafcaab font change 2026-02-19 15:36:56 +01:00
Admin
bc2f3fc1db voice PTT 2026-02-19 15:11:46 +01:00
Admin
a1a38a862f cleanup 2026-02-19 14:28:24 +01:00
Admin
0e845c991a voice input 2026-02-19 14:13:07 +01:00
Admin
a97de6db2e fix 2026-02-19 14:10:39 +01:00
Admin
0cf4a8e3ca nicer dots 2026-02-19 09:21:14 +01:00
Admin
c058dee74b terminal title 2026-02-19 09:01:47 +01:00
Admin
2abd0c89b2 websocket jitter 2026-02-19 08:51:28 +01:00
Admin
b8f1aacfd2 file icon color 2026-02-19 08:39:25 +01:00
Admin
894c485d50 fix 2026-02-19 08:35:09 +01:00
Admin
d23b1b06a0 git dots 2026-02-19 08:31:54 +01:00
Admin
1581943963 git dots 2026-02-19 08:03:39 +01:00
Admin
0a71358169 terminal split 2026-02-19 07:18:43 +01:00
Admin
0d682afd4a cleanup buildmanager 2026-02-19 07:11:55 +01:00
Admin
dd7e125d92 ai agent ui control 2026-02-18 21:31:04 +01:00
Admin
c2342aedfb ui automation 2026-02-18 21:28:46 +01:00
Admin
a5510b3f53 ui proto 2026-02-18 21:10:25 +01:00
Admin
20e79994d4 change subprocess plumbing to websocket 2026-02-18 14:06:44 +01:00
Admin
d32b7ca004 optimize inflate/deflate 2026-02-18 13:28:39 +01:00
Admin
c142ae342d make fast inflate fast 2026-02-18 11:52:32 +01:00
Admin
0ed063f951 fast inflate for git lib 2026-02-18 11:31:25 +01:00
Admin
68cce922c9 fast deflate 2026-02-18 11:24:00 +01:00
Admin
107b2f625f git lib added 2026-02-18 10:04:26 +01:00
Admin
7656e2b537 fix selection accross items 2026-02-17 21:49:02 +01:00
Admin
ae50c3997e fix svg shadows 2026-02-17 21:09:04 +01:00
Admin
41a2aa4758 fix 2026-02-17 20:39:08 +01:00
Admin
d8967385bc fix 2026-02-17 19:25:31 +01:00
Admin
a298eb62f3 terminal work/ webp 2026-02-17 18:11:36 +01:00
Admin
c6d8df41ec rounded boxes 2026-02-17 11:38:21 +01:00
Admin
7b45e69fed 3d charts otw 2026-02-17 10:21:14 +01:00
Admin
8e7985507e instance pbr 2026-02-17 09:56:13 +01:00
Admin
db10fc4ceb instance pbr 2026-02-17 09:52:04 +01:00
Admin
66c6b010e4 instance pbr 2026-02-17 08:27:27 +01:00
Admin
953bb4a4e1 cleanup 2026-02-17 08:04:42 +01:00
Admin
b7739e2d62 cleanup 2026-02-17 07:47:00 +01:00
Admin
8f74844b98 opengl ok 2026-02-16 23:21:59 +01:00
Admin
30ea63fc30 depthstencil 2026-02-16 22:19:38 +01:00
Admin
cb55db99cb windows 2026-02-16 22:00:26 +01:00
Admin
f05b9ca9ce physics 2026-02-16 18:10:26 +01:00
Admin
484bbb91f1 physics 2026-02-16 18:07:13 +01:00
Admin
e013be1894 physics 2026-02-16 18:06:57 +01:00
Admin
f5a25d726c linux depth 2026-02-16 17:35:49 +01:00
Admin
41746c0346 fix up things 2026-02-16 17:27:28 +01:00
Admin
fa114f59ef openGL 2026-02-16 17:19:02 +01:00
Admin
cd1c1753f2 gltf example! 2026-02-16 15:51:24 +01:00
Admin
e854539b2a fix warnings 2026-02-16 11:58:51 +01:00
Admin
90dfde084c ios support 2026-02-16 10:57:27 +01:00
Admin
250566d044 vulkan vendoring completed 2026-02-16 09:10:27 +01:00
Sabin Regmi
3773d8db2d
Use saturating_sub for empty collections (#856)
Replace occurrences of self.len() - 1 with self.len().saturating_sub(1) in serialization code to avoid underflow when serializing empty slices/maps. Adds a unit test in serde_json to confirm an empty HashMap serializes to "{}". Changes touch libs/micro_serde/src/serde_json.rs and libs/micro_serde/src/serde_ron.rs.
2026-02-16 07:21:02 +01:00
Kevin Boos
26b1d49e53
cargo_makepad: return actual error code instead of silent failure (#854)
This ensures that if an error occurs in a `cargo makepad` invocation,
the `cargo makepad` binary will actually return an error
(and importantly, an error exit code) instead of returning a success
exit code of `0` in all cases.
This allows CI passes and automated testing to work as normal.

Extra: cleaned up "show help" output
2026-02-16 07:20:47 +01:00
Admin
e2f30f16e9 clean 2026-02-15 19:21:10 +01:00
Admin
670944980b clean build 2026-02-15 18:43:19 +01:00
Admin
1c8ceb4693 cleanup widget tree 2026-02-15 18:34:42 +01:00
Admin
5ad1df51f1 vulkan otw + widget fixes 2026-02-15 16:04:24 +01:00
Admin
acdac25dfa android vulkan otw 2026-02-15 14:18:42 +01:00
Admin
c7fed2da23 android back 2026-02-15 13:30:18 +01:00
Admin
f430c0a0b5 headless done 2026-02-15 13:06:09 +01:00
Admin
969b8bf3b3 headless optimized 2026-02-15 12:42:58 +01:00
Admin
874c3c490a headless optimized 2026-02-15 12:31:30 +01:00
Admin
9ee2a0b711 headless multithreading 2026-02-15 12:21:22 +01:00
Admin
8dc9efe96b headless otw 2026-02-15 11:48:51 +01:00
Admin
c2e2af5d86 headless otw 2026-02-15 11:41:33 +01:00
Admin
29b2778114 headless otw 2026-02-15 09:48:28 +01:00
Admin
309626bd19 web almost there 2026-02-14 22:49:23 +01:00
Admin
bf6ba654bd web almost there 2026-02-14 22:46:32 +01:00
Admin
f82f0abe0b otw 2026-02-14 22:14:54 +01:00
Admin
4d036ecdc4 otw 2026-02-14 22:14:26 +01:00
Admin
1db3ff2d08 webGL2 2026-02-14 22:07:26 +01:00
Admin
210606aee7 linux complete! 2026-02-14 19:42:15 +01:00
Admin
6f7f864778 linux wayland dnd 2026-02-14 19:37:09 +01:00
Admin
976b6af59d linux wayland clipboard1 2026-02-14 19:31:58 +01:00
Admin
0579dfb81c linux done! 2026-02-14 19:25:46 +01:00
Admin
762b72c8b6 almost shared mem linux 2026-02-14 19:10:45 +01:00
Admin
345028f2bb almost shared mem linux 2026-02-14 19:09:48 +01:00
Admin
53551f85b7 almost shared mem linux 2026-02-14 19:05:54 +01:00
Admin
fa98587e89 linux 2026-02-14 18:07:21 +01:00
Admin
b791dcc741 wayland working 2026-02-14 17:19:21 +01:00
Admin
53aa1ab325 wayland working 2026-02-14 17:10:17 +01:00
Admin
8a60aee681 x11 working 2026-02-14 16:53:27 +01:00
Admin
7969fd4ee1 fix 2026-02-14 15:12:37 +01:00
Admin
d27ab1e1f6 windows terminal 2026-02-14 14:23:07 +01:00
Admin
1cdc947ecb windows runs! 2026-02-14 13:59:45 +01:00
Admin
5fb238f0b1 windows runs! 2026-02-14 13:43:44 +01:00
Admin
641c367e89 fix remote 2026-02-14 12:52:33 +01:00
Admin
e72fce3c55 fix 2026-02-14 12:42:11 +01:00
Admin
4524c8e90a windows otw 2026-02-14 12:32:53 +01:00
Admin
3a0691697d windows otw 2026-02-14 12:28:02 +01:00
Admin
8e84ba7f56 windowsrs vendored 2026-02-14 12:27:38 +01:00
Admin
35e2b31dce vendored windows 2026-02-14 11:54:41 +01:00
Admin
e121a1d7b2 windows rs strip 2026-02-14 11:32:16 +01:00
Admin
476c19a473 terminal 2026-02-14 09:51:47 +01:00
Admin
73e5a06053 fix selection
;
2026-02-13 19:22:12 +01:00
Admin
2b0329a856 terminal almost 2026-02-13 19:00:23 +01:00
Admin
99e1497ae6 missing 2026-02-13 17:58:58 +01:00
Admin
2d898b3e1c draw groups 2026-02-13 17:58:20 +01:00
Admin
599599a5a6 passable terminal 2026-02-13 16:58:26 +01:00
Admin
d2393f4c44 terminal coalesce tweaks 2026-02-13 16:48:40 +01:00
Admin
a1717b781c fix tree 2026-02-13 16:11:31 +01:00
Admin
cff0582831 simple ui id system 2026-02-13 15:59:21 +01:00
Admin
0bb54c29cf refactor widget tree 2026-02-13 14:49:21 +01:00
Admin
8f46f52f28 cleanup 2026-02-13 13:22:37 +01:00
Admin
09d8babcbb terminal finnicking 2026-02-13 10:49:42 +01:00
Admin
b5a213e5fb terminal finnicking 2026-02-13 10:44:49 +01:00
Admin
ee70de2ba2 improve terminal 2026-02-13 10:08:58 +01:00
Admin
0aefdc4ddb terminal otw 2026-02-13 09:54:08 +01:00
Admin
959b6edc0d terminal otw 2026-02-13 09:41:16 +01:00
Admin
066d020ac9 terminal otw 2026-02-13 08:48:04 +01:00
Admin
3f0830a0da terminal in studio 2026-02-13 08:27:49 +01:00
Admin
b675e78934 image search 2026-02-12 21:56:41 +01:00
Admin
344af4d1cb revert splashmd 2026-02-12 21:15:00 +01:00
Admin
8b4f116a75 add http resources 2026-02-12 20:20:27 +01:00
Admin
10ab1838d5 add http resources 2026-02-12 20:20:17 +01:00
Admin
b9f957ecef todo app 2026-02-12 19:58:49 +01:00
Admin
9edced7a6c todo app in splash 2026-02-12 19:32:35 +01:00
Admin
d1fdcbe3d6 fix up widget tree search in fresh uis 2026-02-12 16:50:39 +01:00
Admin
156ce2e41b fix pdf 2026-02-12 15:29:15 +01:00
Admin
6233d5221f cleanup 2026-02-12 15:16:31 +01:00
Admin
e27aa52b0b cleanup 2026-02-12 15:13:05 +01:00
Admin
7915b520b2 cleanup 2026-02-12 15:02:01 +01:00
Admin
2cb357a246 cleanup 2026-02-12 14:57:54 +01:00
Admin
60447d41e6 First 2.0 2026-02-12 14:54:11 +01:00
Admin
1ba7b2f7fe First 2.0 2026-02-12 14:52:33 +01:00
Admin
30df081776 task 2026-02-12 14:00:53 +01:00
Admin
52cbec46b2 finish networking api for splash 2026-02-12 13:43:24 +01:00
Admin
f09667328a refactor script<>widget 2026-02-12 12:35:21 +01:00
Admin
bc92ceb5e2 fix handle fallback 2026-02-12 10:44:25 +01:00
Admin
ed18b1768d splash maps 2026-02-12 09:04:52 +01:00
Admin
f06d6da283 studio fixes 2026-02-12 08:53:46 +01:00
Admin
c134f5c252 more sturdy 2026-02-12 08:34:15 +01:00
Admin
9488f6e6c5 claude acp 2026-02-12 07:58:08 +01:00
Admin
96420e1651 fix uid widget tree 2026-02-12 07:50:24 +01:00
Admin
5ee33c49c1 maps 2026-02-12 07:47:13 +01:00
Admin
033ee387e3 fix uid widget tree 2026-02-12 07:36:20 +01:00
Admin
988c2d7a77 fix uid uniqueness 2026-02-12 07:21:50 +01:00
Admin
2710bef7a1 widget tree refactor 2026-02-12 07:05:13 +01:00
Admin
56d47582bb cleanup 2026-02-11 17:19:18 +01:00
Admin
8a1f8b1d02 fix shader 2026-02-11 14:27:28 +01:00
Admin
bb420272cf better sdf/msdf strategy 2026-02-11 10:43:42 +01:00
Admin
fea7e5ffc9 fixup 2026-02-10 22:33:38 +01:00
Admin
2a303e28ff fix 2026-02-10 20:40:02 +01:00
Admin
d5b88c6af0 cleanup 2026-02-10 20:34:53 +01:00
Admin
63c6cbec53 cleanup 2026-02-10 20:31:22 +01:00
Admin
ff0066d448 vertical text 2026-02-10 19:43:36 +01:00
Admin
3febb0c552 compile 2026-02-10 18:27:25 +01:00
Admin
af7d507488 compile 2026-02-10 18:26:51 +01:00
Admin
d3d8a5784f charts 2026-02-10 16:01:09 +01:00
Admin
d3119a4a80 pdf select 2026-02-10 15:44:26 +01:00
Admin
b486149d82 vector and map 2026-02-10 14:43:38 +01:00
Admin
4b44f9f244 gpu vector rendering 2026-02-10 11:06:33 +01:00
Admin
33d3c5de1d adaptive async MSDF 2026-02-10 10:03:20 +01:00
Admin
31c3280717 math with sdf rendering 2026-02-10 01:04:00 +01:00
Admin
0b63766ac3 svg fonts work 2026-02-10 00:47:55 +01:00
Admin
3dca3ce432 splash otw 2026-02-09 18:51:45 +01:00
Admin
ee1dc18164 html parser 2026-02-09 16:46:26 +01:00
Admin
9d84fe240b regex 2026-02-09 16:35:52 +01:00
Admin
45e49cf0cb optimize csg 2026-02-09 14:12:46 +01:00
Admin
1ec5c0de84 optimize aabb 2026-02-09 14:00:46 +01:00
Admin
387b57e23a csg optimisations 2026-02-09 13:44:46 +01:00
Admin
0fba4ca504 cleanup 2026-02-09 13:20:41 +01:00
Admin
b8d26ff50f csg 2026-02-09 13:18:55 +01:00
Admin
7bbc6050bc splash vector 2026-02-09 08:03:49 +01:00
Admin
a84695d95e working 2026-02-09 00:41:09 +01:00
Admin
ed28b89658 working 2026-02-09 00:39:31 +01:00
Admin
eadc371149 fix svg 2026-02-08 21:39:55 +01:00
Admin
4a199c6227 fix svg 2026-02-08 21:17:46 +01:00
Admin
5ac4d48d65 svg instancing 2026-02-08 20:20:47 +01:00
Admin
46b11d555e fix temp allocs in svg 2026-02-08 19:47:56 +01:00
Admin
a52134bfca fix temp allocs in svg 2026-02-08 19:44:21 +01:00
Admin
072f6f5690 stable endcaps 2026-02-08 19:33:20 +01:00
Admin
96513d8d72 svg pixelshaders 2026-02-08 18:52:51 +01:00
Admin
05a9e7c4f6 lets try this metal glitch fix 2026-02-08 16:19:44 +01:00
Admin
71434bdd72 svg done! 2026-02-08 16:16:38 +01:00
Admin
668b82e379 svg otw 2026-02-08 16:00:51 +01:00
Admin
ab8ed845c8 svg otw 2026-02-08 15:47:56 +01:00
Admin
23880be6c8 cleanup 2026-02-08 12:47:26 +01:00
Admin
c3d5d044d1 cleanup 2026-02-08 12:39:38 +01:00
Admin
e6a0f027b7 splitup 2026-02-08 12:35:48 +01:00
Admin
6c720bf04b inhousing svg 2026-02-08 12:24:55 +01:00
Admin
8c84bf6959 svg otw 2026-02-08 11:37:35 +01:00
Admin
86c9130ac9 fix up ai gen mistakes for splash 2026-02-07 19:30:23 +01:00
Admin
6435218097 fix up ai gen mistakes for splash 2026-02-07 19:30:07 +01:00
Admin
e5977d2310 fix up ai gen mistakes for splash 2026-02-07 17:59:54 +01:00
Admin
d94ec93372 id change 2026-02-07 16:53:33 +01:00
Admin
edc9f81c17 streaming splash otw 2026-02-07 13:59:34 +01:00
Admin
9b04b5ac69 streaming! 2026-02-07 10:16:55 +01:00
Admin
07941e1b8c fix windows 2026-02-06 19:36:36 +01:00
Admin
3a0de192af debugging streaming in splash 2026-02-06 17:39:20 +01:00
Admin
b59d6b1002 splash widget otw 2026-02-06 15:44:30 +01:00
Eddy Bruel
dc953df2c4 Clear layout cache in text input if turtle width changes. 2026-02-06 15:38:00 +01:00
Admin
78def5b61f debugging draw issue 2026-02-06 11:33:23 +01:00
Admin
8acbf54bdd debugging draw issue 2026-02-06 11:32:22 +01:00
Admin
15667d35b7 debugging draw issue 2026-02-06 11:27:10 +01:00
Admin
7f1c9713f8 cross child selection working for code editor 2026-02-06 10:30:57 +01:00
Admin
99026683dd selection workign somewhat, code editor otw 2026-02-06 09:55:11 +01:00
Admin
79d2ba8595 finally smooth animation 2026-02-05 20:52:15 +01:00
Admin
e1a64a2735 finally smooth animation 2026-02-05 19:51:58 +01:00
Admin
899ab3ac94 finally smooth animation 2026-02-05 19:47:32 +01:00
Admin
60a7ee9f82 remove horrible generics 2026-02-05 19:37:23 +01:00
Admin
0ba3151f84 selection fix portallist 2026-02-05 17:29:27 +01:00
Admin
15f1a314e6 selection fix portallist 2026-02-05 17:08:52 +01:00
Admin
7ae85ce36f selection fix portallist 2026-02-05 16:51:30 +01:00
Admin
57bf1e22ec fix tail scroll animation 2026-02-05 16:26:27 +01:00
Admin
752dc1d617 add area extension for animation 2026-02-05 16:22:40 +01:00
Admin
a5a30207c2 fix portal list smooth tail at end 2026-02-05 13:56:16 +01:00
Admin
d8a91df623 fix portal list smooth tail at end 2026-02-05 13:55:33 +01:00
Admin
a05596500b clickable items with text selection in portal list 2026-02-05 12:35:07 +01:00
Admin
80c58eb6f4 first pass ai lib 2026-02-05 12:13:03 +01:00
Admin
ae97be726d move 2026-02-05 11:11:38 +01:00
Admin
c6fcc3bdbf move 2026-02-05 11:10:34 +01:00
Admin
26815fad10 move 2026-02-05 11:09:57 +01:00
Admin
8cec6cf365 move script crate from libs/ to platform2/ 2026-02-05 11:08:08 +01:00
Admin
0b738f92d3 pixel smooth scrolling for portal list 2026-02-05 11:02:28 +01:00
Admin
85cb77ea30 selectable log list 2026-02-05 10:41:17 +01:00
Admin
a905ce327c selectable textflow/html in portal list 2026-02-05 10:16:19 +01:00
Admin
9e820b53cb selectable textflow/html in portal list 2026-02-05 10:14:02 +01:00
Admin
fa69ae106c selectable textflow/html in portal list 2026-02-05 10:13:07 +01:00
Admin
93246fe7f2 selectable textflow 2026-02-05 08:56:32 +01:00
Admin
8229fa4669 dep cleanup 2026-02-05 07:55:16 +01:00
Admin
06d046574f inhouse all deps 2026-02-04 20:14:11 +01:00
Admin
110ac0e40d kurbo: fix warnings, remove more unused code (CuspType, regularize, detect_cusp, cubics_to_quadratic_splines) 2026-02-04 20:02:15 +01:00
Admin
4654be8549 strip kurbo: remove stroke, fit, moments, simplify, offset, triangle, translate_scale, mindist 2026-02-04 19:58:41 +01:00
Admin
ecb31f83c3 stripping continues 2026-02-04 19:49:57 +01:00
Admin
4c831e90c8 stripping continues 2026-02-04 19:39:39 +01:00
Admin
3863209c39 buildtime fixup 2026-02-04 19:31:38 +01:00
Admin
6456dcd7cb inhouse all libs for ai stripping 2026-02-04 18:57:13 +01:00
admin
f6f07cb612 studio recompile 2026-02-04 15:44:16 +01:00
admin
986d36ad72 hover out state 2026-02-04 14:52:00 +01:00
admin
51f43e08f6 makepad studio without xpc service 2026-02-04 14:38:46 +01:00
admin
97ef7e4ddd fix x process without XPC bounce 2026-02-04 14:37:59 +01:00
admin
863a05cef2 fix tab drop cloning 2026-02-04 14:16:08 +01:00
admin
20aeb2f3d3 fix filetree 2026-02-04 14:15:27 +01:00
admin
b98fb4eb40 fix scroll to end in log 2026-02-04 13:56:06 +01:00
Admin
053d6b57d3 code editor word wrap cursor fix 2026-02-04 12:03:51 +01:00
Admin
92f1cd4ea5 fix filetree 2026-02-04 11:33:07 +01:00
Admin
28b112c016 fix lag 2026-02-04 11:28:59 +01:00
Admin
3ba3ec9b70 fix cargo 2026-02-04 11:08:28 +01:00
Admin
e23a1aa7b5 studio fixes 2026-02-04 11:05:31 +01:00
Admin
11329e51cb studio papercuts 2026-02-04 10:57:19 +01:00
Admin
939ba7f77b studio papercuts 2026-02-04 10:53:39 +01:00
Admin
a7fd72dd3f add better logview features 2026-02-04 10:35:37 +01:00
Admin
7a2d7b2327 fix text input losing focus 2026-02-04 10:22:06 +01:00
Admin
cb3a7f65f3 running works 2026-02-04 10:10:28 +01:00
Admin
45073fca83 running works 2026-02-04 10:06:26 +01:00
Admin
13ee17a68b checkgen optional 2026-02-04 09:59:13 +01:00
Admin
cd238c9c06 remove set_reffed 2026-02-04 09:45:53 +01:00
Admin
b3e4bdcb9e gc faster 2026-02-04 08:59:36 +01:00
Admin
24e61cfcf7 gc works 2026-02-04 08:54:47 +01:00
Admin
9c037711b3 fix 2026-02-03 17:46:08 +01:00
Admin
11e2c58325 eval shallow 2026-02-03 16:51:12 +01:00
Admin
5533a98861 gc fixed! 2026-02-03 15:14:00 +01:00
Admin
830003cd1a gc debugging otw 2026-02-03 13:00:52 +01:00
Admin
967bba81a0 gc work 2026-02-03 11:10:20 +01:00
Admin
e41b4d4711 script_apply_eval complete 2026-02-03 10:47:36 +01:00
Julián Montes de Oca
ba57a32b31
Fix missing changes from IME on some platforms (#852) 2026-02-02 20:51:38 +01:00
Admin
aadc68376d unclobber codce editor 2026-02-02 19:04:42 +01:00
Admin
4b55c1e3b0 icons 2026-02-02 18:53:02 +01:00
Admin
0c48db0969 studio otw 2026-02-02 17:11:03 +01:00
Admin
e31f860206 slides view 2026-02-02 13:05:32 +01:00
Admin
6acd73b3a3 slides view 2026-02-02 13:02:14 +01:00
Admin
8b9ce04f05 fix filetree, animator 2026-02-02 12:41:32 +01:00
Julián Montes de Oca
9e53e13d68
IME: Support on Android and Input Configuration (#839)
* WIP

* Improvements for cursor control

* Enhance text selection and key event handling for Samsung keyboard compatibility

* Default to multine inputtype in android

* WIP Input configurations

* Enhance iOS text input handling

* Cleanup

* Comment out 'Next' variant across platforms for future implementation.

* Rename IME Config API to match web APIs

* Fix Android emoji deletion by implementing UTF-16 code unit index conversion

* Proper ASCII-only input and improve iOS keyboard handling

* Replace 'is_numeric_only' with 'input_mode'

* Prevent pasting invalid characters

* Cleanup

* Cleanup

* Hide clipboard actions on text change

* Cleanup

* Unify keyboard event types and fix iOS text input regressions

Unified TextInputEvent with new fields for better IME support across platforms:
- Added `composition` field for IME preview ranges (CJKinput)
- Added `full_state_sync` for complete buffer state (Android approach)
- Added `replace_range` for autocorrect/suggestion replacements (iOS approach)

All platforms: Standardized on CharOffset for character position handling

* Prevent text synchronization with the platform during active composition

* Add UITextInputCurrentInputModeDidChangeNotification support

* Add underline for active IME composition in TextInput

* Improve editor action handling for multiline inputs on Android

* Enhance IME composition tracking and clipboard action handling in TextInput

* Simplify IME state handling on Android

* Cleanup IME handling on iOS

* Cleanup

* Cleanup IME handling on iOS

* Improve docs/comments

* Improve docs/comments

* Add floating cursor support for keyboard trackpad in iOS

* Refine IME handling in TextInput to prevent iOS buffer loss during composition updates

* Move UITextInput protocol implementation into its own module

* Move MakepadInputConnection into its own file

* Cleanup

* Improve general IME handling in TextInput. Improve docs and comments

* Refactor text input configuration to separate soft keyboard settings for mobile platforms.
2026-02-02 11:48:21 +01:00
Admin
e082842ba8 make $ escape automatically 2026-02-01 21:41:20 +01:00
Admin
d9d0b8ce5b filetree almost 2026-02-01 21:07:09 +01:00
Admin
af5ff2a59e tooltips need work 2026-02-01 20:22:21 +01:00
Admin
5052bf40af modal 2026-02-01 20:02:03 +01:00
Admin
e6e77001e2 expandable panel 2026-02-01 19:56:55 +01:00
Admin
e0aafdf8ce fix menu enum 2026-02-01 19:28:05 +01:00
Admin
44928b763c cleanup 2026-02-01 18:36:09 +01:00
Admin
2405c28b2a add dynamic enums and desktop_button 2026-02-01 18:22:54 +01:00
Admin
c710e149d6 add dynamic enums and desktop_button 2026-02-01 18:17:40 +01:00
Admin
f1a2d723c9 dock working 2026-02-01 17:59:33 +01:00
Admin
84c1d24254 dock working 2026-02-01 17:46:59 +01:00
Admin
c2b022b38a fix portal list response to mousewheel 2026-02-01 14:04:39 +01:00
Admin
3373883bf3 better errors for failing enum apply 2026-02-01 13:59:47 +01:00
Admin
1fc3682e4a better errors for failing enum apply 2026-02-01 13:54:05 +01:00
Admin
d6579a5769 fix markdown and textflow 2026-02-01 12:14:31 +01:00
Admin
a0c71abc2e nested destructuring 2026-02-01 11:53:31 +01:00
Admin
3a43b7824c basic destructuring 2026-02-01 11:36:05 +01:00
Admin
920740767c for loop fixes 2026-02-01 10:43:52 +01:00
Admin
d0d114ab0d more script engine tests 2026-02-01 09:36:05 +01:00
Admin
7b3fb2e976 more script engine tests 2026-02-01 09:33:00 +01:00
Admin
3bdc6bab17 more script engine tests 2026-02-01 08:57:03 +01:00
Admin
e235cd7005 more script engine tests 2026-02-01 08:53:23 +01:00
Admin
83ac77f8ba shader enums 2026-02-01 08:17:19 +01:00
Admin
5a672aac7b add match to script engine 2026-01-31 18:10:08 +01:00
Admin
b4c2915960 add match to script engine 2026-01-31 17:54:11 +01:00
Admin
e1660e426d portal list 2026-01-31 17:00:57 +01:00
Admin
c128a82cde portal list 2026-01-31 17:00:34 +01:00
Admin
b4a8333a22 portal list 2026-01-31 17:00:25 +01:00
Admin
924d3801d0 portal list 2026-01-31 16:42:06 +01:00
Admin
b0381ccd20 scrollbar fixed 2026-01-31 16:22:46 +01:00
Admin
571329122c animator 2026-01-31 14:48:03 +01:00
Admin
0f1fff61c3 cleaner startup flow 2026-01-31 13:36:18 +01:00
Admin
7d505faf88 splitter 2026-01-31 13:32:40 +01:00
Admin
6927db1bb4 radio button 2026-01-31 13:27:10 +01:00
Admin
4ea0333415 checkbox 2026-01-31 13:06:04 +01:00
Admin
a36f0a102d dropdown and popupmenu 2026-01-31 12:47:18 +01:00
Admin
19f0518a3e faster script engine 2026-01-31 12:22:49 +01:00
Admin
54eb8ceb3d faster script engine 2026-01-31 12:06:31 +01:00
Admin
602bf9f07f faster script(more unsafe) 2026-01-31 11:48:23 +01:00
Admin
2fff9135a5 cleanup 2026-01-31 11:26:26 +01:00
Admin
2c6ed2f734 cleanup 2026-01-31 11:22:49 +01:00
Admin
111894787a slider with textinput 2026-01-31 11:01:27 +01:00
Admin
f7111dd412 massive script refactor to allow nested vm swapping on and off of cx 2026-01-31 10:33:11 +01:00
Admin
e3f6ed6bd4 textinput compiles 2026-01-31 08:23:21 +01:00
Admin
c7a34d973f loading spinner 2026-01-30 20:11:48 +01:00
Admin
f53e1e9575 image works 2026-01-30 19:31:42 +01:00
Admin
5a75c45e3f svg icons on buttons 2026-01-30 18:43:14 +01:00
Admin
abbb742c36 clean out the button shader a bit 2026-01-30 17:43:43 +01:00
Admin
8fd847c1ad fix up postfix methods for concise style shaders 2026-01-30 17:16:02 +01:00
Admin
1063c3cec3 fix up postfix methods for concise style shaders 2026-01-30 17:02:38 +01:00
Admin
42485455f5 button is back! 2026-01-30 16:17:45 +01:00
Admin
c182b0a9c0 button is back! 2026-01-30 16:13:32 +01:00
Admin
c023554c5f add proper pod*pod operator support 2026-01-30 16:07:28 +01:00
Admin
87dc4de4aa fix font quickly 2026-01-30 15:22:37 +01:00
Admin
707ce113e6 DSL substructure assign even cleaner 2026-01-30 15:15:55 +01:00
Admin
ea55dc9ad3 DSL substructure assign even cleaner 2026-01-30 15:08:33 +01:00
Admin
80581d92d4 added label 2026-01-30 14:58:03 +01:00
Admin
aca1828886 updated zune libraries to latest 2026-01-30 14:01:42 +01:00
Admin
e74db7f165 make deps optional 2026-01-30 13:49:15 +01:00
Admin
868f96bee6 reduce monomorphisation costs 2026-01-30 13:18:57 +01:00
Admin
f33e4bdd44 cleanup 2026-01-30 11:02:13 +01:00
Admin
f4c626a864 we have a window again! 2026-01-30 10:38:40 +01:00
Admin
81daa73f17 add script wrappers to window/pass 2026-01-30 10:20:45 +01:00
Admin
e09c64ebd1 widget refactor otw 2026-01-30 09:17:14 +01:00
Admin
e248f240b2 oops\ 2026-01-29 19:08:12 +01:00
Admin
80c656fed8 clean up view_ui 2026-01-29 18:10:40 +01:00
Admin
20db041c33 add shader error handling 2026-01-29 17:41:23 +01:00
Admin
cd8a7bac62 custom handling of value casting 2026-01-29 17:26:37 +01:00
Alex
48c88705ec
fix: rename Math widget to MathView to avoid shader namespace collision (#848)
The `pub Math` DSL registration in math_widget shadows the shader
built-in `Math` namespace (defined in draw/src/shader/std.rs), which
provides `Math::random_2d` and `Math::rotate_2d`. This causes runtime
shader compilation errors in every widget that uses `Math::random_2d`
for color dithering (Slider, Button, Label, CheckBox, Icon, TextInput,
Tab, LinkLabel, PopupMenu, DropDown, RadioButton, View).

Rename the widget DSL name from `Math` to `MathView`, consistent with
Makepad naming conventions (CodeView, CircleView, RoundedView, etc.).
The Rust struct remains `Math` — only the DSL registration name changes.

Downstream users need to update:
  `inline_math = <Math> {}` → `inline_math = <MathView> {}`
  `display_math = <Math> {}` → `display_math = <MathView> {}`

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:14:09 +01:00
Admin
fe4dc7ce60 cleanupt 2026-01-29 17:03:55 +01:00
Admin
eec6e89846 view almost there 2026-01-29 16:28:49 +01:00
Admin
e35751cf3f error type compaction 2026-01-29 13:14:51 +01:00
Admin
4b5ad14ce7 add more detailed type errors 2026-01-29 12:58:45 +01:00
Admin
5272d7c996 add more detailed type errors 2026-01-29 12:48:39 +01:00
Admin
b37635e4f7 add nice errors to script engine and shader compiler 2026-01-29 12:34:46 +01:00
Admin
abb1b4ef85 script error refactor done 2026-01-29 11:24:01 +01:00
Admin
d1768c677a script error refactor otw 2026-01-29 10:17:45 +01:00
Admin
e0e910ccc8 basic animator working 2026-01-28 18:38:58 +01:00
admin
a5f398085f animator otw 2026-01-28 16:25:03 +01:00
Admin
f0fac7e1e5 fix proto fields 2026-01-28 12:11:15 +01:00
Admin
89592532e5 add apply_default for animator 2026-01-28 11:01:55 +01:00
Admin
8350b6bf86 working scrollbar 2026-01-27 19:34:02 +01:00
Admin
74d181563a slider works 2026-01-27 17:45:02 +01:00
Admin
17ab6d60f3 animator otw 2026-01-27 15:54:21 +01:00
Admin
51e8fc9cb2 preparing animator 2026-01-27 14:45:10 +01:00
Admin
91f068106c theme refactor 2026-01-27 09:51:23 +01:00
Admin
ca08a99c5e refactor apply 2026-01-27 09:10:31 +01:00
Admin
77c33adcc3 rename Apply 2026-01-26 20:37:41 +01:00
Admin
ebcee59cf9 splat operator 2026-01-26 19:51:02 +01:00
Admin
c7c7966e93 proto field += 2026-01-26 18:14:48 +01:00
Admin
f1a2271ff2 proto field fonr 2026-01-26 17:59:32 +01:00
Admin
d1d727f6df proto field otw 2026-01-26 16:27:17 +01:00
Admin
9d43f6ec64 add support for scope uniforms 2026-01-26 12:10:37 +01:00
Admin
71fe89d3c1 theme uniform buffers 2026-01-26 11:10:16 +01:00
Admin
7145dc7298 theme uniform buffers 2026-01-26 10:53:34 +01:00
Admin
9f89b86821 theme methods 2026-01-26 10:26:42 +01:00
Admin
951467be88 theme vars otw 2026-01-26 09:41:00 +01:00
Admin
2b3640715a widgets2 barebones 2026-01-24 10:43:02 +01:00
Admin
e12b6cca4d shader compiler bits 2026-01-23 16:06:38 +01:00
Admin
bc34aacf6a shader compiler bits 2026-01-23 15:59:28 +01:00
Admin
edcd8d7854 text renders! 2026-01-23 15:26:13 +01:00
Admin
3271655c1c apple block oops 2026-01-23 10:49:26 +01:00
Admin
1d6672056b resource handling otw 2026-01-23 10:47:42 +01:00
Admin
e72677c880 windows compiles again 2026-01-22 15:41:19 +01:00
Admin
066a7aeeb7 split up refactor 2026-01-22 15:29:25 +01:00
Admin
774a07ce44 draw text compiles on metal 2026-01-22 11:24:12 +01:00
Kevin Boos
ba3b02b77d
Expose whether a native LongPress has occurred before a FingerMove (#846)
This bit of info was already tracked, but it wasn't exposed by the
`FingerMoveEvent` struct.

Doing so will allow widgets to differentiate between a gesture like
long-press then drag (for drag-n-drop) vs. just a regular drag
(for something like finger-based scrolling).
This is useful in TextInput, Dock, and any view that may respond
differently to a drag vs a "select then drag".
2026-01-22 08:49:49 +01:00
Kevin Boos
2898fb1367
Hide the Tooltip widget upon any click/tap, drag, or scroll event (#844)
* Hide the `Tooltip` widget upon any click/tap, drag, or scroll event

* Tooltip: handle raw events directly as to not impact `Hit` consumption
2026-01-21 19:41:31 +01:00
admin
cddca0b266 draw text otw 2026-01-21 17:00:45 +01:00
admin
8e4fec1ad4 working instance values 2026-01-21 15:06:35 +01:00
Admin
618e46a35a fix 2026-01-21 13:39:40 +01:00
Admin
ac629a08b1 metal renders a circle again! 2026-01-21 12:53:18 +01:00
Admin
4b1a422951 sdf now compiles 2026-01-21 11:49:18 +01:00
Admin
c94b9bcc54 sdf now compiles 2026-01-21 11:49:10 +01:00
Admin
88bd0b53d4 sdf shader lib otw 2026-01-21 11:00:20 +01:00
Admin
9968ac4a78 sdf lib parses 2026-01-21 10:09:22 +01:00
Admin
6194d0756c sdf shader lib otw 2026-01-21 09:54:03 +01:00
Admin
d2e5b10f79 remove derive_live 2026-01-21 08:34:05 +01:00
Eddy Bruel
7a5069495a Whitespace should never be wrapped 2026-01-20 20:40:17 +01:00
Admin
20fb64d074 First quad rendering on metal! 2026-01-20 15:40:55 +01:00
Admin
6c76e6a2c7 metal backend compiles! 2026-01-20 10:54:15 +01:00
Admin
20b8635520 metal shader generated 2026-01-19 14:55:34 +01:00
Admin
85bfa562bc renderer refactor otw 2026-01-19 12:20:44 +01:00
Admin
b3d6078837 renderer refactor otw 2026-01-19 12:20:44 +01:00
Jason Yau
4355f29c4b
fixed IME composition popup position for Windows (#841)
Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-01-17 17:57:36 +01:00
Admin
e97eb8f163 fix http server search 2026-01-16 18:54:15 +01:00
Admin
41f36f3481 wasapi chan limit 2026-01-16 16:49:50 +01:00
Admin
c68b62af96 wasapi chan limit 2026-01-16 16:34:46 +01:00
Admin
d0c0635dc8 fix wasapi 2026-01-16 10:39:51 +01:00
Admin
f2195130f9 fix wasapi 2026-01-16 10:33:39 +01:00
Admin
2d98142985 fix wasapi 2026-01-16 10:29:11 +01:00
Admin
63cc30997f fix wasapi 2026-01-16 10:22:09 +01:00
Admin
b965734cb7 fix wasapi 2026-01-16 10:03:29 +01:00
admin
6badfb0520 deref shader struct order 2026-01-14 15:38:23 +01:00
Admin
3b265e41cb fixing audiostream 2026-01-14 12:07:21 +01:00
Admin
6fd524eadc fixing audiostream 2026-01-14 11:58:27 +01:00
Admin
c1f4d84845 fixing audiostream 2026-01-14 11:49:11 +01:00
Admin
f84d8d1c99 fixing audiostream 2026-01-14 11:44:40 +01:00
Admin
e1aa4bffb0 fixing audiostream 2026-01-14 11:29:18 +01:00
Admin
a517bb17fa fixing audiostream 2026-01-14 11:24:55 +01:00
Admin
d3621ff749 fixing audiostream 2026-01-14 11:20:11 +01:00
Admin
7c06a84052 fixing audiostream 2026-01-14 11:14:19 +01:00
Admin
b0ed30c556 fixing audiostream 2026-01-14 11:00:48 +01:00
Admin
109e70e356 fixing audiostream 2026-01-14 10:43:15 +01:00
Admin
e5ed812242 fixing audiostream 2026-01-14 10:07:07 +01:00
Admin
888a7131a2 fixing audiostream 2026-01-14 10:06:02 +01:00
Admin
a0719510c0 fixing audiostream 2026-01-14 10:02:12 +01:00
Admin
70ac15c940 fixing audiostream 2026-01-14 09:59:08 +01:00
Admin
c76219087d fixing audiostream 2026-01-14 09:45:25 +01:00
Admin
3e429c72e1 fixing audiostream 2026-01-14 09:32:27 +01:00
Admin
5607fe9bed add audio loopback on macos 2026-01-14 09:20:26 +01:00
Admin
df2db9eed8 add audio loopback on macos 2026-01-14 09:14:38 +01:00
Admin
e00b893343 add audio loopback on macos 2026-01-14 09:13:32 +01:00
Admin
6a7b655815 add audio loopback on macos 2026-01-14 09:04:03 +01:00
Admin
c2ab823150 fix audiostream buffer strategies 2026-01-14 09:03:43 +01:00
Admin
a13120f400 add audio loopback on macos 2026-01-14 02:00:52 +01:00
Admin
bfcb6dc572 add audio loopback on macos 2026-01-14 01:56:23 +01:00
Admin
afac947a41 add volume 2026-01-14 00:52:35 +01:00
Admin
df40c25162 make it multichannel 2026-01-14 00:38:43 +01:00
Admin
acdcfc7719 make it multichannel 2026-01-14 00:34:38 +01:00
Admin
388b3c4e09 make it multichannel 2026-01-14 00:19:54 +01:00
Admin
6b6d561584 add wasapi loopback 2026-01-14 00:03:06 +01:00
Admin
b35474cea2 remove direct monitor 2026-01-13 23:51:31 +01:00
Admin
e367d2d3f3 add direct monitor 2026-01-13 22:29:31 +01:00
Admin
83dba3b49e add direct monitor 2026-01-13 22:27:56 +01:00
Admin
96982a5e85 add direct monitor 2026-01-13 22:20:20 +01:00
Admin
c1cf374da3 add limiter 2026-01-13 22:00:28 +01:00
Admin
6990c445aa add muting 2026-01-13 21:47:39 +01:00
Admin
faecc487d0 audio fixed 2026-01-13 21:32:09 +01:00
Admin
d839583777 maybe audio fix 2026-01-13 21:19:38 +01:00
Admin
c5ccd63a85 maybe audio fix 2026-01-13 21:17:36 +01:00
Admin
1bb561587e maybe audio fix 2026-01-13 21:11:35 +01:00
Admin
a3d56709e7 audio stream fix ai generated 2026-01-13 19:15:42 +01:00
Admin
ebfdfb9d24 remove warning 2026-01-13 16:37:10 +01:00
Admin
377bcca1d8 somehow lost this 2026-01-13 16:34:42 +01:00
Admin
becf624d8a shader compiler rust reflection otw 2026-01-13 16:21:54 +01:00
Admin
8a022e7b8c shader compiler otw 2026-01-13 15:57:15 +01:00
Julián Montes de Oca
b966c2ab02
iOS: Add UITextInput protocol implementation for IME support (#838)
- MakepadTextInputView with marked text (composition) for CJK input
- UTF-16 ↔ char index conversion for emoji/Unicode handling
- TextRangeReplaceEvent for autocorrect/autocomplete
2026-01-12 14:10:44 +01:00
Admin
65883107fd fix new httpserverrequest type 2026-01-09 08:09:21 +01:00
Kevin Boos
ce22d7af3a
Quick hotfix to make cargo-makepad build again (#837) 2026-01-09 08:06:00 +01:00
Admin
9b08dbdfe5 strings 2026-01-07 10:39:31 +01:00
Admin
6e285af7bb add http server script bindings 2026-01-07 08:55:50 +01:00
Admin
4c0394bbd9 add ffb 2026-01-07 07:31:14 +01:00
Julián Montes de Oca
5bc0b10b8a
Remove broken script handling fn calls from various platforms (#836) 2026-01-06 18:06:49 +01:00
Julián Montes de Oca
fd606cc3f9
Remove app module and main entry point from math_widget (#835) 2026-01-06 17:00:37 +01:00
Julián Montes de Oca
58c1768e4c
Add macOS AEC support via VoiceProcessingIO (#834) 2026-01-06 17:00:25 +01:00
Julián Montes de Oca
bcb3bfa1d3
Add clipboard actions support for iOS (#833)
Implemented UIEditMenuInteraction for clipboard actions, allowing copy, cut, paste, and select all functionalities.
2026-01-06 17:00:02 +01:00
Admin
054e2df626 fix 2026-01-06 10:30:49 +01:00
Admin
2baab504d4 switch to DirectInput 2026-01-06 10:26:11 +01:00
Admin
a526edcb44 switch to DirectInput 2026-01-06 10:19:38 +01:00
Admin
b8bbc3218f switch to DirectInput 2026-01-06 10:12:33 +01:00
Admin
597e506099 rename to game input api 2026-01-06 09:59:17 +01:00
Admin
933e3aec88 add windows gamepad api 2026-01-06 08:44:20 +01:00
Admin
0c5f4f59e4 fix 2026-01-05 18:09:45 +01:00
Admin
3be9aaabeb fix 2026-01-05 18:06:03 +01:00
Admin
ec21136184 basic apple gamepad support 2026-01-05 18:03:46 +01:00
wyenox
ba19892a93
Fix various issues in cargo makepad wasm (#826)
* Fix `Uncaught ReferenceError: env is not defined`

at bindgen.js: const imports = __wbg_get_imports(); imports.env = env;

at (index): let wasm = await init({module_or_path: module}, env);

* fix `Uncaught DOMException: WebAssembly.Memory object could not be cloned.`

at web.js:         worker.postMessage(this.alloc_thread_stack(args.context_ptr, args.timer));

* add missing rustc flags to enable threading

* allow cargo makepad in linux arm

* debugs

* don't color cargo tree

* remove debug prints

* remove unsupported os handling that didn't exist before
2026-01-05 15:14:29 +01:00
Julián Montes de Oca
7e2e0eadae
Add support for definding a math view in Markdown (#825) 2026-01-05 15:14:19 +01:00
Admin
b40b9af499 merge 2025-12-23 16:15:54 +01:00
Eddy Bruel
61913a3e5f Add svg widget 2025-12-19 14:45:38 +01:00
Kevin Boos
b5de1c3060
Add optional serde derives for Serialization/Deserialization on select public types (#831)
Can be activated by setting the "serde" feature on `makepad-widgets`
(or other internal crates).
2025-12-18 08:38:58 +01:00
admin
c32329fc0d metal otw 2025-12-17 16:20:42 +01:00
Admin
7b8952e2e9 fix 2025-12-12 20:40:40 +01:00
Admin
1cfba392c1 fix up tests 2025-12-10 13:04:00 +01:00
Admin
169e561e8e metal shaders otw 2025-12-06 11:42:01 +01:00
Eddy Bruel
d3defbd31f Use mitex to convert from tex to typst 2025-12-05 13:27:20 +01:00
Eddy Bruel
286db39bd5 Move math widget to its own crate 2025-12-05 13:27:20 +01:00
Eddy Bruel
735a489219 Initial attempt 2025-12-05 13:27:20 +01:00
Admin
5b42bfacb7 fix self in shaders 2025-12-04 15:00:25 +01:00
Admin
d50428a47b splash fix tests 2025-12-04 14:40:17 +01:00
Admin
10130f066a glsl, hlsl, metal first steps 2025-12-04 13:18:39 +01:00
Admin
02a1f03eb6 glsl, hlsl, metal first steps 2025-12-04 12:55:20 +01:00
Sabin Regmi
0d99fc0e3a
fix: correct identifier comparison in expect_specific_ident function (#823) 2025-12-02 18:42:47 +01:00
Julián Montes de Oca
fa88145586
Remove unused tempfile dep (#805) 2025-12-02 18:42:01 +01:00
wyenox
65121c1ec3
Update Moly URL (#822) 2025-12-02 18:41:46 +01:00
Admin
7880546817 align mathtypes to WGSL 2025-12-02 11:43:12 +01:00
Admin
5f7ea80f4c platform2/draw2 otw 2025-11-30 14:56:53 +01:00
Admin
dd8df0d6a4 thisio uniforms 2025-11-29 11:52:27 +01:00
Admin
da323b87fd shader thisio on the way 2025-11-28 15:30:07 +01:00
Admin
6bec51d980 extend shader types 2025-11-28 11:19:28 +01:00
Admin
8a41821304 allow this to be a type 2025-11-28 09:54:49 +01:00
Admin
7ae84db0cf shader struct this mutable 2025-11-28 09:19:40 +01:00
Admin
b5053163fd shader refactor 2025-11-28 09:07:45 +01:00
Admin
8850a48058 shader otw 2025-11-28 08:57:52 +01:00
Admin
f4bbe57578 consolidate code 2025-11-27 17:54:08 +01:00
Admin
6c6185ab9c array index in shader 2025-11-27 17:50:19 +01:00
Admin
66954ef694 named fields for wgsl shader structs 2025-11-27 16:37:26 +01:00
Admin
c22475f093 script pod array builders 2025-11-27 11:41:12 +01:00
Admin
925395b79e script pod array builders 2025-11-27 11:14:15 +01:00
Admin
f77cbbb7aa refactoring opcode macros a bit 2025-11-27 10:11:13 +01:00
Admin
769c57fa36 refactoring opcode macros a bit 2025-11-27 10:03:31 +01:00
Admin
eec748a4b4 shader field += operators 2025-11-27 09:31:56 +01:00
Admin
b9394eb6b9 shader field += operators 2025-11-27 09:19:32 +01:00
Admin
fb000a6331 field assign 2025-11-27 09:15:37 +01:00
Admin
3977cec662 shader wgsl struct def output 2025-11-26 15:18:37 +01:00
Admin
066a5ffdb7 shader wgsl struct def output 2025-11-26 15:08:49 +01:00
Admin
b2a2866257 podtype this method calling 2025-11-26 14:40:18 +01:00
Admin
5e08f8a7d6 podtype static method calling 2025-11-26 09:37:53 +01:00
Admin
ad4f30fc5d struct and swizzle fields 2025-11-25 16:02:11 +01:00
Admin
78c5127cc0 add assignment-arithmetic impl 2025-11-25 15:37:39 +01:00
Admin
d33ab6a405 fixed assignment exprs 2025-11-25 15:31:30 +01:00
Admin
e759c5ba30 adding var 2025-11-25 15:14:32 +01:00
Admin
161a21c591 builtin arg typechecks 2025-11-25 15:10:01 +01:00
Admin
3fc63930b9 add builtin tables 2025-11-25 13:59:30 +01:00
Admin
488d340fc3 block scopes in shader 2025-11-25 13:30:29 +01:00
Admin
5dbe2daf12 basic for loops 2025-11-25 13:19:16 +01:00
Admin
b6a49c6b05 shader ifelse type the same 2025-11-25 12:47:56 +01:00
Admin
d44f13074b added better errors 2025-11-25 12:21:32 +01:00
Admin
79bbdbd7d6 extend type tables 2025-11-25 10:49:41 +01:00
Admin
f0497e7a83 extend type tables 2025-11-25 10:49:28 +01:00
Admin
5c1ae0de4c shader compiler function decollide / var shadowing 2025-11-24 19:01:01 +01:00
Admin
dda8253830 shader compiler nested function/structs 2025-11-24 17:32:41 +01:00
Admin
466b1474b8 shader compiler scope resolve 2025-11-24 13:23:41 +01:00
Admin
0ffbe61ece shader compiler pod constructors 2025-11-24 12:46:53 +01:00
Admin
c538926b8e fix script logs 2025-11-22 14:24:17 +01:00
Julián Montes de Oca
619dec61c4
Android: Implement ShowClipboardActions with native ActionMode (#821)
Cx API:
- Implement existing ShowClipboardActions
- Add HideClipboardActions
- Cross-platform API ready for iOS implementation

Android Implementation:
- Native ActionMode integration with floating toolbar (API 23+)
- JNI bindings for showing/hiding menu and handling clipboard actions
- Event system for Copy/Cut/Paste/Select All actions
- Smart menu state management based on selection and clipboard

TextInput Integration:
- Long press selects word and shows menu
- Double tap and long press selects word and shows menu
- Selection preservation when tapping selected text
2025-11-22 10:06:05 +01:00
Julián Montes de Oca
67be5bcfb3
Fix button clicks during keyboard dismissal on mobile (#803)
* Fix button clicks during keyboard dismissal on mobile

When tapping a button while the keyboard is visible on iOS/Android, the keyboard dismisses and shifts the layout mid-press. This caused the event system to treat the finger as no longer "over" the button, preventing the click from registering.

Fixed by treating taps as "over" if the finger didn't move significantly, even if the widget moved underneath due to layout shifts.

* Make is_over conditionals clearer
2025-11-18 20:39:08 +01:00
Admin
14c0413a9b first shader typeinferencing 2025-11-18 07:43:55 +01:00
Eddy Bruel
b3dc1e2c42 Fix bug in label areas 2025-11-17 14:58:31 +01:00
Admin
659e3ed110 first shader transpiler infra 2025-11-16 12:05:01 +01:00
Admin
3c7868ffb0 warning 2025-11-15 14:29:53 +01:00
Kevin Boos
39cd6bb06d
Ensure that LongPress (LongClick) on Android properly tracks touch movement (#820)
* Ensure that LongPress (LongClick) on Android properly uses touch slop

Previously, we assumed that multiple touch action events would occur
before a LongClick, but that is not necessarily true.
It is possible to just have one down touch immediately followed by a
LongClick, so we now account for that.

This also fixes the tracking of touch event locations such that
stale values aren't accidentally used when calculating if a finger movement
exceeded the allowable touch slop for considering a touch as a long press.
(Rust would've caught that... thanks Java)

* remove excess log stmt
2025-11-15 09:31:17 +01:00
Admin
bacd3e8e14 fix 2025-11-14 21:49:05 +01:00
Admin
cbc9861b00 pod otw 2025-11-14 12:35:10 +01:00
Eddy Bruel
f910755f8d Pass descender to turtle in DrawText 2025-11-14 11:25:24 +01:00
Eddy Bruel
495150f49f Unify line spacing between turtle and DrawText 2025-11-14 11:25:13 +01:00
Admin
08eef2797b swizzle otw 2025-11-14 11:01:47 +01:00
Kevin Boos
97983ad26f
Fix modal; enable widgets to block scrolling except within a certain area (#819)
* Fix modal event handling behavior

Everything now works as expected, *except* for Scroll events that seem to
still be received by views beneath the modal, e.g., an underlying PortalList.

* Enable widgets to block scrolling, except within a certain area

* This is important for Modals to prevent scrolling of background widgets
  whilst still allowing the inner `content` view to be scrolled.
* Modals are now forcibly full-screen (or rather, full-window)
  in order to properly ensure that scrolling-allowed areas
  always stay relevant, as the Modal can no longer be contained
  within a non-full-window parent widget/view.

* remove errant log statement
2025-11-13 08:45:36 +01:00
Admin
0fc9e07f2f added half floats and vec pods 2025-11-12 23:37:24 +01:00
admin
f46adf30b0 pod print works 2025-11-12 14:43:45 +01:00
Admin
6bc466e1c6 pod print otw 2025-11-12 13:20:49 +01:00
Admin
fcaadf061d pod layotus 2025-11-12 11:19:14 +01:00
Admin
eefd1057e7 restructure script heap 2025-11-12 09:10:05 +01:00
Eddy Bruel
c07ca804cd Implement line spacing between rows in turtle 2025-11-11 14:12:49 +01:00
Eddy Bruel
e761b94852 Wrap walk metrics into struct 2025-11-10 16:20:51 +01:00
Admin
4384468057 script pod otw 2025-11-10 14:32:28 +01:00
Admin
48d80e5ea0 add first step for pods 2025-11-09 21:18:17 +01:00
wyenox
3e6d4c951e
"app focus" events renamed to "window focus" (#818) 2025-11-07 22:59:54 +01:00
wyenox
e68263e2a2
fix window-level focus issues (#817) 2025-11-07 19:04:41 +01:00
Admin
d04b8339f4 fix 2025-11-06 17:07:24 +01:00
Admin
8ed9e1fb93 fix 2025-11-05 13:48:58 +01:00
Admin
2a8d0ded8e cleanup tasks 2025-11-04 17:52:09 +01:00
Admin
8bd13d28b0 update comfyui to new channels 2025-11-04 15:55:59 +01:00
Admin
fe78b82ab5 update comfyui to new channels 2025-11-04 15:52:23 +01:00
Admin
25efce79ec update comfyui to new channels 2025-11-04 15:45:59 +01:00
Admin
142f76972b first channels working 2025-11-04 15:04:22 +01:00
Admin
4e698ea384 first channels working 2025-11-04 14:59:04 +01:00
Eddy Bruel
7382169d98 Implement RowAlign::Baseline 2025-11-04 10:39:15 +01:00
Eddy Bruel
acd565dce0 Store descender in finished walk 2025-11-04 10:39:15 +01:00
Julián Montes de Oca
5edd4e9ae0
Fix x11 handle_script_signals call (#811) 2025-11-03 18:20:39 +01:00
Julián Montes de Oca
fe01aa947e
Enable drag scrolling by default in Scroll*Views (#810) 2025-11-03 17:16:22 +01:00
Admin
8205c30521 cleanup 2025-11-03 14:49:43 +01:00
Eddy Bruel
fe1a0a4dd2 Add descender to walks 2025-11-03 14:36:06 +01:00
Admin
725a6343b5 add getters/setters to type tables 2025-11-03 14:23:21 +01:00
Admin
c731a43fe7 added script handles 2025-11-03 13:36:38 +01:00
Admin
b8494ae112 fix 2025-11-02 16:09:05 +01:00
Admin
37cb236ac7 fix 2025-11-02 15:56:18 +01:00
Admin
8509ff90aa added stdlib fns 2025-11-02 11:32:43 +01:00
Admin
76215572dc added stdlib fns 2025-11-02 10:50:35 +01:00
Admin
0f50405d49 fix 2025-11-01 19:42:23 +01:00
Admin
86a13523dd first splash script working! 2025-11-01 19:33:16 +01:00
Admin
938b86b11d child processes and random nr gen 2025-11-01 17:45:24 +01:00
Admin
28b7be3ff4 child processes and random nr gen 2025-11-01 17:40:30 +01:00
Admin
e7c8c3e3e3 add script timers 2025-11-01 12:56:54 +01:00
Admin
8732511703 websockets 2025-10-31 17:36:02 +01:00
Admin
ceb68e3091 websockets 2025-10-31 17:23:17 +01:00
Admin
61075f95f2 add fn syntax 2025-10-31 14:43:02 +01:00
Admin
0ba6fa7f50 add fn syntax 2025-10-31 14:33:37 +01:00
Eddy Bruel
74d4f0ffae Implement fits with min/max bounds relative to unused size 2025-10-31 11:13:24 +01:00
Admin
856dbdc5d8 implemented += concats 2025-10-31 10:37:40 +01:00
alanpoon
5f58721cc5
Added set_texture for RotatedImage (#806) 2025-10-30 22:20:52 +01:00
Julián Montes de Oca
fc4b7a22b4
Fix network response handling in process_to_wasm function (#808) 2025-10-30 20:13:33 +01:00
Julián Montes de Oca
899128907e
Remove auto-deny logic for unimplemented permissions (#807) 2025-10-30 19:57:19 +01:00
Admin
bc77fb44e2 strings are rc now 2025-10-30 18:39:25 +01:00
Eddy Bruel
d5bc7b9806 Prefer Right over Right { wrap: false } in DSL 2025-10-30 16:04:52 +01:00
Eddy Bruel
4aca6b0653 Flow::Right should still work 2025-10-30 16:01:58 +01:00
Admin
6b5773eea5 async networking scripting 2025-10-30 15:54:14 +01:00
Eddy Bruel
04f25515ac Implement per-row alignment for rightward flowing turtles 2025-10-30 12:43:18 +01:00
Eddy Bruel
02c73d01fb Split off wrapping the turtle into its own function 2025-10-30 12:43:18 +01:00
Eddy Bruel
04587b4722 Move next_walk_{is_first/offset} from Turtle to Cx2d 2025-10-30 12:43:18 +01:00
Eddy Bruel
60c4593593 Rename Turtle::row_height to Turtle::next_row_offset 2025-10-30 12:43:18 +01:00
Admin
3d6e4c4a4b typecheck arg 2025-10-30 10:26:40 +01:00
Admin
f75f7adc9a default to cx for script macro 2025-10-30 10:17:14 +01:00
Admin
c6b76a4566 fix + to autodetect concat 2025-10-30 09:31:39 +01:00
admin
fcb8fa8faf first fs apis working 2025-10-29 17:26:31 +01:00
Admin
255c0b4d88 script interface for httprequest complete! 2025-10-29 14:02:29 +01:00
Admin
1100833c15 make json string keys id searchable and compareable 2025-10-29 00:09:15 +01:00
Admin
0aec50a276 make json string keys id searchable and compareable 2025-10-28 23:54:12 +01:00
Admin
1756d53fd4 make json string keys id searchable and compareable 2025-10-28 23:43:14 +01:00
Admin
7d861363ce make json string keys id searchable and compareable 2025-10-28 23:17:00 +01:00
Admin
0a47183528 make json string keys id searchable and compareable 2025-10-28 23:04:54 +01:00
Admin
1f909308cc make json string keys id searchable and compareable 2025-10-28 22:50:50 +01:00
Admin
52b246cde7 map json strings to ids in lookup 2025-10-28 20:38:29 +01:00
Admin
25e0d31aea add json parser 2025-10-28 17:55:42 +01:00
Admin
71f5a3be7d add json parser 2025-10-28 15:56:48 +01:00
Admin
45068fbb25 add string to vec mappings 2025-10-28 12:46:50 +01:00
Eddy Bruel
d3b6a2bdab Keep track of finished rows in turtle 2025-10-28 11:52:21 +01:00
Eddy Bruel
0d4a5a1906 WIP 2025-10-28 11:36:21 +01:00
Eddy Bruel
7881fbb17c Fix bug in wrapping 2025-10-28 11:36:21 +01:00
Eddy Bruel
9674bda214 Unify Size::Right and Size::RightWrap 2025-10-28 09:20:57 +01:00
Stevo
7435852986
Fix X11 Linux compilation errors (#804)
- Fixed missing comma after CopyToClipboard match arm
- Fixed premature closing brace in match statement
- Corrected OpenglCx import path from opengl_x11 to opengl_cx
- Fixed incorrect use of 'self' instead of 'cx' in ShowTextIME, CheckPermission, and RequestPermission handlers

These changes resolve compilation errors that prevented building makepad-studio on Linux X11.
2025-10-27 20:21:03 +01:00
Admin
fea82ee336 tighten newtypes 2025-10-27 17:20:15 +01:00
Admin
e63956b616 added typed arrays 2025-10-27 17:00:41 +01:00
Admin
f185ccfc36 added typed arrays 2025-10-27 16:44:24 +01:00
Eddy Bruel
84dc2afaf0 Use turtle_new_line for wrapping 2025-10-27 12:57:49 +01:00
Admin
5da6d7397d updated gc for arrays 2025-10-27 12:43:53 +01:00
Admin
4e969bbdd7 fix uizoo 2025-10-27 11:39:42 +01:00
Admin
f2e1bbcd7b arrays 2025-10-27 11:38:28 +01:00
Eddy Bruel
1a195ba297 Fix 2025-10-27 11:19:22 +01:00
Eddy Bruel
c504ba62c9 Remove walk_turtle_with_align 2025-10-27 11:13:51 +01:00
Eddy Bruel
b1cb63962e Remove spurious printlns 2025-10-27 11:13:50 +01:00
Admin
3c1d9c29d4 splitting off typed arrays 2025-10-27 09:30:44 +01:00
Admin
6250208d24 reuse intern string allocs 2025-10-26 10:40:55 +01:00
Admin
5c7a718924 make sure strings have singular rep 2025-10-26 10:30:59 +01:00
Admin
de86ff4028 make types longer 2025-10-25 21:21:49 +02:00
Admin
12d7b8f2ab make types longer 2025-10-25 21:19:30 +02:00
Admin
7e9de7ce42 make types longer 2025-10-25 21:04:17 +02:00
Admin
2176889023 Fused Id and LiveId 2025-10-25 20:53:30 +02:00
Admin
ad5ac76b10 dont turn on touch scroll by default, fix gradient 2025-10-25 20:03:29 +02:00
Admin
0bd7cc3d59 replacing id soon 2025-10-25 19:46:43 +02:00
Julián Montes de Oca
dbf0ac96fc
Add focus loss detection to TextInput, for keyboard dismissal (#802)
TextInput now self-detects when user taps outside its area and dismisses the keyboard.
The tap event is not consumed.

Fixes keyboard staying open when tapping widgets that don't grab focus.
2025-10-25 19:42:32 +02:00
Julián Montes de Oca
f8cd9e6683
Enhance touch handling by using touch radius across platforms. (#801)
- Updated hit testing to account for finger size
- added touch size retrieval in iOS and Android
2025-10-25 19:42:18 +02:00
Julián Montes de Oca
cfde454ad2
Add touch-based drag scrolling to ScrollBar (#799)
* Add drag and flick scrolling to ScrollBars

* Move touch-based drag and flick implementation to ScrollBar
2025-10-25 19:41:36 +02:00
Julián Montes de Oca
4f221544f4
Fix macOS high CPU usage during idle (#798)
- Main loop timer is no longer running unconditionally but rather armed/disarmed on demand
- Removed unnecessary repaint_windows() call
- File watcher is now only included for debug builds
2025-10-25 19:41:10 +02:00
Julián Montes de Oca
8cd911a8e9
Fix Markdown and HTML ordered list numbering and alignment (#794)
* Fix numbered markdown lists repeating the first number

* Refactor list item padding calculation to accommodate multi-digit markers and fix alignment
2025-10-25 19:36:41 +02:00
Drin
8a33f8c37e
feat: a native wayland backend (#793)
* initialize the event loop for wayland backend

Signed-off-by: drindr <dreamchancn@qq.com>

* feat: hidpi wayland and mouse event
- hidpi support with wayland
- mouse event support
- refactor some code

Signed-off-by: drindr <dreamchancn@qq.com>

* wayland xkbcommon for key pressing event

Signed-off-by: drindr <dreamchancn@qq.com>

* add support for IME in with wayland

---------

Signed-off-by: drindr <dreamchancn@qq.com>
Co-authored-by: makepaddev <20386332+makepaddev@users.noreply.github.com>
2025-10-25 19:35:58 +02:00
Julián Montes de Oca
c659a7da56
Add audio input permission handling and example project (#792)
* Add permission handling for audio input

- Introduced `CheckPermission` and `RequestPermission` operations in `CxOsOp` for managing audio input permissions.
- Implemented permission status checks and requests across iOS, macOS, and Android.
- Added a new `Permission` module to define permission types and statuses.
- Updated event handling to include permission results in the event system.
- Updated manifest files to include necessary permissions for audio input on Android.

* Add audio example project

- Added a new example project for audio processing and permission handling
- Implemented UI components for audio device selection, microphone capture, and playback controls.
- Integrated permission handling for audio input and updated the workspace configuration to include the new example.

* Implement audio permission in Web, catch up Linux and Windows

* Refactor iOS permission handling to avoid ios_app re-entrancy

* Implement iOS mic capture and add sample rate to AudioInfo

* Ensure iOS audio output uses loudspeaker, only when not using external devices

* Enhance audio example by handling sample rates and resampling
2025-10-25 19:15:57 +02:00
Julián Montes de Oca
ef8502d3a3
Fix double borrowing if IOS_APP during window geometry check (#791)
Co-authored-by: Julian Montes de Oca <joulei@buriza.local>
2025-10-25 19:15:44 +02:00
Julián Montes de Oca
d85f0b1778
Fix websockets drop panic (#790) 2025-10-25 19:15:22 +02:00
Guocork
a71c71a7de
Change XStoreName to Xutf8SetWMProperties (#742)
* Fix bug in text centering

* change XStoreName to Xutf8SetWMProperties

---------

Co-authored-by: Eddy Bruel <me@eddybruel.com>
Co-authored-by: Admin <info@makepad.nl>
Co-authored-by: makepaddev <20386332+makepaddev@users.noreply.github.com>
2025-10-25 18:57:22 +02:00
Admin
ddcae99b69 vec and option 2025-10-25 17:32:37 +02:00
Admin
8741f03d20 vec and option 2025-10-25 17:03:17 +02:00
Admin
5cb44bd4c9 vec and option 2025-10-25 16:39:23 +02:00
Admin
1fed558272 rolled up script procmacros 2025-10-25 15:29:36 +02:00
Admin
40ca856ee5 enums otw 2025-10-24 17:30:12 +02:00
Admin
b86c02f299 enums otw 2025-10-24 16:15:35 +02:00
Admin
3e9a51f90a typechecker infra 2025-10-24 13:43:45 +02:00
Eddy Bruel
4f5bf512a0 Rethink selection drag scrolling as a post-op during draw 2025-10-24 11:40:36 +02:00
admin
4a1fefc97d type info otw 2025-10-24 10:03:51 +02:00
Eddy Bruel
16a742a9ff Implement vertical selection drag scrolling for TextInput 2025-10-23 10:48:02 +02:00
admin
c2830a9f51 dirty tracking and procmacro first run 2025-10-22 14:58:01 +02:00
admin
4fc2402acf dirty tracking and procmacro first run 2025-10-22 14:57:10 +02:00
admin
82f596df8d fix 2025-10-22 14:57:00 +02:00
admin
033e76f9b5 add dirty flags on fields 2025-10-21 20:44:12 +02:00
Admin
022ef91a60 procmacro prep 2025-10-21 13:40:38 +02:00
Admin
d6ba528d41 procmacro prep 2025-10-21 12:28:09 +02:00
Eddy Bruel
c395817415 Add ascender to selection rects 2025-10-21 12:24:33 +02:00
Admin
c36ea7100c procmacro prep 2025-10-21 12:04:58 +02:00
Admin
a4ff3b8617 make trap a cell 2025-10-21 11:27:26 +02:00
Admin
7fb9ec7525 prepare procmacros 2025-10-21 10:26:39 +02:00
Admin
afe5693e64 added tests 2025-10-20 14:29:11 +02:00
Admin
a08a1902f9 added try 2025-10-20 14:20:11 +02:00
Admin
63ad498818 added try 2025-10-20 14:14:02 +02:00
Admin
98cd17ca14 added try 2025-10-20 14:07:23 +02:00
Admin
573c3aa442 change trapping mechanism 2025-10-20 12:00:05 +02:00
Admin
cfb1cf543b change trapping mechanism 2025-10-20 11:46:09 +02:00
Admin
21177b3c29 change trapping mechanism 2025-10-20 11:39:37 +02:00
Admin
f7e5f1f7b2 fixed tests 2025-10-19 18:43:26 +02:00
Admin
d0fd07d5a5 scope inherits when shadowing 2025-10-19 18:25:50 +02:00
Admin
2d0c71692c fixed tests 2025-10-18 13:35:56 +02:00
alanpoon
6ce9aca06b
allow apply_over for image_scale and image_pan (#788) 2025-10-18 10:12:21 +02:00
Admin
601ef508ef fixed tests 2025-10-17 21:58:13 +02:00
Admin
e36be09618 fixed tests 2025-10-17 17:19:40 +02:00
Admin
9daf918978 fixed tests 2025-10-17 17:15:02 +02:00
Admin
3560f1e1e2 fixed tests 2025-10-17 17:12:53 +02:00
Admin
03b288f8b3 added access rights to script objects 2025-10-17 16:36:57 +02:00
Admin
f87d8729d0 special hashmap for keys 2025-10-17 10:57:53 +02:00
Admin
c80ee686f8 fix up tests 2025-10-17 10:30:19 +02:00
Admin
003851e6f7 fix up tests 2025-10-17 10:03:54 +02:00
Admin
2968eb5e03 add ip tracing to setvalue 2025-10-17 09:25:06 +02:00
Admin
021685d530 clean up heap 2025-10-17 08:38:58 +02:00
Admin
192714f3ff clean up heap 2025-10-17 08:38:43 +02:00
Admin
b4e2dda008 add script dep to platform 2025-10-16 13:04:49 +02:00
Admin
36c641d76c cleanup 2025-10-16 11:29:49 +02:00
Admin
f9030476c6 cleaning up splash interop apis 2025-10-16 11:00:45 +02:00
Admin
fc87b130cd cleaning up splash interop apis 2025-10-16 10:55:57 +02:00
Admin
289f4f087b cleaning up splash interop apis 2025-10-16 10:44:48 +02:00
Admin
89a8c5c03d call from rust to script 2025-10-15 13:54:35 +02:00
Admin
0f4d82b26f call from rust to script 2025-10-15 13:25:07 +02:00
Admin
075de99e55 added while and loop 2025-10-15 11:27:53 +02:00
Admin
e64258f058 added break 2025-10-15 10:09:07 +02:00
Admin
04dd594d06 10% faster 2025-10-15 09:07:55 +02:00
Admin
af175ed071 missing opcodes 2025-10-14 21:19:16 +02:00
Admin
63d392c05c missing opcodes 2025-10-14 21:18:32 +02:00
Admin
a19df0ed65 assign ifnil 2025-10-14 19:29:44 +02:00
Admin
053a1c0258 equality operators 2025-10-14 16:48:36 +02:00
Admin
2c58e512d5 equality operators 2025-10-14 16:32:34 +02:00
Admin
f54913f7d4 equality operators 2025-10-14 16:12:04 +02:00
Admin
999c86495d add nan tracing 2025-10-14 13:37:20 +02:00
Admin
26c2871afb specialise more errors 2025-10-14 13:02:37 +02:00
Admin
c7c4fc5b19 specialise more errors 2025-10-14 12:57:25 +02:00
Admin
a4f932e81d add .? operator 2025-10-14 12:44:08 +02:00
Admin
175046579d add ? operator 2025-10-14 12:29:29 +02:00
Admin
1755305260 more error types 2025-10-14 12:00:58 +02:00
Admin
21eae8b7bb added exception value types 2025-10-14 11:08:11 +02:00
Admin
db0b68ea3e added exception value types 2025-10-14 10:59:26 +02:00
Admin
56453ee1d2 closures from native methods otw 2025-10-13 22:37:41 +02:00
Admin
b796660128 multiple script bodies 2025-10-13 18:49:27 +02:00
Admin
0def9d5440 multiple script bodies 2025-10-13 18:43:54 +02:00
Admin
cbaf3479e6 separators 2025-10-13 14:40:35 +02:00
Admin
1beb90e9a8 separators 2025-10-13 14:38:59 +02:00
Admin
dd90bc1c88 add script macro 2025-10-13 13:50:09 +02:00
Admin
2ee3aca1b7 moved id/value out 2025-10-13 11:19:58 +02:00
Admin
b614b39a7e split off opcodes 2025-10-13 00:44:09 +02:00
Admin
a4752502e9 sourcemaps 2025-10-13 00:27:33 +02:00
Admin
9790987a0f sourcemaps 2025-10-13 00:11:23 +02:00
Admin
e41d1b8cc3 sourcemaps 2025-10-13 00:00:59 +02:00
Admin
eb4a6e2b45 made for/let/return expressions 2025-10-12 23:05:39 +02:00
Admin
94a57d130f fix more fast cycle object freeing 2025-10-12 21:16:21 +02:00
Admin
be9d8ab0dd added working range objects 2025-10-12 21:02:57 +02:00
Admin
a11fc3c687 bit looser no @type needed 2025-10-12 19:13:48 +02:00
Admin
43be2d9ab7 add is operator 2025-10-12 19:08:40 +02:00
Admin
30a30895d5 fixup native and restructure 2025-10-12 17:07:52 +02:00
Admin
ab6dacb0fe restructure 2025-10-12 11:47:19 +02:00
Admin
23e5688887 paired for loop iterator 2025-10-10 15:50:10 +02:00
Admin
5cf404df5c unbroke fib 2025-10-09 22:37:37 +02:00
Admin
911a7b4431 fix scope mixup 2025-10-09 22:20:06 +02:00
Admin
5ac762da01 for loops otw 2025-10-09 20:42:35 +02:00
Eddy Bruel
ae2ed8542c Make fit bounds relative to first ancestor with known size 2025-10-09 16:36:03 +02:00
admin
3bf1d1507e cleanup 2025-10-08 18:07:29 +02:00
admin
29edb5df54 fixed tokenizer operators 2025-10-08 15:31:18 +02:00
Admin
54fd4d9994 add merge 2025-10-08 11:28:40 +02:00
Admin
57594427ac add extend 2025-10-08 11:24:30 +02:00
Admin
8b51a81998 change this passing method 2025-10-08 11:15:13 +02:00
Admin
55dd831792 sys fns split up 2025-10-08 11:01:52 +02:00
Admin
cdf89355ee fix this and push 2025-10-08 10:58:27 +02:00
Admin
722d4fddda short hand object constructors 2025-10-08 09:54:37 +02:00
Admin
9c38fbc0d3 working log 2025-10-08 09:05:11 +02:00
Admin
af19a04e49 inline sysfns 2025-10-07 23:07:42 +02:00
Eddy Bruel
86d21d4014 Revert "bit slower fib but alas"
This reverts commit 33bf32e282.
2025-10-07 15:02:06 +02:00
Admin
33bf32e282 bit slower fib but alas 2025-10-07 14:53:54 +02:00
Admin
b567063b27 bit slower fib but alas 2025-10-07 14:53:08 +02:00
Admin
c117ff382e profile 2025-10-07 13:58:43 +02:00
Admin
897052b8aa sys fns 2025-10-07 13:41:37 +02:00
Admin
8971ca1275 add array indexes 2025-10-07 12:20:31 +02:00
Admin
653d2911e4 fib a tiny bit faster 2025-10-07 11:20:46 +02:00
Admin
679efc2b52 fib works again 2025-10-07 11:07:38 +02:00
Admin
d0991d3962 add object types 2025-10-07 02:15:45 +02:00
Admin
99f27908fc added typed arrays 2025-10-07 01:23:24 +02:00
Admin
57de5745c2 :+ prototype me field inheritance 2025-10-06 17:28:32 +02:00
Admin
38e487bfac added maps as possible object key/value 2025-10-06 15:43:11 +02:00
Admin
070027bcb5 added maps as possible object key/value 2025-10-06 15:40:15 +02:00
Admin
99e651757c add slow method 2025-10-06 12:25:16 +02:00
Admin
ecbfee01b4 added stack bounds, log and method calls 2025-10-06 09:35:23 +02:00
Admin
ae3fb25ae2 add splash log operator 2025-10-05 23:34:48 +02:00
Admin
63f4945f31 make me nil at call root 2025-10-05 23:22:21 +02:00
Admin
8f1ff45ae1 move mes onto callframe 2025-10-05 23:15:35 +02:00
Admin
eb84525216 added return 2025-10-05 22:59:33 +02:00
Admin
c3499d7fa0 not-named fn args work 2025-10-05 22:28:01 +02:00
Admin
96c351e9c0 not-named fn args work 2025-10-05 22:18:07 +02:00
Admin
6437849c8b drop scope if unreffed 2025-10-05 22:03:59 +02:00
Admin
efb687b6e6 return to simple 2025-10-05 21:25:53 +02:00
Admin
ab5a010e68 added locals 2025-10-05 20:58:59 +02:00
Admin
fcca8c8649 undo and add locals 2025-10-05 19:35:25 +02:00
Admin
f8c764c780 postfix ids 2025-10-05 17:31:02 +02:00
Admin
b55bb29ef7 immediate numbers in opcodes 2025-10-05 16:19:52 +02:00
Admin
66c47e4e76 fib runs 2025-10-05 15:41:43 +02:00
Admin
ced4f72ed7 fib runs 2025-10-05 15:19:08 +02:00
Admin
71f93d1611 interpreted if else 2025-10-05 14:42:36 +02:00
Admin
d9a4f86bb5 parsed if else hopefully 2025-10-05 14:12:22 +02:00
Admin
b690c060df split out opcode 2025-10-05 13:33:28 +02:00
Admin
c6e4d1f1a1 split off opcodes into type 2025-10-05 13:01:02 +02:00
Admin
6cabde85e6 fn calls 2025-10-04 22:23:59 +02:00
Admin
254c0ccccd remove lots of pops for assignments 2025-10-03 18:08:57 +02:00
Admin
1992a092f6 give opcodes space for immediates 2025-10-03 16:26:38 +02:00
Admin
9ca52276e5 added inline strings to nanbox 2025-10-03 15:31:54 +02:00
Admin
984dd7e8af added inline strings to nanbox 2025-10-03 15:23:01 +02:00
Admin
7b679fa4a0 use high bit of id for escape 2025-10-03 14:46:31 +02:00
Admin
1329efa45f use high bit of id for escape 2025-10-03 14:40:16 +02:00
Admin
00edc7a4df added id-as-var 2025-10-03 13:58:00 +02:00
Admin
2a6c6985ea added id-as-var 2025-10-03 13:38:11 +02:00
Admin
9e467e2d8e have prototypes 2025-10-03 12:38:52 +02:00
Admin
c432bbbf10 have prototypes 2025-10-03 12:14:09 +02:00
Admin
f78275d3e4 it calculates! 2025-10-03 12:07:56 +02:00
Admin
b08c115d33 it calculates! 2025-10-03 11:52:40 +02:00
Admin
0963a22286 it calculates! 2025-10-03 11:27:06 +02:00
Admin
5cb480db89 interpreter otw 2025-10-03 10:49:15 +02:00
Admin
61a5c4d49c parsed functions 2025-10-03 09:23:52 +02:00
Admin
4a2a053e09 fix warning 2025-10-02 18:38:33 +02:00
Admin
f1560978ca added let instructions 2025-10-02 18:38:16 +02:00
Eddy Bruel
44cf86a897 Implement relative min/max fits 2025-10-02 15:42:36 +02:00
Admin
27ca0e767c make id have $ flag 2025-10-01 11:54:03 +02:00
Admin
e14590b347 make id have $ flag 2025-10-01 11:51:12 +02:00
Admin
5c162d1bd1 deep proto inheritance otw 2025-10-01 11:32:48 +02:00
Admin
02b540f667 added 3 value operators 2025-09-30 16:49:39 +02:00
Admin
912995bdbf script otw 2025-09-29 16:28:00 +02:00
Admin
1892e7456e concat and add 2025-09-28 16:43:09 +02:00
Admin
3d7c83ed03 splash: multizone gc heaps 2025-09-28 14:17:32 +02:00
Admin
6102a9ee4f script engine basic gc 2025-09-26 14:39:59 +02:00
Admin
85df8d5c6c script parse call, index and fields 2025-09-25 17:26:42 +02:00
Eddy Bruel
fdf9d66e81 Implement min/max fits 2025-09-25 16:09:28 +02:00
Admin
cd172245dd script parse call, index and fields 2025-09-25 14:07:32 +02:00
Admin
8785cf9f63 script parse parens 2025-09-25 13:28:12 +02:00
Admin
6d79a6c7da script object constructors 2025-09-25 12:44:30 +02:00
Admin
0dbf11b821 first parsed math expression 2025-09-25 12:10:35 +02:00
admin
2571e20894 parser otw 2025-09-24 16:31:18 +02:00
Admin
67cffbe879 script otw 2025-09-24 12:18:01 +02:00
Admin
4f18372633 script engine otw 2025-09-24 11:00:28 +02:00
Admin
4cf8cb8e41 script engine otw 2025-09-24 10:16:15 +02:00
Admin
96fdd75707 script tokenizer done 2025-09-23 08:47:09 +02:00
Admin
75d1a50507 script tokenizer done 2025-09-23 08:37:23 +02:00
makepaddev
86422fb774 wasm elide warnings removed 2025-09-09 22:24:18 +02:00
makepaddev
a08fb728de fix elided warnings 2025-09-09 21:42:05 +02:00
makepaddev
6a9b75c601 fix elided lifetime warnings 2025-09-09 21:37:52 +02:00
Admin
9568059be3 aistream 2025-09-09 21:27:28 +02:00
Eddy Bruel
462ef81efe Fix bug in weighted fills 2025-09-09 10:18:55 +02:00
Admin
fdab48a1b7 ai fixup 2025-09-09 09:37:35 +02:00
Admin
0e77401bbc longer timeouts on macos urlsession 2025-09-09 09:30:29 +02:00
Eddy Bruel
9919360ba5 Make sure to move turtle to new location on wrap 2025-08-20 16:12:38 +02:00
Eddy Bruel
f7ba57942c Fix position bug in draw_walk when Flow is RightWrap 2025-08-12 11:09:20 +02:00
Eddy Bruel
e576d4e0d6 Remove support for multistyle text layouts 2025-08-01 12:23:56 +02:00
Lyda
252f4e328a
Implement IME composition handling in WasmWebBrowser (#772)
* Implement IME composition handling in WasmWebBrowser

This update introduces support for handling Input Method Editor (IME) composition events in the WasmWebBrowser class. The changes include:

- Added event listeners for `compositionstart`, `compositionupdate`, and `compositionend` to manage the composition state and data.
- Skipped normal input events during composition to prevent interference.
- Sent the final IME input result to the WebAssembly module upon composition end, ensuring proper handling of user input.

These enhancements improve the text input experience for users utilizing IME, particularly for languages requiring composition.

* chore: remove log
2025-07-29 09:27:27 +02:00
Admin
963cad9d81 sync load image sizes 2025-07-22 11:58:12 +02:00
Kevin Boos
8970147b06
Create PageFlip child widget within set_active_page() (#783)
This allows an app dev/user to create and populate a new widget
within the PageFlip parent widget *before* waiting for it to be drawn.
Previously, the child widgets within PageFlip were either created
upon app load (which is inefficient) or upon draw (which is too late).
2025-07-21 20:52:34 +02:00
Admin
20f1333f55 fix align typo 2025-07-15 10:25:15 +02:00
Admin
97a224befb fix cargo makepad quest missing font 2025-07-14 11:36:00 +02:00
Julián Montes de Oca
7fc2283327
Rework StackNavigation into an actual Stack (#766)
* Refactor stack navigation into a proper stack

 - Replace show_stack_view_by_id with push/pop/popToRoot methods
- Add proper navigation stack with history tracking
- Fix animation transitions between consecutive views
- Add stack inspection methods (depth, can_pop, current_view)
- Maintain backward compatibility with deprecated methods
- Update action enum to use Push/Pop/PopToRoot variants

* Add support for multiple instances of StackNavigation.

- Introduced full-screen flag necessary to disable full-screen positioning and sliding animations when needed.
2025-07-12 02:11:27 +02:00
alanpoon
a5b8f1bb6a
fix_small_spinner_not_showing (#782)
* fix_small_spinner_not_showing

* remove debug
2025-07-12 02:11:16 +02:00
Kevin Boos
13842ff545
Add PortalList::is_filling_viewport() (#781)
* Update old `windows-targets` dep version to reduce lockfile duplicates

All other crates in the Rust ecosystem depend on `windows-targets`
v0.52.*, so this small change vastly reduces the number of duplicate
`windows-*` crate dependencies that cargo must download and track in the lockfile.

Also address minor compiler warnings in AdaptiveView.

* Add `PortalList::is_filling_viewport()`

This is needed in order for an app to be able to take action
upon a portal list's viewport not being completely full.
For example, if you're showing a chat room, you can fetch older events
until there is enough history to fill the entire viewport.
2025-07-12 02:11:05 +02:00
Kevin Boos
7513acdf3f
Update old windows-targets dep version to reduce lockfile duplicates (#780)
All other crates in the Rust ecosystem depend on `windows-targets`
v0.52.*, so this small change vastly reduces the number of duplicate
`windows-*` crate dependencies that cargo must download and track in the lockfile.

Also address minor compiler warnings in AdaptiveView.
2025-07-12 02:10:51 +02:00
okapii
8516eea76f Fix dropdown 2025-07-10 16:17:19 +02:00
okapii
7920779934 Dropdown code cleanup 2025-07-10 15:52:57 +02:00
okapii
d5050e15fb Add Gradient support to icon shaders (button, checkbox, radio) 2025-07-10 15:13:12 +02:00
okapii
8d60077782 Fixes 2025-07-10 14:48:34 +02:00
okapii
c578200a6e Fix container gradient in UI Zoo scrollbar demo 2025-07-10 14:17:02 +02:00
okapii
332e44fcd4 Light theme refinements 2025-07-10 14:15:27 +02:00
okapii
8c35957234 Variable name consistency fix 2025-07-10 14:07:21 +02:00
okapii
813bcd272d Theme application and theme optimizations (improving active state stylings) 2025-07-10 14:03:12 +02:00
okapii
3981d8777c Consistent theme application and rotary focus state optimizations 2025-07-10 13:56:13 +02:00
okapii
4254632fda Cleanup pass: proper theme vairable application 2025-07-10 13:39:40 +02:00
okapii
c86b6f5f52 Improve UV mapping support of widgets 2025-07-10 12:34:24 +02:00
okapii
60ef77846b Further complete dither support 2025-07-10 12:24:32 +02:00
okapii
8a933e01c4 Button: make the flat variant the base one 2025-07-10 12:11:15 +02:00
okapii
509ce1075a Complete gradient dither support 2025-07-10 12:04:09 +02:00
okapii
82ad912ce0 Dropdown: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
7547702955 RadioButton: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
85343cb5ab RadioButton: Gradient refactor 2025-07-10 11:21:38 +02:00
okapii
f568d99a91 RadioButton: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
f880ff15f9 Radio: Gradient refactor progress 2025-07-10 11:21:38 +02:00
okapii
9e2f85ace5 Checkbox: fixed missing gradient_dir variables 2025-07-10 11:21:38 +02:00
okapii
0c44b63541 Checkbox gradient refactor 2025-07-10 11:21:38 +02:00
okapii
7d3a659bb3 Toggle: Gradient refactor 2025-07-10 11:21:38 +02:00
okapii
18805f42ae Checkbox: Gradient refactor 2025-07-10 11:21:38 +02:00
okapii
780aa4f9d8 popup_menu: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
0adbed4549 popupmenu: popupmenuitem gradient refactor 2025-07-10 11:21:38 +02:00
okapii
e0e921669b Rotary: Gradient refactor 2025-07-10 11:21:38 +02:00
okapii
dcceca19af Rotary: Gradient refactor 2025-07-10 11:21:38 +02:00
okapii
0619e8907d SliderRound: Gradient refactor 2025-07-10 11:21:38 +02:00
okapii
34f6ba8be8 SliderRound: Gradient refactor 2025-07-10 11:21:38 +02:00
okapii
0139383e82 Slider: Gradient refactor 2025-07-10 11:21:38 +02:00
okapii
6f7427987e Slider: Gradient refactor progress 2025-07-10 11:21:38 +02:00
okapii
99354d0975 Slider: Gradient refactor progress 2025-07-10 11:21:38 +02:00
okapii
ca37266543 Slider: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
70b161d158 Slider: Gradient refactor progress 2025-07-10 11:21:38 +02:00
okapii
c84d56e9b2 Dark theme: focus state improvements 2025-07-10 11:21:38 +02:00
okapii
d69b2bda8d Textinput: structural refactor that turns the flat variant into the base style 2025-07-10 11:21:38 +02:00
okapii
662c21f351 Dark theme refinement 2025-07-10 11:21:38 +02:00
okapii
6f8d032d84 TextInput: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
f9061931e3 TextInput: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
fa1e96f90d tab: further fix the pixel offset problem 2025-07-10 11:21:38 +02:00
okapii
deb5b5ea67 tab_bar: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
671f6af404 tab: re-fix outline offset issue 2025-07-10 11:21:38 +02:00
okapii
ac97bf978d tab_bar: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
e21b177b99 Filetree: bugfix, filetree was not filling the full height of its containers 2025-07-10 11:21:38 +02:00
okapii
50b7cb732f Slidesview: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
a466c0b442 Variable name unifying 2025-07-10 11:21:38 +02:00
okapii
cd87a1076a View: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
b6e84e6863 view: gradient refactor progress 2025-07-10 11:21:38 +02:00
okapii
60539ab38c View: gradient refactor progress 2025-07-10 11:21:38 +02:00
okapii
4182040748 view: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
959749009e view: gradient refactor progress 2025-07-10 11:21:38 +02:00
okapii
b1c444f82c View: gradient refactor progress 2025-07-10 11:21:38 +02:00
okapii
f58b107664 Tab: gradient refactor updates 2025-07-10 11:21:38 +02:00
okapii
c05f1c7453 Tab: gradient refactor 2025-07-10 11:21:38 +02:00
okapii
eff462825f LinkLabel gradient refactor 2025-07-10 11:21:38 +02:00
okapii
ea27251944 Refactor progress 2025-07-10 11:21:38 +02:00
okapii
e48627c0a8 Label gradient refactor 2025-07-10 11:21:38 +02:00
okapii
9228b22b85 Button gradient-direction uniform renaming 2025-07-10 11:21:38 +02:00
okapii
3427107a4f Icon widget refactor 2025-07-10 11:21:38 +02:00
okapii
2e08834988 SpinnerWidget improvements and adding a demo to UI Zoo 2025-07-10 11:21:38 +02:00
okapii
7ca05c7711 Improving the sentinel value conditions 2025-07-10 11:21:38 +02:00
okapii
54b58570f8 Button: minor code improvement 2025-07-10 11:21:38 +02:00
okapii
689b134385 Button: adding a gradient direction flag 2025-07-10 11:21:38 +02:00
okapii
edf8c69ab4 Shader ergonomics improvement button refactor 2025-07-10 11:21:38 +02:00
okapii
ca7de3ba50 TabFlat: removed a faulty 1px offset 2025-07-10 11:21:38 +02:00
okapii
a350aeaa91 Improve widgets in order to work properly with more extreme global theme settings. 2025-07-10 11:21:38 +02:00
okapii
3beaed1e5d Rotary: make sure that rotaries are not being cut off at the bottom when scaled 2025-07-10 11:21:38 +02:00
okapii
1d6ba10df3 Make sure tabs don't detach with high global spacing values in the theme. 2025-07-10 11:21:38 +02:00
okapii
3520f0c38a Adding a min-height to the tab-bar 2025-07-10 11:21:38 +02:00
okapii
727f6b9d14 Adding min/max support to DSL expressions 2025-07-10 11:21:38 +02:00
okapii
5949090856 subtle tab_bar/tab-contrast increase 2025-07-10 11:21:38 +02:00
okapii
a9347b1695 Tabs: make sure low beveling values don't break how tabs are drawn. 2025-07-10 11:21:38 +02:00
okapii
343ad815b9 Adding a ddefault label to Buttons 2025-07-10 11:21:37 +02:00
okapii
48e8a54fe7 Dark theme refinements 2025-07-10 11:21:37 +02:00
okapii
4603f22319 SliderRound: adding val_padding support 2025-07-10 11:21:37 +02:00
okapii
86a4a50387 Slider minimal restructuring 2025-07-10 11:21:37 +02:00
okapii
8d58b1d658 UI Zoo: update to work with val_padding and remove val_size for sliders 2025-07-10 11:21:37 +02:00
okapii
5091282353 Adding value padding to SliderGradientY und SliderFlat, too 2025-07-10 11:21:37 +02:00
okapii
8aa2f96dbc Slider: restructure to have a global height and val_padding 2025-07-10 11:21:37 +02:00
okapii
515abb0057 Slider: small bugfix 2025-07-10 11:21:37 +02:00
okapii
e07f306260 Rotaries: val_padding support and code cleanups 2025-07-10 11:21:37 +02:00
okapii
70f2fb7025 Rotary and RotaryGradientY: code cleanup and proper val_padding implemetations 2025-07-10 11:21:37 +02:00
okapii
24f48c57c8 Radiobutton refactor 2025-07-10 11:21:37 +02:00
okapii
0aa2340e59 Radiobutton refactoring progress 2025-07-10 11:21:37 +02:00
Eddy Bruel
840e7669d1 Further cleanup 2025-07-09 15:04:16 +02:00
Eddy Bruel
4cbf821c34 WIP 2025-07-09 15:04:16 +02:00
Eddy Bruel
42a3db6fe5 Clean up end_turtle 2025-07-09 15:04:16 +02:00
Eddy Bruel
ad8891a4be Further cleanup 2025-07-09 15:04:16 +02:00
Eddy Bruel
ed7629b74e Implement min/max fills 2025-07-09 15:04:16 +02:00
Eddy Bruel
3ccf3628f2 Refactor Size::Fill to have fields 2025-07-09 15:04:16 +02:00
Eddy Bruel
791e11f036 Preparatory work for min/max fills 2025-07-09 15:04:16 +02:00
Eddy Bruel
8576b399cf Implement weighted fills 2025-07-09 15:04:16 +02:00
Eddy Bruel
97317539c4 Factor out deferred_{width/height}_up_to from end_turtle 2025-07-09 15:04:16 +02:00
Eddy Bruel
98e30c03cf Clean up defer_walk 2025-07-09 15:04:16 +02:00
Eddy Bruel
d46e92cc55 Clean up walk_turtle 2025-07-09 15:04:16 +02:00
Eddy Bruel
f4746924ad Helper functions for moving the turtle 2025-07-09 15:04:16 +02:00
Eddy Bruel
54953c6e7a Clean up child_spacing 2025-07-09 15:04:16 +02:00
Eddy Bruel
3f31a6dcd1 Clean up update_width_used/update_height_used 2025-07-09 15:04:16 +02:00
Eddy Bruel
f25f843c6f Dead code removal 2025-07-09 15:04:16 +02:00
Eddy Bruel
fea9767ec0 Helper functions for rects 2025-07-09 15:04:16 +02:00
Eddy Bruel
e7da71f266 Clean up eval_width/eval_height 2025-07-09 15:04:16 +02:00
Eddy Bruel
e6bdf1c025 Helper functions for widths/heights 2025-07-09 15:04:16 +02:00
Kevin Boos
3b5c73b028
Fix iOS Info.plist to use canonical version strings. Add location permissions. (#778) 2025-07-08 19:57:51 +02:00
Kevin Boos
fe649a34d3
Avoid OOB error in ab_glyph_rasterizer (#779) 2025-07-08 19:57:34 +02:00
Julián Montes de Oca
d6fb5b8014
Track parent size in AdaptiveView to support applying selector oustide draw flow (#776) 2025-07-01 20:43:21 +02:00
Guocork
0cf3638556
widget: loading_spinner (#774)
* loading spinner

* convert style

* use arc_round_caps refactor

* adjust the speed

* change to uniform

* Update widgets/src/loading_spinner.rs

Co-authored-by: Kevin Boos <1139460+kevinaboos@users.noreply.github.com>

* Update widgets/src/loading_spinner.rs

Co-authored-by: Kevin Boos <1139460+kevinaboos@users.noreply.github.com>

---------

Co-authored-by: Kevin Boos <1139460+kevinaboos@users.noreply.github.com>
2025-06-30 19:49:50 +02:00
Julián Montes de Oca
3885596b04
Fix AdaptiveView not re-applying selector on geom change (#775) 2025-06-30 19:48:50 +02:00
alanpoon
836d042c45
Persistent and restoration of Window State (#770)
* Added create_window, remove is_created check

* Added Event::Shutdown for window when shutting down

* reduce code redundancy with assignment

Co-authored-by: Kevin Boos <1139460+kevinaboos@users.noreply.github.com>

* allow macos fullscreen

* add window_fullscreen

* window_fullscreen

* linux_x11, support set fullscreen during initalisation

---------

Co-authored-by: Kevin Boos <1139460+kevinaboos@users.noreply.github.com>
2025-06-20 20:49:55 +02:00
Guocork
792011702e
impl set_position on Linux (#748)
* impl  set_position

* remove set value

* finish set_position
2025-06-20 20:49:03 +02:00
Julián Montes de Oca
432dd2207a
Implement data directory handling for mobile platforms (#769)
- Add `get_data_dir` method to `CxOsApi` for retrieving writable data directory paths on mobile platforms.
2025-06-20 20:48:48 +02:00
Kevin Boos
8d34458302
Allow users of TextInput to know when a keypress had no effect (#767)
* Allow users of `TextInput` to know when a keypress had no effect

This PR makes the `TextInput` widget emit a `TextInputAction::KeyDownUnhandled`
action upon keypresses that don't actually make any change to the widget's selection.

This action is currently only emitted for the Up, Down, Left, and Right arrow keys,
but we can certainly add it to others like Backspace, Delete, etc if desired.

Use case: if you want to enable custom behavior when the user uses arrow keys
to navigate an empty TextInput widget, such as jumping up out of the TextInput focus
to edit the most recent message you just sent in a chat app, then this PR
is necessary. Otherwise there's no way to know whether a given input actually had
any effect on the TextInput's inner cursor/selection state.

* Add the FaceID usage description to iOS Info.plist

Allows Makepad apps to use biometric auth on iOS
2025-06-20 20:48:06 +02:00
Admin
cb96fb5c7f ai bounds fix on raster 2025-06-19 20:54:33 +02:00
okapii
40f8166e53 Dark & light theme disabled state look improvements 2025-06-17 17:22:58 +02:00
okapii
eee6fd8922 Minor Slider improvements 2025-06-17 15:26:21 +02:00
okapii
5d2767cc7a CheckboxCustom updates 2025-06-16 12:07:09 +02:00
okapii
4fbd5363ac Checkbox: remove superfluous match statement, introduce CheckBoxCustom for arbitrary graphical CheckBoxes 2025-06-16 11:09:16 +02:00
okapii
08f2651373 Add disabled examples to UI Zoo 2025-06-16 10:48:31 +02:00
okapii
5ee3e3d978 Optimize flatter widget variants and remove ones for which this approach does not work properly 2025-06-16 10:48:31 +02:00
okapii
34b364f124 Further apply theme, simplify skeleton theme. 2025-06-16 10:48:31 +02:00
okapii
fbb3f7eee0 Apply theme and further clean up skeleton theme 2025-06-16 10:48:31 +02:00
okapii
fc85dd4b3a Light theme refinements 2025-06-16 10:48:31 +02:00
okapii
9fe9750984 SliderRound: fix input position 2025-06-16 10:48:31 +02:00
okapii
3c649b4f54 Light theme progress 2025-06-16 10:48:31 +02:00
okapii
1da98f6995 Skeleton finished 2025-06-16 10:48:31 +02:00
okapii
3b7b2f0dd3 Finished Skeleton Theme 2025-06-16 10:48:31 +02:00
okapii
8588740225 Skeleton theme progress 2025-06-16 10:48:31 +02:00
okapii
183a3e58c7 Skeleton Theme progress 2025-06-16 10:48:31 +02:00
okapii
b48804cfb5 Skeleton Theme progress 2025-06-16 10:48:31 +02:00
okapii
b8646879a6 Skeleton theme progress 2025-06-16 10:48:31 +02:00
okapii
9793f2366b Skeleton theme progress 2025-06-16 10:48:31 +02:00
okapii
3f924f5278 Skeleton theme progress 2025-06-16 10:48:31 +02:00
okapii
928c7cd679 Subtly improve controsts 2025-06-16 10:48:31 +02:00
okapii
a6027e094c Design progress 2025-06-16 10:48:31 +02:00
okapii
f18c87989a Theme styling progress 2025-06-16 10:48:30 +02:00
okapii
a34a1194d7 Light Theme: progress 2025-06-16 10:48:30 +02:00
okapii
01c22006ea Filetree: Fix filler block 2025-06-16 10:48:30 +02:00
okapii
9ac5c7962a tab_bar: styling refinements 2025-06-16 10:48:30 +02:00
okapii
e825ea4b6c Slider: refinements 2025-06-16 10:48:30 +02:00
okapii
6c1fa12bd8 UI Zoo, image examples: styling improvements 2025-06-16 10:48:30 +02:00
okapii
a55ed669aa UI Zoo: layout styling refinements 2025-06-16 10:48:30 +02:00
okapii
998f677adf UI Zoo: use the light theme for developing it 2025-06-16 10:48:30 +02:00
okapii
2840727fa7 UI Zoo, view examples: Apply the theme to make light mode work properly 2025-06-16 10:48:30 +02:00
okapii
1ae7eef34e Light theme progress 2025-06-16 10:48:30 +02:00
okapii
956e862611 Make sure Hr and Vr don't become invisible when the global beveling theme parameter is 0. 2025-06-16 10:48:30 +02:00
okapii
460177ddc1 Remove opaque colors from theme to make sure the contrast global parameter works as expected 2025-06-16 10:48:30 +02:00
okapii
fe8a60bc62 Makepad Readme update 2025-06-10 11:23:14 +02:00
Admin
2f60a93960 buildrs for font paths in git dep 2025-06-06 15:52:26 +02:00
Julián Montes de Oca
9f93aea7cd
AdaptiveView Improvements (some regression fixes) (#764)
* Fix DrawList generation mismatch in overlay cleanup

Prevents "Drawlist id generation wrong index" errors when AdaptiveView
switches between variants by using checked_index() instead of direct
indexing to safely handle recycled DrawList IDs in overlay.end().

* Dispatch WindowGeomChange on web upon CreateWindow

* Move display context updates to window and improve default selector in AdaptiveView
2025-06-03 22:11:55 +02:00
Admin
d48585604d fix up openxr anchors 2025-05-31 21:38:42 +02:00
Admin
b44537fdbb fix up quest xr demo 2025-05-31 20:23:19 +02:00
Admin
f5ffecdd06 move quest xr_net example into main repo 2025-05-31 19:20:56 +02:00
Admin
397b6bffa8 remove warnings 2025-05-31 14:42:50 +02:00
Admin
553aeb21b7 fix glyph rendering edgecase of chinese character 2025-05-31 14:41:24 +02:00
Admin
251737968a temporary sdf-tweak to make the font error go away for now 2025-05-30 12:47:59 +02:00
Kevin Boos
ea18528d4d
Restore overwritten change in internal makepad-android-state library (#763)
* Add `Dock::replace_tab()`: change the inner widget content of a tab
without having to remove and recreate the actual tab itself.

* Fix StackNavigation to forward non-visibility events to all subviews

* Allow saving/restoring the state of the `TextInput` widget

This redoes the changes introduced in #533, but modified for the new version of TextInput

* Add KeyModifiers parameter to `TextInputAction::Returned`

* Fix bug in text centering

* Properly restore the state of the TextInput

* undo version bumps for `makepad-android-state` and `makepad-jni-sys`

---------

Co-authored-by: Eddy Bruel <me@eddybruel.com>
2025-05-29 17:38:43 +02:00
Julián Montes de Oca
784ac0d2ea
Improve Markdown heading scaling and general spacing (#740)
* Fix bug in text centering

* Improve markdown scaling and spacing

---------

Co-authored-by: Eddy Bruel <me@eddybruel.com>
2025-05-28 18:19:43 +02:00
Alex
14e9e41ef2
Remove unwrap() calls in JPEG size detection to prevent panics (#757) 2025-05-23 14:28:10 +02:00
admin
27d4c1e616 filter text input 2025-05-17 14:20:25 +02:00
admin
7a904ea179 filter text input 2025-05-17 14:14:25 +02:00
admin
66671e13af filter text input 2025-05-17 14:09:42 +02:00
Admin
5b3130e624 fix IME backspace on macos regression 2025-05-15 21:53:46 +02:00
Admin
0abdc02f22 1.0.0 2025-05-15 16:46:16 +02:00
Admin
750221451c fix webgl 2025-05-15 16:46:16 +02:00
Eddy Bruel
abbfdfd7bb Update README.md 2025-05-14 12:01:47 +02:00
Eddy Bruel
1cd8861e26 Update README.md 2025-05-13 13:45:22 +02:00
Admin
a1868e19d3 remove shader cache dir warning 2025-05-13 09:23:58 +02:00
Admin
227f3aa152 websocket closed unexpectedly 2025-05-13 09:09:57 +02:00
Admin
28a412f8d9 remove autoversion 2025-05-13 08:58:18 +02:00
Admin
199d20e864 count textflow 2025-05-13 08:51:46 +02:00
Admin
b07dd40d7c studio keepalive 2025-05-13 08:42:40 +02:00
Admin
64fdec5b64 fix tint uizoo windows 2025-05-13 08:34:23 +02:00
Admin
ab846774bf keepalive websocket 2025-05-13 08:07:59 +02:00
Admin
8ecf0d9265 fix linux stdin timers 2025-05-13 07:59:32 +02:00
Admin
8b972ab9d2 studio websocket terminate 2025-05-13 00:22:45 +02:00
Admin
a1ad9ca706 studio websocket terminate 2025-05-13 00:22:22 +02:00
Admin
4d45885472 add back resumable drawing to root 2025-05-13 00:19:37 +02:00
Admin
b9c209246c horrible websocket fix 2025-05-13 00:08:26 +02:00
Admin
2092a4dd42 horrible websocket fix 2025-05-12 23:50:57 +02:00
Admin
8a71619733 horrible websocket fix 2025-05-12 23:47:58 +02:00
Admin
b0cee19a9b windows copy/cut mixup 2025-05-12 23:11:39 +02:00
Admin
77b7b6aad9 ai chat mgr warning 2025-05-12 22:57:21 +02:00
Admin
963a98782f rebase script 2025-05-12 22:56:54 +02:00
Admin
c50eb85ce0 remove chat context warning 2025-05-12 22:56:16 +02:00
Admin
99e6cfb64c windows ime warning off 2025-05-12 22:52:24 +02:00
Admin
548abe1b02 revert jni/android state 2025-05-12 12:47:17 +02:00
Admin
76c878d55c webgl fix 2025-05-12 12:33:32 +02:00
Admin
f5b01c1289 version 0.9.1 2025-05-12 12:09:24 +02:00
Admin
33d131b32d version 0.9.1 2025-05-12 12:08:14 +02:00
Admin
2bb7b6822c fix split 2025-05-12 12:08:13 +02:00
Admin
612e0a43df fix join badly 2025-05-12 12:08:13 +02:00
Admin
ba0e5e7957 chinese font split 2025-05-12 12:08:13 +02:00
okapii
0af7541b27 UI Zoo: removed custom font definitions 2025-05-12 12:04:39 +02:00
Admin
b462cf3301 0.9.0 widgets online! 2025-05-12 10:12:08 +02:00
Admin
d6d534ca09 0.9.0 otw 2025-05-12 10:02:38 +02:00
Admin
77cbfe7d9b 0.9.0 test 2025-05-11 22:39:18 +02:00
Admin
ef1178f1dd 0.9.0 2025-05-11 22:35:20 +02:00
Admin
fc6739237a 0.9.0 2025-05-11 22:31:53 +02:00
Admin
5a687fee93 0.9.0 test 2025-05-11 22:24:36 +02:00
Admin
d71dc0277b remove old font 2025-05-11 22:14:51 +02:00
Admin
6c7e83f0ac auto version ttf-parser unique name 2025-05-11 22:08:12 +02:00
Admin
137dbee5c9 windows stdin time 2025-05-11 20:40:00 +02:00
Admin
f489c6d63e windows stdin time 2025-05-11 20:33:34 +02:00
Admin
b2559b0493 windows stdin time 2025-05-11 20:23:04 +02:00
Admin
9ec000e38e windows stdin time 2025-05-11 20:22:08 +02:00
Admin
9236ffd6c9 windows repaint 2025-05-11 20:18:45 +02:00
Admin
983ea8e5b6 windows repaint 2025-05-11 20:18:00 +02:00
Admin
66f002f167 designer style 2025-05-11 20:12:11 +02:00
Admin
1b4d0f71d1 fix svg on android 2025-05-11 20:04:36 +02:00
Admin
5b2285c604 opengl panic android fix 2025-05-11 19:57:38 +02:00
Admin
4e49401fdb centering label 2025-05-11 19:54:27 +02:00
Admin
47f6a3162a centering label 2025-05-11 19:53:02 +02:00
Admin
4301bd0837 small fonts 2025-05-11 18:43:26 +02:00
Admin
2ac5eeadfb fix def color 2025-05-11 18:27:29 +02:00
Admin
f10203851d disable partial texture updates 2025-05-11 18:17:38 +02:00
Admin
4a6642a87c remove warning 2025-05-11 18:15:58 +02:00
Admin
de8c4496c4 fix webgl theme triggering recompile of all shaders 2025-05-11 18:13:17 +02:00
Admin
41feb04a06 fix html defaults 2025-05-11 17:41:05 +02:00
Admin
7b04e4bfb6 fix ui zoo color 2025-05-11 17:40:53 +02:00
Admin
5a0d9a3287 fix heading margin top 2025-05-11 12:56:26 +02:00
Admin
931da9104a paragraph and heading margins 2025-05-11 12:43:55 +02:00
Admin
e731adbc40 fix heading spacing 2025-05-11 12:28:11 +02:00
Admin
8b22de8e92 fix wasm timerstorm 2025-05-11 12:25:26 +02:00
okapii
9b0ceb7c2d Theme: Changed default tinting color to blue 2025-05-11 12:09:35 +02:00
okapii
ac5e04ff2d Changed default tinting color 2025-05-11 12:09:35 +02:00
okapii
a2d1953f08 Base theme parameter: Improved tinting 2025-05-11 12:09:35 +02:00
okapii
eb4c9ea6f3 UI Zoo toolbar: Improve slider ranges, update font-size slider default 2025-05-11 12:09:35 +02:00
okapii
99ddb2e82f Fix: font-size global theme-parameters 2025-05-11 12:09:35 +02:00
Admin
356b793c09 min wrap spacing 2025-05-11 11:42:02 +02:00
Admin
b1c1fd4dfa heading margin 2025-05-11 11:16:10 +02:00
Admin
43763010ab heading margin 2025-05-11 11:14:04 +02:00
Admin
bb0ddb0bfd fix margin left on codeblock on first line 2025-05-11 11:06:04 +02:00
Admin
2f1686c132 trim end newlines 2025-05-11 10:40:58 +02:00
Admin
39f465310a better linespacing 2025-05-11 10:31:59 +02:00
Kevin Boos
4d83b8fbc5
Add Dock::replace_tab(): change the inner widget content of a tab (#734)
without having to remove and recreate the actual tab itself.
2025-05-10 19:50:49 +02:00
Kevin Boos
fe33ecf888
Fix StackNavigation to forward non-visibility events to all subviews (#735) 2025-05-10 19:50:29 +02:00
Kevin Boos
93b8c44705
Allow saving/restoring the state of the TextInput widget (#736)
* Allow saving/restoring the state of the `TextInput` widget

This redoes the changes introduced in #533, but modified for the new version of TextInput

* Properly restore the state of the TextInput
2025-05-10 19:50:16 +02:00
Kevin Boos
d01acd5b95
Add KeyModifiers parameter to TextInputAction::Returned (#737) 2025-05-10 19:48:59 +02:00
Admin
a83d91c1ac fix linespacing 2025-05-10 19:10:17 +02:00
Admin
bfd402fa19 fix up markdown code blocks a bit 2025-05-10 15:19:48 +02:00
Admin
4e0e82e5bc spacing fixes 2025-05-10 15:01:52 +02:00
Admin
b3bdb834d7 no double row heights 2025-05-10 14:55:26 +02:00
Admin
04415e9b3c text layout fix 2025-05-10 14:52:42 +02:00
Admin
7a7b36e05b remove docs 2025-05-10 14:13:12 +02:00
Admin
206b53a71b fix start of line trim 2025-05-10 11:53:47 +02:00
Admin
42bf320045 attribute tags whitespace 2025-05-10 11:51:15 +02:00
Admin
fc2b255117 remove debug 2025-05-10 11:02:07 +02:00
Admin
0165c5793e smooth zoom 2025-05-10 10:58:14 +02:00
Admin
87e69934a5 collapsed newlines in textflow 2025-05-10 10:57:33 +02:00
Admin
e0fa1b6442 html whitespace collapse 2025-05-09 20:53:35 +02:00
Eddy Bruel
1dffa38ab9 Clear dirty rect when updating atlas textures. 2025-05-09 16:03:56 +02:00
Admin
6b7dab0334 fix up wasm load for uizoo 2025-05-09 15:18:54 +02:00
Admin
0bf95e7254 add temp_y_shift 2025-05-09 15:18:54 +02:00
okapii
8e99dda1a6 UI Zoo: update default tinting color in the toolbar 2025-05-09 13:15:47 +02:00
okapii
b3101b2d3e Vr/Hr: stop making beveling influence dimensions and margins 2025-05-09 13:06:28 +02:00
okapii
1833dd1be0 Ironfish fixes 2025-05-09 13:06:28 +02:00
okapii
d9f2b7df28 ButtonFlatterIcon improvements 2025-05-09 13:06:28 +02:00
okapii
91cdb5a33c UI Zoo: Fix widgetsoverview text 2025-05-09 13:06:28 +02:00
Admin
fa80c554d3 ui zoo sliders and text input hotload fix 2025-05-09 12:19:15 +02:00
Admin
472d5dd565 sliders in ui zoo 2025-05-09 11:44:06 +02:00
Eddy Bruel
7011142c85 Fix bug in text centering 2025-05-09 11:44:06 +02:00
Eddy Bruel
14bcf4383c Don't crash on right-to-left scripts 2025-05-09 10:23:10 +02:00
Admin
c160835b3a fix windows resource load 2025-05-08 22:19:27 +02:00
Eddy Bruël
a5c29db888
Fix text centering (#733) 2025-05-08 13:26:43 +02:00
469 changed files with 15302 additions and 116204 deletions

28
.gitignore vendored
View file

@ -52,31 +52,3 @@ ggml-*.bin
/kokoro_voices/
__pycache__/
tesla_credentials.json
# Studio per-checkout runtime state: terminal scrollback, AI chat logs, window
# layout. Committed once by mistake (fab00bf27 "Studio term histories") — it is
# local scratch, it churns every session, and it had grown to ~30 MB of chat
# JSON and terminal buffers carrying developer email addresses and home paths.
.makepad/
# Working notes, plans and handoff docs. These are scratch for whoever is
# mid-task — they go stale the moment the work lands, and a repo root full of
# them buries the three docs that are actually reference material. Kept on
# disk, out of the repo. The reference docs (README, splash.md, splashgame.md)
# and the Studio runbook (AGENTS.md) stay tracked.
/aigame*.md
/blur.md
/bridge.md
/datasources.md
/game.md
/glass.md
/gps.md
/handoff.md
/isolate.md
/layers.md
/map.md
/map2.md
/profiler.md
/route.md
/shiny.md
/status.md

View file

@ -1,20 +1,7 @@
workspace.members = [
# === app ===
"widgets/dll",
# === apps ===
"apps/route",
# === arcade (game.md) ===
"apps/arcade",
"libs/game/math",
"libs/game/render",
"libs/game/sim",
"libs/game/blocks",
"libs/game/gen",
"libs/game/audio",
"libs/game/script",
"libs/game/net",
"libs/game/coedit",
"libs/game/pkg",
"libs/game/session",
"libs/game/assets",
# === examples ===
"examples/hotload_ui",
"examples/teamtalk",
@ -53,19 +40,46 @@ workspace.members = [
"examples/cad",
"examples/box3d",
"examples/hello_world",
# === xr app ===
"xr",
# === studio ===
"studio/hub",
"studio/desktop",
# === necessary tools ===
"tools/cargo_makepad",
"tools/map_tiles",
"tools/map_bake",
"tools/remote",
"tools/arcade_eval",
# === tests ===
"libs/box3d",
"libs/gif",
"libs/gltf",
"libs/splat",
"libs/latex_math",
"libs/mbtile_reader",
"libs/map_nav",
"libs/geodata",
"libs/makepad_ai",
"libs/tesla",
"libs/converse",
"libs/pdf_parse",
"libs/regex",
"libs/svg",
"libs/apple_sys",
"libs/mlx",
"libs/mlx/cli",
"libs/shared_bytes",
"libs/tsdf",
"platform/network",
"libs/filesystem_watcher",
"libs/live_reload_core",
"platform/script/test",
"platform/script/std",
"platform/studio",
"studio/hub",
"libs/makepad_test",
"libs/makepad_test/macros",
"studio/desktop",
"tools/cargo_makepad",
"tools/tui_test",
"tools/profiler",
"tools/map_tiles",
"libs/mb3d",
"libs/openexr",
"libs/git",
"libs/fast_inflate",
"libs/lz4",
"xr",
"libs/cef",
]
workspace.exclude = [

View file

@ -4,10 +4,10 @@
- Discord: https://discord.gg/adqBRq7Ece
- Rik Arends: https://twitter.com/rikarends
- Eddy Bruel: -
- Sebastian Michailidis: https://bsky.app/profile/okpokpokp.bsky.social
Makepad is an AI-accelerated application and game development environment for Rust. It combines a high-performance UI runtime, a live-editable design language, and a fast iteration loop so you can build native and web apps with a tight feedback cycle.
It also has a large set of AI backends integrated for embedding llms or generative AI models inside applications or run them easily on local hardware
Makepad is an AI-accelerated application development environment for Rust. It combines a high-performance UI runtime, a live-editable design language, and a fast iteration loop so you can build native and web apps with a tight feedback cycle.
This repository contains the core engine, widgets, tools, and examples.
@ -17,7 +17,6 @@ This repository contains the core engine, widgets, tools, and examples.
- A Rust-first framework with a scriptable UI DSL.
- A studio app for running, inspecting, and iterating on examples and projects.
- An AI-accelerated workflow: structure and tooling aimed at making code generation, refactoring, and iteration faster and safer.
- Simple forward 3D renderer for making games on Quest and all other supported platforms
## Features
@ -53,7 +52,7 @@ Linux build/runtime dependencies are listed in `./tools/linux_deps.sh`:
Use the apt-get command below, or run the script on Ubuntu/WSL2:
```bash
sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config clang ca-certificates libssl-dev libx11-dev libxcursor-dev libxkbcommon-dev libxrandr-dev libxi-dev libxinerama-dev libasound2-dev libpulse-dev libwayland-dev wayland-protocols libegl1-mesa-dev libgl1-mesa-dev libgles2-mesa-dev libglx-dev libdrm-dev libgbm-dev libgl1-mesa-dri mesa-vulkan-drivers mesa-utils mesa-utils-extra x11-apps gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-gl gstreamer1.0-alsa gstreamer1.0-pipewire libgstreamer1.0-0 libgstreamer-plugins-base1.0-0 libgstreamer-gl1.0-0
sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config clang ca-certificates libssl-dev libx11-dev libxcursor-dev libxkbcommon-dev libxrandr-dev libxi-dev libxinerama-dev libasound2-dev libpulse-dev libwayland-dev wayland-protocols libegl1-mesa-dev libgl1-mesa-dev libgles2-mesa-dev libglx-dev libdrm-dev libgbm-dev libgl1-mesa-dri mesa-vulkan-drivers mesa-utils mesa-utils-extra x11-apps
```
## Build And Run Makepad Studio

308
aigame.md Normal file
View file

@ -0,0 +1,308 @@
# aigame — moving the AI Game Maker from Godot to Makepad
Plan for replacing the Godot backend of the kids' Game Maker (`examples/godot`) with a
small, AI-generatable game engine running **inside makepad itself**, built from three
things we already have: the **splash script VM** (hot-reloadable, per-isolate), the
**box3d** physics engine, and the **xr** 3D scene framework. Written 2026-07-09.
---
## 1. What we have today (the Godot version)
**Where games go:** `~/games/<name>/` (override with `GAMEMAKER_HOME`), one Godot 4.7
project per game. The app owns `tools/` (agent harness) and `CLAUDE.md`; the kid's game
is `project.godot` + `scenes/*.tscn` + `scripts/*.gd`. Per-game app state (chat log,
Claude session id, model choice) lives in `<game>/.gamemaker/`.
**The loop:** kid holds F1 and talks (Whisper) or types → transcript goes to Claude Code
(via `makepad_ai::ClaudeCodeAgent`, tool policy locked to Read/Glob/Grep/Edit/Write +
`tools/gd` only) → Claude edits GDScript/tscn files → the app keeps the kid on the
last-good running game and relaunches it only when the turn completes.
**What the AI actually builds** (from the real `~/games/my-game`, ~2600 lines generated):
- Everything is **procedural colored boxes** — no image/model/sound assets at all
(enforced by the system prompt). 2D: `StaticBody2D`+`ColorRect` ground segments,
platforms, moving platforms, walls, a goal flag. 3D: cube ground/stairs/towers/trees,
a gold goal block, box-people.
- **Character controllers**: `CharacterBody2D/3D` + gravity + jump + `move_and_slide`,
respawn on fall, mount/dismount vehicles.
- **Behaviors**: per-frame `_physics_process(delta)` steering (villagers chase, soldiers
patrol), group queries (`get_nodes_in_group("vehicle")`), distance checks, spawn/despawn,
timers (`create_timer`), win/lose conditions.
- **HUD**: `Label`s toggled visible ("You win!", "Caught!").
- **Input** through named actions only (`ui_left/ui_right/ui_accept`) so keyboard, input
tapes, and gamepad (AgentEye binds the A button) all work through one vocabulary.
This is the complete capability envelope a kids' game engine needs. It is small.
## 2. How the agent remote-operates the game and looks at pictures
Three mechanisms, all file-based (Claude only has `tools/gd` as a shell surface):
| Verb | Mechanism | What the AI gets |
|---|---|---|
| `gd peek` | Drops `.agent/peek_request`; the **AgentEye autoload** in the *live* game polls (250ms), grabs 4 viewport screenshots over ~1.2s via `get_viewport().get_texture().get_image()`, writes player pos/vel/`is_on_floor()` | `.agent/sheet.png` + `state.txt` — sees what the kid sees, zero interruption |
| `gd shot <scene> [frames] [tape]` | Second Godot instance boots `tools/harness.tscn`; harness loads the target scene, **replays a JSON input tape by frame number** (`{"f":30,"press":"ui_accept"}``Input.action_press`), prints `[probe]` pos/vel/floor lines every 15 frames; recorded with `--write-movie --fixed-fps 60` (deterministic: same tape ⇒ same frames). Since 2026-07-09 it launches via `open -g` + an unfocusable offscreen window so it never steals the kid's focus | contact sheet + numeric probe log — "the jump feels floaty" becomes a number |
| `gd errors` | Greps the run log for `SCRIPT ERROR` / `ERROR:` | error text |
**The pictures:** `tools/sheet.py` tiles N evenly-spaced frames into one labelled contact
sheet (`.agent/sheet.png`) so the agent reads *one* image showing motion over time instead
of 120 frames.
**Weaknesses inherent to the Godot backend** (what the migration removes):
1. **Separate process.** Applying `.tscn` changes needs a full game restart (state loss,
focus management, pid babysitting, zombie processes). We just spent a day making this
tolerable; in-process it disappears entirely.
2. **Capture needs a real (hidden) window** — Godot's `--headless` crashes with
`--write-movie`. Makepad has a true headless CPU renderer.
3. **Opaque runtime.** The only introspection is print statements the harness happened to
include; errors come from log-grepping.
4. **Two unfamiliar languages** (GDScript + tscn) and a giant API surface the model can
misuse. A curated DSL of ~25 constructs is easier to prompt and to verify.
## 3. Building blocks in makepad (surveyed 2026-07-09)
### 3.1 Splash script VM — the hosting/hot-reload story is already built
- `widgets/src/splash.rs`: the `Splash` widget evaluates DSL **strings** in a dedicated
isolate VM (`cx.alloc_splash_vm_with_network`), with instruction limits
(`with_instruction_limit(200_000, …)`), and re-evaluates **incrementally** via
`eval_with_append_source` parser checkpoints — this is how aichat streams a growing
`runsplash` block into a live widget. Per-isolate `let`/`fn` state persists across events.
- The DSL is a real language: `let`/`fn`, templates, `for`, `if/else`, closures,
`on_click`-style handlers, struct arrays, `promise()/.await()`, HTTP. `splash.md` is the
authoring manual the AI already follows; `examples/splash_preview/` is an offline
generate-and-verify corpus harness (drives the `claude` CLI, evals every generated app,
flags empty widget trees).
- **Gaps for games:** no script-facing frame tick or timers, no keyboard events routed
into isolates, and eval errors are *not* fed back (a broken block renders blank; errors
only reach stderr via `ScriptVm::drain_errors`). All three are core workstreams below.
### 3.2 box3d — the physics core (`libs/box3d`, pure Rust port of Erin Catto's Box3D)
- Shapes: sphere, capsule, convex hull (`make_box_hull`), triangle mesh, heightfield,
compound. Bodies: static/kinematic/dynamic. Full joint set. Sensors with begin/end
touch **events**, contact/hit events, ray/shape casts, explosions, wind.
- A **kinematic character controller** (`mover.rs`, `world_collide_mover`,
`world_cast_mover`) — exactly what the player/NPC vocabulary needs.
- **Bit-exact deterministic** across architectures and worker counts, faster than Rapier
on 8/9 benchmark scenes, with a built-in **snapshot + record/replay** substrate
(hash-exact). This upgrades the whole verify story: same tape ⇒ same *simulation*, not
just same frames.
- Consumer API is flat free functions (`create_world`, `create_body`,
`create_hull_shape`, `world_step`, `body_get_transform`). **Rust-only today — zero
script bindings.** That binding layer is the single biggest work item.
- `examples/box3d/src/main.rs` already shows the renderer we need: instanced lit
boxes/spheres (`DrawPhysMesh` script-shader, per-instance color+transform), an
`XrCamera` orbit, `NextFrame`-driven stepping. ~600 lines, self-contained.
### 3.3 makepad-xr — the 3D scene layer (`xr/`, crate `makepad-xr`)
- `XrNode` scene graph (pos/rot/scale, physics body kind, children), object library
(`Cube`, `IcoSphere`, `Gltf`, `FractalTree`, splats), behaviors in Rust (`Tank`, `Car`,
`Shooter`), 2D-UI-on-a-plane (`XrView` — a free HUD system), PBR-ish shading.
- **Worlds are authored in the script DSL and hot-reload** (`XrNode` handles
`apply.is_reload()`; `on_render` closures rebuild geometry live) — proof that
"AI edits script → live 3D scene updates" already works in this codebase.
- Desktop fallback: orbit camera + **gamepad** gameplay (`cx.game_input_states()`).
Quest: hands, passthrough, depth-scanned colliders, multiplayer (`xr/net`).
- Caveats: physics is **Rapier3D** (not box3d), **no audio**, **no keyboard gameplay
input**, and behaviors are compiled Rust (only the scene layer is scriptable).
### 3.4 Remote-operate substrate we already proved this session
- **Headless renderer**: `MAKEPAD=headless` builds render real frames to PNG on CPU with
JIT-compiled shaders (`--draws=N`, `MAKEPAD_HEADLESS_OUT_DIR`). We used it today to
pixel-verify a widget fix. This replaces the hidden-window capture instance outright.
- **makepad_test** (`libs/makepad_test`): selector-driven UI automation (click/fill/
wait_text) against a headless app instance, with failure screenshots — the skeleton of
an input-tape runner.
- **Studio hub bridge**: `WidgetTreeDump` / `Click` / `Screenshot` RPC into a running
app — the skeleton of `peek` without file polling.
## 4. The aigame engine — design
**Principle: keep the exact product shape** (voice → Claude edits files → kid keeps
playing until the AI is happy → instant apply), swap the engine underneath. The Game
Maker app shell (chat, Whisper, TTS, sessions, model picker, relaunch policy) is reused
as-is; only "relaunch Godot" becomes "hot-swap the game script".
### 4.1 Architecture
```
~/games/<name>/game.splash ← the file(s) Claude edits (Read/Edit/Write, same as now)
│ (file watch)
GameMaker app ─── GameHost widget ← owns a splash isolate + a box3d World + fixed 60Hz tick
│ │
│ ├─ shadow-eval on change: new isolate, eval, 1 smoke tick
│ │ ├─ clean → swap in (kid sees change in <1s, mid-play)
│ │ └─ errors → keep last-good running, errors go to the agent
│ ├─ renderer: instanced boxes/spheres/capsules (from examples/box3d)
│ ├─ HUD: plain makepad widgets overlaid (or XrView in 3D)
│ └─ ActionMap: keyboard + gamepad + tape → named actions
└─ agent harness (tools/ag): test / peek / errors — see §5
```
- **One process.** The game is a widget in the Game Maker window (optionally poppable
into its own window later). No pids, no focus stealing, no restart.
- **"It works" state, upgraded:** today the kid keeps the old *process*; here the
last-good *isolate + world* keep running while the new source shadow-evals. A turn that
ends broken never even flickers the kid's game — and the AI gets the error text
immediately instead of a blank screen (fixes the Splash blank-on-error gap).
- **Fixed timestep** (1/60, 4 substeps, like the box3d example) for determinism; render
interpolation optional later.
### 4.2 The script surface the AI writes (curated, not raw bindings)
Bind a small **game vocabulary** into the isolate rather than exposing raw box3d — a
~25-construct API is easier to prompt, to sandbox, and to keep stable. Sketch (syntax
illustrative, follows splash rules):
```splash
// game.splash — everything the AI edits lives here
let SPEED = 240.0
let JUMP = 520.0
fn build_world() {
game.gravity(vec3(0, -30, 0))
game.box{pos: vec3(0, -1, 0) size: vec3(120, 2, 8) color: #x3a8f4a} // ground
for i in 0..6 {
game.box{pos: vec3(10 + i * 8, i * 2, 0) size: vec3(4, 1, 4) color: #x8a6a3a}
}
game.box{pos: vec3(58, 13, 0) size: vec3(2, 2, 2) color: #xf5c13a tag: "goal" sensor: true}
}
player := game.mover{pos: vec3(0, 2, 0) size: vec3(1, 2, 1) color: #x4466aa lock_z: true}
npc := game.mover{pos: vec3(30, 2, 0) size: vec3(1, 2, 1) color: #xaa4444 lock_z: true
on_tick: |dt| { self.walk_towards(player.pos(), 3.0) }
}
player.on_tick: |dt| {
self.walk(input.axis("left", "right") * SPEED * dt)
if input.pressed("jump") && self.on_floor() { self.jump(JUMP) }
if self.pos().y < -20 { self.teleport(vec3(0, 2, 0)) }
}
game.on_touch: |a, b| {
if a.tag() == "goal" || b.tag() == "goal" { ui.hud_win.set_visible(true) }
}
game.camera.follow(player, side_2d: true)
```
Vocabulary checklist, derived 1:1 from what the Godot corpus actually used:
| Corpus need (Godot) | aigame construct | Backed by |
|---|---|---|
| ColorRect/box world building | `game.box/sphere/capsule{...}` (static/dynamic/kinematic, color, tag, sensor) | box3d shapes + instanced `DrawPhysMesh` |
| CharacterBody + move_and_slide | `game.mover{...}` + `walk/jump/on_floor/teleport` | box3d `mover.rs` character controller |
| `_physics_process(delta)` | `on_tick: \|dt\| {...}` per entity + `game.on_tick` | fixed-step pump into isolate |
| groups / `get_nodes_in_group` | `tag:` + `game.find("tag")`, `e.distance_to(x)` | engine-side registry |
| Area2D / goal triggers | `sensor: true` + `game.on_touch` | box3d sensor events |
| moving platforms | kinematic body + `on_tick` setting velocity | box3d kinematic |
| vehicles/mount | `attach/detach` (weld joint or parent) | box3d joints |
| HUD labels | plain widgets over the viewport (`ui.hud_win`…) | makepad widgets (free) |
| respawn / timers | `teleport`, `game.after(secs, \|\| {...})` | engine timer wheel |
| input actions | `input.pressed("jump")`, `input.axis(..)` | ActionMap (§4.3) |
| 2D platformer | `lock_z: true` + `side_2d` camera | box3d motion locks / parallel joint — **verify which; fallback: post-step plane clamp** |
| win/lose sounds | `game.beep{...}` synth SFX | `cx.audio_output` (already used for TTS) |
### 4.3 Input: one ActionMap for keyboard, gamepad, and tapes
Mirror the Godot design that made tapes+controllers free: script code only ever sees
named actions (`left/right/jump/up`). The engine maps arrow keys/WASD + gamepad
(`cx.game_input_states()`, as xr's Tank does) + **tape events** onto the same names.
Makepad has full keyboard events (`Event::KeyDown`/`KeyCode`) — xr just never wired them;
we wire them in the GameHost, not in xr.
### 4.4 Renderer choice
**Phase 1: lift `examples/box3d`'s renderer** (instanced lit unit-cube/sphere +
`XrCamera`, ~200 lines) into the GameHost. It draws exactly the corpus art style.
**Later: converge with xr** — adopt `XrNode`/`XrView` for scene+HUD and port xr's physics
from Rapier to box3d (justified independently: box3d is faster on 8/9 scenes and
deterministic; one physics engine in the tree instead of two). That convergence buys
Quest/hands/multiplayer for the *same game scripts* — a kid's game playable in VR — but
it is explicitly not on the critical path.
## 5. The agent harness on makepad (remote-operate, tier by tier)
Same three verbs, better substrate. `tools/ag` (or a `gd`-compatible shim so the prompt
barely changes):
| Verb | Godot today | aigame |
|---|---|---|
| `ag test [frames] [tape]` | hidden Godot instance, --write-movie, probe prints | **headless run of the same GameHost** (`MAKEPAD=headless`): eval `game.splash`, feed the tape into the ActionMap, `world_step` N frames, render PNGs on CPU, emit probe lines (engine reads pos/vel/on_floor directly — no print statements needed). box3d determinism ⇒ bit-exact repeatability, stronger than Godot's fixed-fps movie |
| `ag peek` | file-RPC into live game (AgentEye), viewport screenshots | in-process: the app screenshots its own game pass texture + dumps entity state on request (file trigger kept for CLI compat, or a local socket). Kid keeps playing, same as now |
| `ag errors` | grep run logs | **drain the script VM error queue** — precise parse/runtime errors with line numbers, returned as text. Also auto-attached to the turn when a shadow-eval fails, so the AI often self-corrects *without* running anything |
| pictures | `sheet.py` contact sheet | keep `sheet.py` verbatim (it's engine-agnostic: dir of PNGs → one labelled sheet) |
| tapes | JSON `{"f":N,"press":"ui_accept"}` | same format, actions renamed; probe list = tags |
Bonus unlocked by box3d: `ag test --record` / snapshot scrubbing — the AI can capture a
deterministic recording once and re-probe it at different frames without re-running.
## 6. Changes to the Game Maker app (small)
- `play_game()`/`relaunch_if_pending()``GameHost::reload(path)` (shadow-eval + swap).
The turn-completion policy from 2026-07-09 stays: kid keeps last-good until the AI is
happy — it just gets cheaper (no process restart, sub-second apply).
- System prompt + per-game `CLAUDE.md`: rewritten against the aigame DSL; ship a new
**`aigame-dsl.md`** authoring guide (the `splash.md` equivalent for games: the
vocabulary table, tick/input/tape rules, the `#x` hex rule, worked platformer example).
- Template: `~/games/<name>/game.splash` starter + `tools/ag` + tapes. `refresh_harness`
(added today) already re-stamps tools on project switch.
- Permission policy shrinks: Claude gets `Edit/Write(./**)` + `Bash(./tools/ag:*)` only.
## 7. Phases and milestones
**Phase 0 — proof of loop (the risky part, do first)**
Script-bind the minimum box3d surface (world/step, box+sphere bodies, transforms) into a
splash isolate; GameHost widget with fixed tick, keyboard ActionMap, `on_tick` dispatch
into script closures; lift the box3d example renderer.
*Milestone:* a ~100-line `game.splash` platformer (locked z) runs at 60fps and hot-reloads
on file save without dropping world state of the running instance until swap.
*Verify here:* per-tick script-call overhead with ~20 entities × 60Hz under instruction
limits; box3d 2D locking mechanism; headless JIT compiles `DrawPhysMesh` (we fixed the
scalar-cast JIT bug today — same risk class).
**Phase 1 — the corpus vocabulary**
Mover controller, sensors → `on_touch`, tags/queries, spawn/despawn, timers, camera
follow (side-2D + third-person), HUD overlay widgets, gamepad, synth SFX (`game.beep`).
*Milestone:* hand-port `~/games/my-game` (both the 2D level and the 3D chase sandbox) to
`game.splash` — the real generated corpus is the acceptance test for vocabulary
completeness.
**Phase 2 — the agent loop**
Headless `ag test` (tape → frames → probe → sheet), `ag peek`, VM-error round-trip +
shadow-eval, Game Maker integration, new prompt + `aigame-dsl.md`, template swap.
*Milestone:* end-to-end kid session: "make him jump higher" → edit → self-test headless →
turn completes → game hot-swaps mid-play; zero focus steal; broken edits never reach the
kid and come back to the AI as line-numbered errors.
*Regression harness:* splash_preview-style batch corpus — a set of recorded kid requests
run through the real `claude` CLI against `aigame-dsl.md`, each result eval-checked and
tape-smoke-tested headlessly.
**Phase 3 — convergence and reach**
Port `makepad-xr` physics Rapier→box3d; host aigame scenes on `XrNode`/`XrView`; the same
`game.splash` then runs on Quest (hands/passthrough) and inherits xr multiplayer.
Optional: box3d record/replay scrubbing in the harness; state-preserving hot reload via
world snapshots.
## 8. Open questions / decision log
1. **Curated `game.*` API vs raw box3d bindings** — recommended: curated (smaller prompt,
stable across engine refactors, sandboxable). Raw bindings can come later for power use.
2. **2D story** — one engine (3D + locked axis + orthographic-ish side camera), not a
second 2D engine. Needs the motion-lock verification in Phase 0.
3. **Where behaviors live** — corpus says script-side `on_tick` closures suffice (steering
is ~10 lines); compiled-Rust behaviors (xr's Tank pattern) stay an escape hatch for
things script is too slow for.
4. **Splash-in-chat vs GameHost** — games do NOT run as `runsplash` chat blocks; the
GameHost is a dedicated widget with its own isolate, tick, and input focus. Chat
blocks stay for the AI showing UI snippets.
5. **Error feedback for aichat generally** — the VM-error round-trip built for aigame
(drain_errors → agent) should be upstreamed to the `Splash` widget too; blank-on-error
hurts every runsplash use case.

52
aigame_parity_gap.md Normal file
View file

@ -0,0 +1,52 @@
# Full-parity gap: current ~/games/my-game vs the aigame engine
Goal (rik, 2026-07-09 late): run the little Godot game — CURRENT state — fully in
splash/gamemaker. Evidence: 21:54 `gd shot` sheet + a full close-read of all 17 actor
scripts. The game now spawns **~44 creatures + 2 vehicles**: Giant DogDay guardian
(23-box model, 1.9x scale, intercept-charges nightmares), 3 headcrabs (leap + latch to
the player's head, 0.5x speed debuff, shake off by jumping), the Prototype
(weeping-angel: only moves unwatched, LOS dot 0.55 vs camera forward), Baba Chops ram
(27 boxes, fire eyes with proximity-ramped emission), Nightmare Huggy (arm-reach lerp),
4 nightmare critters, 10 farm animals (per-kind synth calls w/ pitch), Huggy pack,
Kissy-as-bodyguard, grapple hand (yank player to walls / haul creatures, stretched
cable), 64-chunk welded smooth terrain (256x256 cells, 0.5 terracing, caves, alpha
water at y=3.5), ProceduralSky, shadowed sun, crosshair + hint + 4 colored flash HUD.
**Key architectural relief:** Godot NEVER rotates the physics body — only the visual
`Model` child yaws (atan2 facing + turn-rate clamp). Our unrotated-AABB physics is the
same design; the gap is visual/animation, not physics.
Axis trap for the re-port: main3d.gd heightfield is x-major (`i*(CELLS+1)+j`), our
game.terrain is z-major row-major — transpose or the goal lands on the wrong peak.
(The Godot-side "_heights@62" parse error is a stale Godot cache: identifier doesn't
exist on disk; file is internally consistent.)
## Engine work — Fork A "the look" (game_view render/world)
| # | Item | Spec from the game |
|---|---|---|
| A1 | Per-entity model yaw | auto-face velocity w/ turn-rate clamp (default movers), `game.face(id, yaw)` / `turn_rate` override; physics AABB unchanged |
| A2 | Owner-local parts | part offsets rotate with owner yaw; fronts at z convention |
| A3 | Part animation | `game.move_part(part, {pos, rot_x, rot_z…})` engine-lerped (t≈dt*9, Godot's constant); `game.scale(id, v)` model scale (CatNap curl 1/0.6/1, DogDay 1.9x); headcrab spin |
| A4 | Emission | glow parts (eyes 34, runtime ramp 1.5→5, bolt/beacon); `glow:` spawn opt + `game.glow(id, e)` |
| A5 | Sky + ambient + fog | gradient top (0.32,0.58,0.9) → horizon (0.75,0.87,0.96); distance fade |
| A6 | Blob shadows | dark ground quad per mover at height-lookup y (real shadow maps out of scope) |
| A7 | Smooth terrain | triangulated heightfield mesh, flat per-tri normals, per-vertex height colors (SAND≤3.6/GRASS≤13/DIRT≤17.5/STONE≤21/SNOW), height-lookup collision; translucent water sheet (0.25,0.55,0.85,0.6) |
## Engine work — Fork B "systems"
| # | Item | Spec |
|---|---|---|
| B1 | Ride attach + debuff | `attach(..., {mode:"ride", spin})` per-frame head-follow; `game.speed_mult(id, f)`; shake-off = script (vel.y > 5 or timeout → detach) |
| B2 | Grapple support | `game.beam(a, b, {size,color})` stretched transient box (cable); haul = attach + detach-with-pop (exists); yank = set_vel (exists) |
| B3 | Camera | third-person low rig: pivot height 1.6, boom 10, pitch clamp [1.2, 0.25], occlusion pull-in vs terrain/boxes (`max(1, hit0.5)`), ignore tagged scenery; optional mouse-look |
| B4 | HUD slots | crosshair toggle, hint line (top-left), 4 independent colored center banners: `game.text(slot, msg, {color, size})`; billboard labels: multiple per entity, color/size/height opts |
| B5 | Synth voices | bark, moo, clank, whip (+ per-call pitch already works) |
## Script-side patterns (no engine work — fixture implements with existing APIs)
group-AI intercept (tags + find + dual distance gates), LOS gate (cam_yaw dot), duck
damage dispatch (tag → handler table in script), stun/stagger contract, animal AI.
## Sequence
Fork A → Fork B (same files, sequential) → Fork C re-port of the CURRENT game as
fixtures/sandbox3d.splash v2 (acceptance test) → GPU visual check by rik.

179
aigame_port_findings.md Normal file
View file

@ -0,0 +1,179 @@
# aigame port findings — my-game 3D sandbox → game.splash
Result of test-porting the AI-generated Godot game (`~/games/my-game`, the active
3D sandbox: ~2100 lines across main3d + 9 actor scripts) onto the gamemaker
engine as one `game.splash`. Fixture:
`examples/gamemaker/resources/fixtures/sandbox3d.splash` (~370 lines — 5.7×
denser than the GDScript, mostly because the engine owns spawning/physics/SFX).
Written 2026-07-09. Companion checklist: `aigame_port_inventory.md`.
## Verification
- Whole world evaluates clean first-load: **1036 entities** (961 terrain columns,
water sheets, 14 trees, goal + beacon, 19-creature cast, 2 trucks), zero eval
or tick errors at the 500k/tick instruction limit.
- Tape run (`ag test`, 200 frames): spawn→fall→land at exactly ground+half;
camera-relative walk at exactly SPEED 6.0; jump arc peaks +2.25 = JUMP²/2G to
the decimal; wander AI steers the cast; respawn works. Probe numbers, not hopes.
- Mount/drive/dismount verified end-to-end *by accident*: the first fixture spawn
was 2.3 units from a truck and the player silently auto-mounted at tick 1 —
seat height, top speed, and jump-to-dismount all matched the Godot behavior
before I even tried to test them. (Fixed by matching the original's player
transform (-8, 11, 8).)
- NOT verified visually (headless pane compositing still pending, task #9):
colors, camera feel, HUD text rendering. Not exercised behaviorally: nightmare
wake/pounce and the win touch (code-verified only; both use the same verbs the
probes exercised).
## Engine bugs the port flushed out (both fixed)
1. **Sensors were solid.** `step_world` filtered collision candidates by body
kind only — `sensor: true` boxes (goals, my water sheets, the beacon) blocked
movement, contradicting the documented contract. One-line filter fix. The
starter game never noticed because its goal floats at jump apex.
2. **(Earlier, same session) optional positional args trapped** — probing
`args[1]` on a 1-arg call failed the whole eval. Every optional arg would
have hit this.
A port whose only failures were engine bugs, not script bugs, is a good sign
for the DSL's authorability.
## The six predicted cleanups — verdict from actually porting
| # | Prediction (aigame_port_inventory.md) | Verdict |
|---|---|---|
| 1 | First-class SFX bank + synth | **Confirmed hard.** The corpus had invented `squeak` and `roar` beyond the bank we shipped — the bank grew twice in one day. Named-bank + beep/jingle is the right shape; expect the bank to keep growing from corpus usage. |
| 2 | Tags + broadcast/duck-dispatch | **Half-confirmed.** `find`/`tag`/`distance` + script-side state objects covered everything the port needed — cross-actor "messages" (enrage/zap/heal) became plain field writes on shared script objects, which is *better* than Godot's group+has_method bus. A native broadcast API is NOT needed while one script owns all actors. It becomes needed only if games ever split across isolates. **Deferred, deliberately.** |
| 3 | Script raycast | **Dodged, honestly.** Ledge-probe AI was replaced by heightmap lookup (`h_at`) because terrain heights are script data anyway. Fine for this game; a shooter that needs line-of-sight will force `game.raycast`. Keep on the roadmap, don't build speculatively. |
| 4 | Camera-relative helper | **Confirmed, split in two.** The missing primitive wasn't a movement helper — it was `game.cam_yaw()` (the orbit camera's yaw was engine-private). With yaw readable, two lines of cos/sin in script do the rest; documented as a pattern instead of an API. |
| 5 | `attach/detach` (seats/carrying) | **Confirmed emphatically.** Replaced ~100 lines of per-actor teleport-following + collision toggling in GDScript with 2 calls. Vehicles, passengers, and the dismount-pop all fell out. |
| 6 | Mover platform carry | Already in the engine (kinematic floor_id carry); the port didn't stress it (no moving platforms in the 3D sandbox — they're in the 2D game). |
## New findings (not predicted)
1. **No RNG in the script language** — the single most-used Godot facility
(randf/randi everywhere in wander AI) simply didn't exist. Added
`game.rand()`/`game.rand_range(a,b)`, xorshift **seeded per eval**: wander AI
now replays identically under input tapes, which Godot's `randomize()`
corpus could never do. Determinism became a feature of the port.
2. **No noise either** — the terrain wants value noise. A 12-line script
`hash/smooth/noise2` worked fine at 31×31. At the original's 256×256 it
wouldn't (65k columns ≈ 3M+ instructions, and 65k entities would swamp both
the O(statics×movers) physics and the instanced renderer). The Godot game
itself had to weld chunks — scale is an *engine* concern in any engine.
→ Roadmap: `game.terrain({cells, cell, heights})` native heightfield
(box3d has heightfield shapes waiting) + greedy column merging.
3. **`shoot` was a hardcoded gap** — the ActionMap knew jump but nothing else;
the corpus registers custom actions at runtime (mouse+F+gamepad X). Added F →
`shoot`/`shoot_pressed`. Real fix on the roadmap: `game.action("dash", "KeyQ")`
runtime action registration, like the corpus does in Godot.
4. **Projectiles work but are clunky**: a bolt = gravity-0 mover + set_vel +
script-side life/hit bookkeeping in a `retain` closure. Fine at 5 bolts;
a bullet-hell would want `game.spawn_projectile({vel, life, on_hit})` with
engine-side lifetime. Mover-vs-mover hits also aren't reported by `on_touch`
(sensor×mover only) — the port used distance checks; real overlap events for
movers is a small, worthwhile addition.
5. **One HUD line is enough** — the original's 5 labels + colors + cancel-token
flashes collapsed into `game.text` + a 6-line script `flash()` with a
generation counter. Colored/positioned HUD text can wait.
6. **Multi-part models are the visible fidelity loss.** Every creature is one
colored box; the originals have legs/shirt/head/eyes/smile built from 612
cubes, billboard nametags, emissive eyes. The single biggest visual upgrade
per line of API: `game.part(id, {offset, size, color})` decorative child
boxes (no physics), plus `game.label(id, text)` nametags. This is also what
made the Godot corpus *charming* — worth prioritizing over new mechanics.
7. **Script ergonomics held up.** Struct-arrays of actor state + one shared
steering function expressed 9 GDScript classes in ~120 lines. Field mutation
through array elements, closures capturing top-level `let`s, and `retain`
with side effects all just worked. The `0.0 - x` workaround for (possibly
fine) unary minus should be tested and, if broken, fixed — it's the ugliest
thing in the fixture.
## Fidelity ledger (what the port drops vs the original)
- **Terrain at 1/8 resolution** (31×31×4u columns vs 256×256×0.625u welded
chunks): terraces are chunkier, no caves (cave-carving needs the fine grid to
read as tunnels), no smooth triangulated slopes, no vertex-color blending.
- **Retired cast stays retired** (Huggy, Robot, soldiers — the original has them
commented out too), so Kissy's enrage/defend/flee arcs are dormant here as
there; her follow behavior is live.
- Single-box creatures, no nametags, no arm-reach animation, no emissive glow,
no procedural sky (fixed background), water is an opaque thin sheet (alpha
untested in the cube renderer), no camera-blocking ray (camera can clip
through hills), no mouse-capture (orbit-drag instead), mini-variants are
recolored critters.
- Behavior approximations: injured-Kissy heals into a follower (original swaps
to a full Kissy with her own arc); nightmare glow-eyes become a body-color
swap; critter squeak probabilities eyeballed.
## Bottom line
The port took one authoring pass, found two engine bugs and five API gaps, and
every gap closed with a small primitive rather than a framework. The engine's
current vocabulary + script-side state objects genuinely cover the corpus's
*behavioral* range; the visible gap is decoration (parts/labels), and the
structural ceiling is terrain scale — both have clear, small next steps.
## v2 re-port (2026-07-09 late) — the full current game
`fixtures/sandbox3d.splash` v2 ports the game's CURRENT state (~3600 GDScript lines,
44 creatures + 2 trucks) onto the upgraded engine: verified headless — eval clean at
90 entities, tape run writes test_done/captures/probes, plaza floor exactly 7.9 and
walk exactly 6.0 (Godot ground truth), and two identical runs produce byte-identical
probes. The tape also exercises the full truck mount/drive/dismount chain.
### Fidelity ledger
**Exact:** every spawn-table name/position/size/color/speed; all behavior constants —
Giant DogDay guardian (follow 34/stand 9.5, charge 8.4 to an intercept point, bonk 5 →
stagger+knockback+bark, golden beam flash), Kissy bodyguard (gates 18/34, intercept,
shove 2.2), headcrabs (chase 26, leap at 6 [up 8 fwd 7, cd 1.8], latch 1.5 → ride-attach
(0,1.95,0) spin 2 + speed_mult 0.5, shake-off on vel.y>5 or 4s, self-stun 2.5),
Prototype weeping-angel (LOS dot 0.55 in 34, creep 70, eye glow 1.5/4.5), Baba Chops
(charge 14 @7.2, ram 2.4, fire-eye glow 2→5 ramp), Nightmare Huggy (hunt 90, arm-reach
via move_part inside 30), nightmare critters (creep 30, nip 1.3 bounce), CatNap
(sleep-curl scale 1/0.6/1, wake 9, catch 1.9), farm animals (shy 4.5/flee 4.4, calls
414s at Godot's per-kind pitches), trucks/passengers, terraced terrain recipe
(12.5+noise, snap 0.5, plaza 26/14/7, water 3.5, height-color bands), goal on the true
peak + glowing beacon, third-person camera (1.6/10/0.35) + crosshair + hint, zap
dispatch on all threat kinds, HUD flash tokens, win latch, full synth bank.
**Approximated:** terrain colors are single-color auto-shade (Godot has height bands
sand/grass/dirt/stone/snow — a 257² script colors array would blow the eval budget;
engine follow-up: band colors as terrain options);
models simplified but silhouette-faithful (DogDay 21 parts incl. the 8-box sunbeam
collar vs 23; Baba 15 vs 27 — horns straight not curled); animal voices = moo/squeak
at Godot's pitch tables (no dedicated oink/baa/cluck recipes); only Nightmare Huggy's
arms animate (pack arms static); zap stun uniform 4.5s (Godot 45.5 per kind).
**Dropped:** cave tunnels only. (x-major→z-major heightfield transpose handled;
goal verified on the true peak via `game.ground_peak()`.)
### Engine gaps: found by the port, then closed engine-side (same day)
1. **CLOSED — eval budget vs terrain scale.** `game.terrain` now runs its noise
engine-side (`freq`/`offset`/`step`/`min`/`max`/`plaza`, cells up to 384), plus
`game.ground_y(x, z)` and `game.ground_peak()`. The fixture builds the full
Godot-scale 257×257 world (256 cells @0.625u) with zero script instruction cost,
and all spawn/tree/goal heights come from ground queries.
2. **CLOSED — second input action.** `grab` exists (keyboard G, gamepad B, tape
action) with `grab`/`grab_pressed` snapshot fields. The grapple hand is ported:
32 u/s flight to range 16 on a `game.beam` cable, terrain hit → player yank
(22 u/s + 6.5 up), creature hit → ride-attach haul, held 1.2s, set down at the
player's feet with a pop. Approximations (memo): solid-hit detection is
terrain-height only (tree/box bodies don't catch the cable — needs a script
raycast/box-probe verb); the hand vanishes at max range instead of retracting.
3. **CLOSED — label outlines.** Engine draws a 4-copy dark outline behind every
billboard label; nothing needed in script.
4. **OPEN — height-carving.** Caves need holes/overhangs in the heightfield (or
rock-slab CSG); the smooth mesh is single-valued.
5. **OPEN (new, minor) — terrain band colors.** Height-band coloring
(sand/grass/dirt/stone/snow) as engine-side terrain options, since script-built
color arrays don't scale past ~100² vertices.
6. **OPEN (new, minor) — solid-probe verb.** A `game.raycast`/box-probe would let
the grapple (and ledge-AI patterns from the corpus) hit boxes, not just terrain.
Verification after the engine-noise/grapple update: eval clean at 90 entities,
zero JIT failures, last_error empty, tape (walk/jump/shoot/grab) → test_done +
captures + probes with plaza floor exactly 7.9 and walk exactly 6.0, two runs
byte-identical.

70
aigame_port_inventory.md Normal file
View file

@ -0,0 +1,70 @@
# my-game → splash port inventory (generated 2026-07-09)
Exhaustive Godot API surface of ~/games/my-game (the AI-generated corpus), used as the
acceptance checklist for the aigame engine port. Two parallel games: 2D side-scroller
(main.tscn) and the ACTIVE 3D sandbox (main3d.tscn, run/main_scene). 13 actors share one
behavior pattern set; 2D/3D scripts are near-mirrors.
## First-class engine APIs required (by measured usage weight)
- **Position as currency**: `global_position` read/write ×122 (write = teleport: respawn,
mount-follow, carry). `distance_to` ×14, `length` ×23, `normalized` ×16.
- **Kinematic character motion**: `move_and_slide` ×14 (ALL actors), `is_on_floor()` ×33,
hand-rolled gravity (`velocity.y += G*dt`, engine gravity unused), `move_toward` ×9
(friction), `is_on_wall` ×6 (hop trigger), per-actor jump/fall constants, respawn on
fall (y beyond limit → teleport to recorded `_spawn`).
- **World building**: StaticBody2D/3D + box colliders for ALL terrain; 3D visuals 100%
MeshInstance3D+BoxMesh+StandardMaterial3D.albedo_color (8 sites); 2D visuals 100%
ColorRect. Counts: 2D = 6 ground + 19 platforms + 7 movers + walls/flag/hills; 3D =
ground 3 + stairs 7 + towers 8 + trees 12 + goal + 10 creatures + 2 vehicles.
- **Groups as event bus** (no signal bus!): groups player/vehicle/soldier/kissy/huggy/game;
`get_nodes_in_group` iterated per-frame for nearest-target;
broadcast = scan group + `has_method()` duck-typing ×14 (`huggy_caught`, `enrage`,
`capture`, `rescue`, `zap`, `send_home`, `cam_yaw`…). Engine needs: tags + find/iterate
+ dynamic method dispatch on entities.
- **Scene tree**: `add_child` ×53 (all procedural), `is_instance_valid` ×14 (cached-ref
guards), `set_script` ×6 (behavior attach to bare bodies), `queue_free` ×3 (bolt).
- **Input**: `get_axis(left,right)` ×6, `is_action_just_pressed` ×7; actions used:
shoot×8, ui_up/right/left/accept×4 each, ui_down×2, ui_cancel×1. RUNTIME InputMap
action creation (shoot = mouse-left + F key + gamepad X). Mouse-capture relative-motion
camera (captured/visible modes, pitch clamp, Esc release).
- **Camera**: 2D = child cam, position smoothing (speed 6), level-bounds limits.
3D = CamYaw→CamPitch→Camera3D boom (z=13), mouse yaw/pitch, camera-relative locomotion
via `Basis(UP, yaw) * wish` — shared by player AND vehicle steering (`driver.cam_yaw()`).
- **Random/AI math**: randf_range ×16, randf ×7, randi/pick_random ×5, lerp ×8, atan2
(model facing), sin/cos ping-pong (movers).
- **Audio — procedural synth, zero files** (sfx.gd autoload the AI wrote itself):
8-voice AudioStreamPlayer pool, 22050Hz s16 WAV buffers generated by `_sweep`
(square pitch glide), `_zap` (falling tone+noise), `_notes` (arpeggio). Bank: jump,
shoot, zap, grab, angry, calm, rescue, shove, board, win. API `Sfx.play(name, pitch)`.
→ aigame should ship this as first-class (`game.sfx("jump")` + beep/jingle synth).
## Structural patterns to support
- Mount/dismount: nearest group("vehicle") within REACH → `mount(player)` → disable
collider (deferred) + stop actor physics + per-frame teleport to seat; up to 3
passenger seats; eject with pop velocity + cooldown.
- Win conditions polled in `_process` (goal distance / x threshold) + `_won` latch + HUD.
- HUD: Label text/visible + font/outline theme overrides; transient banners via
`await create_timer(s)` with generation-token cancel (the ONLY async in the game — no
tweens/AnimationPlayer anywhere).
- Ledge AI: RayCast repositioned per frame + force_raycast_update; ±90° sidestep along
edges (no navmesh). Needs script-facing raycast.
- Moving platforms (2D): AnimatableBody2D sync_to_physics, cosine ping-pong, carries
riders via floor tracking.
- Collision layers: 1=world, 2=creatures, 4=vehicle, 8=player; creatures/player mask
world-only (pass through each other); bolt masks world+creatures.
- Label3D billboard nametags (no_depth_test); emission material on bolt projectile;
ProceduralSky + one shadowed DirectionalLight3D.
## Confirmed absent (do NOT build): tweens, particles, AnimationPlayer, navmesh,
custom shaders, paths, physics joints (in-game), signals (only Area3D.body_entered ×1).
## Porting risk list (ranked)
1. Procedural audio synth (engine-side now, task #6)
2. Mouse-capture camera + camera-relative movement basis
3. Ledge raycast AI (script raycast API)
4. Moving platforms carrying riders (mover ground-velocity inheritance)
5. Runtime input action registration (ActionMap covers it)
6. Deferred collision toggling during mount (engine needs disable-collision-safe-point)
7. Jolt move_and_slide floor-snap feel parity (box3d mover tuning)

View file

@ -1,210 +0,0 @@
# Arcade budgets
What a generated game can spend and still hold frame time. This feeds Fable's
system prompt so generated games stay inside the envelope by construction
rather than being profiled after the fact.
**Status: measured on an M-class Mac (release), extrapolated conservatively
for Quest. The Quest column is an ESTIMATE until it is run on device.**
## Simulation
| thing | measured | notes |
|---|---|---|
| 100 movers + 50 rigid bodies + 65×65 terrain | **0.038 ms/tick** | M1a, release. The 60 Hz budget is 16.6 ms, so the sim is ~0.2 % of it |
| entity lookup | O(log n) | binary search over the sorted-id Vec (M0r) |
| script per tick | ≤ 2 ms | one cumulative 500k-instruction pool shared by on_tick + timers + touch events |
The sim is nowhere near the limiting factor. Draw calls and CPU skinning are.
## Vertex and instance bandwidth
Quest is bandwidth-bound before it is ALU-bound, so this is the number that
matters most on device.
| stream | before | after | note |
|---|---|---|---|
| cube instance | 44 floats / **176 B** | 32 floats / **128 B** | **27 %**, measured from the compiled shader (`RenderStats::instance_floats`), not counted by hand |
| skinned character vertex | 16 floats / 64 B | 6 floats / **24 B** | **62 %**, and this one is re-uploaded *every frame* |
| shadow mesh vertex | 16 floats / 64 B | 6 floats / **24 B** | **62 %** |
| terrain vertex | 16 floats / 64 B | unchanged | uploaded once per terrain revision, so the win is small; see below |
The Knight is the prize: 3716 verts × 64 B = **238 KB every frame** (skinning
is CPU-side, so the whole buffer is re-uploaded) → **89 KB**. On a
bandwidth-bound tiler that is the single biggest saving available.
The instance saving came from moving `sun_color`, `sun_sky`, `sun_ground` and
`fog_color` **off the instance stream into shader uniforms**. They are
identical for every instance in a batch, so as instance fields they were 12
floats of pure duplication per cube. `fog_density` stays per-instance because
shadows switch it off individually. At the demo's instance counts that is
48 B × every cube in the frame, every frame.
### How the packing works
**Vertex attributes in this engine are f32-only** — there is no u8/u16/i16
attribute type. Compression therefore means bit-packing into f32 lanes and
unpacking in the shader, which the engine already supports: `unpack2f16` and
`unpack4u8` are builtins on every backend (Metal/GLSL/HLSL/WGSL), and
`geom.VectorVertexPacked` is the house precedent.
`geom.GameMeshVertex` (`draw/geometry_gen.rs`) is the shared packed layout:
| field | packing | floats |
|---|---|---|
| position | 3 × f32, kept exact | 3 |
| normal | octahedral, 2 × f16 in one lane | 1 |
| uv | 2 × f16 in one lane | 1 |
| colour | 4 × unorm8 in one lane | 1 |
| **total** | | **6 floats / 24 B** |
Two gotchas worth keeping: the pod struct must use **flat `f32` fields, not
`Vec3f`** — std140 pads a vec3 to 16 bytes and the Rust repr(C) size then
fails the POD size assertion. And in the shader language `let` bindings are
immutable and helper fns cannot be forward-referenced, so the octahedral
decode uses a branchless `step(0,v)*2-1` for its sign rather than reassignment
or a shared helper (the `sign()` builtin returns 0 at 0, which would collapse
the fold on an axis-aligned normal).
Terrain still uses PbrVertex: it uploads once per terrain revision rather than
per frame, so the saving does not justify re-verifying gamemaker's 257×257
fixture. It is a mechanical follow-up if wanted.
## Rendering
One draw call per shape per pass, plus one per skinned character. Particles and
shadows join the existing alpha batch, so **neither adds a draw call**.
| thing | desktop | Quest (est.) | why |
|---|---|---|---|
| entities | 2000 | 600 | instance packing is cheap; fill rate is not |
| skinned characters | 8 | 23 | CPU skinning: ~3.7k verts each, re-uploaded every frame |
| projected shadows (`shadow_budget`) | 24 | 8 | 0.6 µs for 24, ~2 µs for 64128 — the CPU cost is trivial, the fill cost of large ground quads is not |
| particles (`ParticleSystem::cap`) | 2000 | 500 | see below |
### Particle cost (measured, per frame, step + instance build)
| cap | live | step | instances | total |
|---|---|---|---|---|
| 500 | 500 | 0.8 µs | 0.7 µs | **1.5 µs** |
| 1000 | 1000 | 3.1 µs | 2.1 µs | **5.2 µs** |
| 2000 | 2000 | 3.1 µs | 2.8 µs | **5.9 µs** |
| 4000 | 4000 | 11.5 µs | 14.5 µs | **26 µs** |
CPU cost stays negligible even at 4000. The real limit is **overdraw**: every
particle is an alpha-blended quad, and a Quest fills pixels far more slowly
than it runs this loop. Hence the 500 cap there — it is a fill-rate budget, not
a CPU one.
### CPU light bake (bake.rs), measured release
| stage | cost | when it runs |
|---|---|---|
| AO (per static, 5 face samples × 8 rays) | **15 µs** | world edits only — sun-independent |
| sun visibility (1 ray per static + per probe) | **34 µs** | world edits **and** whenever the sun swings past 0.03 rad |
| probe lattice sky term | **61 µs** | world edits only |
Measured on the demo world (12 statics, 13 occluders, 605 probes). Debug
builds are ~50× slower (5.7 ms for the probe pass) — measure in release.
The split matters: AO is the expensive half and does not depend on the sun, so
a day/night cycle only pays the 34 µs sun pass. A ray that starts above the
heightfield's highest point and travels upward skips the terrain march
entirely, which is what keeps the probe pass in microseconds.
Bake output costs **zero** bandwidth and zero GPU: it is folded into the
instance colours the renderer was already sending.
### Shadow tiers
Casters are ranked by camera distance. The nearest `shadow_budget` get a
projected silhouette; everything else gets a blob. Both cost one instance, so
the budget buys fidelity rather than draw calls. Rigid bodies and anything
person-sized (≥ 0.5 units tall) count as heroes; smaller movers always get
blobs.
## Setting the budgets
```rust
renderer.set_shadow_budget(8); // standalone XR
particles.set_cap(500); // standalone XR
```
Lowering these on one device is safe: particles and shadows are tier-3 Local
(game.md), so two devices in the same room may draw different numbers of them
and the simulation cannot diverge — particles never touch the world RNG, which
`particles_never_advance_the_world_rng` asserts.
## Rules of thumb for generated games
- A racing game with 4 cars, a track of ~200 static pieces and dust particles
sits at a few percent of frame budget on desktop.
- Prefer one emitter attached to a moving entity over per-frame bursts: an
emitter costs one request, bursts cost one per call.
- Characters are the expensive thing. Two or three on Quest, not eight.
- Terrain above ~129 cells starts to matter for eval time, not draw time (the
isolate's wall-clock budget is 64 ms and it is a hard bail, not a yield).
## step_world weight (measured 2026-08-03, release, aarch64)
Measured with `cargo run -p makepad-game-sim --release --example weigh`, which
wraps the global allocator so the byte counts include everything the tick
touches, not just what the harness allocates.
The tick used to clone two things per tick purely to dodge a borrow: the whole
`Terrain` (its `heights: Vec<f32>` **and** `colors: Vec<Vec4f>`) and every
static/kinematic `Entity` (208 bytes each). Terrain dominated — a 257² field is
1.3 MB/tick, i.e. **79 MB/s of memcpy at 60 Hz on a world with seven entities
in it**. Splitting the struct borrow removes the terrain copy entirely; the
statics snapshot has to stay a copy (movers must sweep against *last* tick's
kinematic poses — that ordering is load-bearing) but now copies a 48-byte
`Solid` view instead of the full entity.
| scene | ms/tick before → after | B/tick before → after |
|---|---|---|
| demo (arcade) | 0.002 → 0.003 | 15,140 → 4,796 (68%) |
| demo + terrain 65 | 0.003 → 0.004 | 99,640 → 4,796 (95%) |
| racing-ish (129 terrain) | 0.007 → 0.002 (71%) | 362,316 → 8,576 (98%) |
| terrain 129 only | 0.005 → 0.000 | 335,804 → 896 (99.7%) |
| terrain 257 only | 0.019 → 0.001 (95%) | 1,323,964 → 896 (99.93%) |
| large (500 static) | 0.063 → 0.056 (11%) | 591,386 → 82,382 (86%) |
| stress (2000 static) | 0.583 → 0.457 (22%) | 2,353,936 → 327,812 (86%) |
Allocations/tick fell from 614 to 311; the residual is the statics snapshot,
the box3d reconcile and touch collection.
**Leak check**: `--soak` runs 10 simulated minutes (36,000 ticks) of a busy
world with projectiles spawning and expiring throughout. RSS is flat at 3.8 MB
from warmup to the end (+0.4% drift, 242 entities alive) — the tick path does
not leak.
**Result-neutrality** is gated by `libs/game/sim/tests/mover_golden.rs`: the
golden world-state hash is byte-identical before and after this optimisation
(verified by reverting the source and re-running), and it covers the terrain,
sweep, platform-carry, attach, projectile-lifetime and auto-face paths that
`rigid_dynamics.rs` doesn't reach.
## Memory and binary, whole app (measured 2026-08-03)
| | value |
|---|---|
| sim core only, all 7 scenes incl. 2000-static stress | 13.4 MB RSS |
| sim soak, busy world, steady state | 3.8 MB RSS |
| `hello_world` (baseline makepad + widgets + headless) | 195 MB RSS, 13.5 MB binary |
| `makepad-arcade` (headless) | 948 MB RSS, 25.1 MB binary |
The engine core is genuinely small; the weight is above it. Two findings worth
acting on, both outside the sim/render/script crates:
1. **~750 MB of Arcade's RSS is not the sim** (13 MB) and not the framebuffer
(unchanged when the headless size changes). It needs a profiler pass to
attribute properly — candidates are the script isolates (each one
re-evaluates the *entire* widgets DSL, `widget_async.rs:317`, and a game
isolate needs none of those prototypes), the glyph/texture atlases, and the
offscreen pass chain.
2. **The voice stack links unconditionally.** `makepad-converse` is a plain
dependency of `apps/arcade`, not feature-gated, so Kokoro TTS is compiled in
and initialised (`tts: backend Kokoro` appears in every boot log) even at the
`chatbox` tier where it can never be used. `voice`/`local-llm` gate the
*models*, not the crate. Gating this is the obvious binary-size win for a
Quest build; the binary carries whisper/kokoro/silero symbols today.

View file

@ -1,46 +0,0 @@
[package]
name = "makepad-arcade"
version = "0.1.0"
edition = "2021"
description = "Makepad Arcade — networked AI game sandbox (see game.md)"
license = "MIT OR Apache-2.0"
[lib]
name = "makepad_arcade"
path = "src/lib.rs"
[[bin]]
name = "makepad-arcade"
path = "src/main.rs"
[dependencies]
makepad-widgets = { path = "../../widgets", version = "2.0.0" }
makepad-game-math = { path = "../../libs/game/math" }
makepad-game-sim = { path = "../../libs/game/sim" }
makepad-game-session = { path = "../../libs/game/session" }
makepad-game-blocks = { path = "../../libs/game/blocks" }
makepad-game-render = { path = "../../libs/game/render" }
makepad-game-assets = { path = "../../libs/game/assets" }
makepad-game-gen = { path = "../../libs/game/gen" }
makepad-game-script = { path = "../../libs/game/script" }
makepad-game-net = { path = "../../libs/game/net" }
makepad-game-coedit = { path = "../../libs/game/coedit" }
makepad-game-pkg = { path = "../../libs/game/pkg" }
makepad-ai = { path = "../../libs/makepad_ai" }
# default-features off drops speech synthesis (~10 MB of binary, ~327 MB
# resident). Re-enabled by `voice` below: a device that can capture speech is
# the one that has any use for producing it.
makepad-converse = { path = "../../libs/converse", default-features = false }
[features]
# Mic capture + Whisper, via the widgets VoiceWave. Separate from `local-llm`
# because transcription and the local judge are different models: a device can
# have one without the other.
voice = ["makepad-widgets/voice", "makepad-converse/tts"]
# Local model compute (Whisper/Silero/Qwen). Off by default so Quest/mobile
# builds — which have no backend — never try to link it. Implies `voice`:
# the judge only has something to judge once there is a mic.
local-llm = ["makepad-converse/local-llm", "voice"]
[dev-dependencies]
makepad-test = { path = "../../libs/makepad_test", version = "0.1.0" }

View file

@ -1,108 +0,0 @@
# Makepad Arcade
Networked AI game sandbox (plan: repo-root game.md). Run with:
cargo run -p makepad-arcade
## Stock assets
Arcade ships with a searchable library of CC0 models and sounds. The binaries
are **not** vendored in git — fetch them once:
./apps/arcade/download_assets.sh # core packs, ~75 MB
./apps/arcade/download_assets.sh --packs=all # everything, ~185 MB
./apps/arcade/download_assets.sh --list # show packs, download nothing
The default fetches a **core** set (~1900 models) so a fresh clone isn't forced
to pull the lot; `--packs=all` gets the full Kenney 3D catalogue (**4669 models
across 47 packs**), and `--packs=nature-kit,car-kit` picks specific ones. Add
the 556 sounds and the nine rigged KayKit characters and the full library is
~5000 searchable assets — of which **36 are rigged and animated**.
Everything is gitignored and pinned — starter kits to exact GitHub commits, the
rest to content-hashed kenney.nl URLs — and every file is sha256-verified, so a
moved or tampered upstream fails loudly instead of silently changing the
library. Downloads are sequential with a delay: this is someone else's
bandwidth.
### Mirroring
`resources/MIRROR.toml` lists every pack with its canonical URL, sha256, size
and model count. Because these assets are CC0, anyone may re-host them, and
that file is what makes a mirror reproducible and verifiable. Point at one with:
ARCADE_ASSET_MIRROR=https://your.host/assets ./apps/arcade/download_assets.sh
./apps/arcade/download_assets.sh --mirror=https://your.host/assets
A mirror is expected to serve `<base>/<slug>.zip`. **The sha256 from
MIRROR.toml is verified identically whichever host served the bytes** — a
mirror is never trusted more than upstream; the hash is the authority.
Everything degrades gracefully without them: the demo runs with primitive
shapes, and tests that need real assets skip with a hint.
**Audio is Ogg Vorbis.** Every Kenney audio pack ships `.ogg` only — no WAV
variant exists upstream — and this tree has no vorbis decoder. The sounds are
therefore indexed and searchable by the AI but **not yet playable**; adding a
decoder (or running `download_assets.sh --transcode`, which converts to WAV
when ffmpeg is installed) is what closes that gap.
### Finding assets
The library is queried by *description*, never by filename, via
`makepad-game-assets`. The agent gets a `find_model` tool and a one-paragraph
summary — it searches, it never receives the catalogue (5000 entries would not
fit in a prompt, and the summary stays ~480 characters however large the
library grows). Ids look like `kenney/racing/vehicle-truck-yellow` and are
stable across re-downloads, because generated game code writes them.
Findability is built in three layers so it scales past 4000 models:
per-pack theme curation (~55 rows, giving every model its setting), filename
token parsing (free, and Kenney's names are systematic), and a hand-curated
query-time synonym table (~240 rows) that applies to the whole catalogue at
once. Item-level curation is spent only on the few hundred most-requested
things. At the full 4,999-entry catalogue: index build ~120 ms, a search
~0.2 ms, ~2.1 MB of heap (release).
Query-side stemming means an inflected request still reaches a base-form
alias ("smashing" → the `smash` alias), and when two entries tie on score the
kind the query implies wins — an unqualified noun like "spaceship" is an
object request, while "metal clang" or "win music" wants something audible.
## Credits
Every asset here is CC0 (public domain). Attribution isn't required by the
licence — we credit anyway, because these libraries exist because someone chose
to give them away.
- **Kenney** — <https://kenney.nl/assets> — 47 model packs totalling ~4,670
models (nature, city, castle, space, food, furniture, vehicles, dungeons,
characters and more), the five starter kits, and all seven sound packs
(impact, interface, sci-fi, music jingles, UI, RPG and digital audio). Thank
you for the extraordinary breadth of free, consistent, genuinely usable game
assets — this library is most of what Arcade can build with.
- **KayKit / Kay Lousberg** — <https://kaylousberg.itch.io/> — nine rigged and
animated characters: five adventurers (Knight, Barbarian, Mage, Rogue,
Rogue_Hooded) and four skeletons (Warrior, Mage, Rogue, Minion). All nine
share one 41-joint skeleton, so an animation authored for any of them plays
on every one — the adventurers ship 76 clips and the skeletons those same 76
plus 19 undead extras. Thank you for giving away rigs and animation sets,
which are the expensive part.
### Rigged characters
`find_cast` lists the animated casts. A cast is a set of characters sharing one
skeleton, so any state works on any member and a part can be recast without
touching animation code:
| rig | members | clips | notable states |
|-----|---------|-------|----------------|
| 41 joints (KayKit) | 9 | 7695 | block, dodge, hurt, dance, sleep, carry |
| 7 joints (Kenney mini) | 22 | 2532 | walk, run, jump, attack, sit, wave |
| 6 joints (Kenney platformer) | 5 | 25 | walk, run, jump, attack |
Pick members from ONE cast for a scene, and use different members so a crowd
isn't clones — the same rule that applies to props.
Machine-readable attribution lives in `resources/CREDITS.toml` so a published
game package can carry credit with it.

View file

@ -1,13 +0,0 @@
fn main() {
// Mirror platform/build.rs: MAKEPAD=headless builds get cfg(headless), so
// the view can stub the gamepad path (the headless platform backend has no
// game-input implementation). Without this arcade cannot build headless,
// and the render-to-PNG test path goes with it.
println!("cargo:rustc-check-cfg=cfg(headless)");
println!("cargo:rerun-if-env-changed=MAKEPAD");
if let Ok(configs) = std::env::var("MAKEPAD") {
if configs.split(['+', ',']).any(|c| c == "headless") {
println!("cargo:rustc-cfg=headless");
}
}
}

View file

@ -1,448 +0,0 @@
#!/usr/bin/env bash
# Fetch the CC0 model packs Makepad Arcade uses for its stock asset library.
# Nothing here is vendored in git (see resources/*/.gitignore) — run this once
# after checkout. Every file is pinned to an exact upstream commit and verified
# by sha256, so a moved or tampered upstream fails loudly instead of silently
# changing the library.
#
# ./download_assets.sh fetch everything (idempotent)
# ./download_assets.sh --list show packs, counts and licences, download nothing
#
# ----------------------------------------------------------------------------
# CREDITS — all assets below are CC0 (public domain). Attribution is not
# required by the licence; we credit anyway, because these libraries exist
# because someone chose to give them away.
#
# Kenney — https://kenney.nl/assets
# Vehicles, city, arena, platformer and FPS kits. Thank you @KenneyNL.
#
# KayKit / Kay Lousberg — https://kaylousberg.itch.io/
# Rigged + animated characters: 5 adventurers (Knight, Barbarian, Mage,
# Rogue, Rogue_Hooded) and 4 skeletons (Warrior, Mage, Rogue, Minion).
# All nine share one 41-joint rig. CC0 verified in each pack's LICENSE.txt
# at the pinned commit. Thank you Kay.
#
# NOTE ON AUDIO FORMAT: every Kenney audio pack ships Ogg Vorbis only (no WAV
# variant exists upstream — checked all seven packs). This tree has no vorbis
# decoder, so the sounds are downloaded and indexed (searchable by the AI) but
# not yet playable; a decoder, or an opt-in local transcode, is the missing
# piece. `--transcode` below does the latter when ffmpeg is installed.
# ----------------------------------------------------------------------------
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resources"
MODELS="$ROOT/models/kenney"
CHARS="$ROOT/characters"
AUDIO="$ROOT/audio/kenney"
MANIFEST="$ROOT/MIRROR.toml"
# ---------------------------------------------------------------------------
# 3D model packs (the full Kenney 3D catalogue) are described by
# resources/MIRROR.toml rather than inlined here: 50 packs is too many to keep
# readable in shell, and that file is also what makes an independent mirror
# reproducible.
#
# Source selection:
# ARCADE_ASSET_MIRROR=https://our.host/path or --mirror=<base>
# A mirror is expected to serve <base>/<slug>.zip. Whichever host serves the
# bytes, the sha256 from MIRROR.toml is verified identically — a mirror is
# never trusted more than upstream.
#
# Selection:
# (default) the "core" packs — ~1900 models, ~60 MB
# --packs=all every usable pack — 4669 models, ~166 MB
# --packs=a,b,c named packs
# ---------------------------------------------------------------------------
MIRROR="${ARCADE_ASSET_MIRROR:-}"
PACKSEL="core"
fetched=0
cached=0
# Kenney audio packs, pinned by content-hashed download URL.
# Each entry: <pack>|<url-hash-segment>|<sha256 of zip>|<sound count>
KENNEY_AUDIO=(
"impact-sounds|87b4ddecda-1677589768|029d734af1582474edf3a694d1b0cebc97c1c152f2f39fa34d4c2bafc5de77f8|130"
"interface-sounds|fa43c1dd4d-1677589452|f2193d072726d6758a5f7871b2dcc54dcce0d5c35c6f0a62f92549b327c81232|100"
"sci-fi-sounds|6b296f9ecf-1677589334|119340f351a5098ad814f78719438c0da355a9ce8a4c8a3af6a8d48aa3d49e04|73"
"music-jingles|f37e530b9e-1677590399|b729ba57959bd58793d2c5cafa348aaf2655d354f3da35ec4729e03ec77197b8|86"
"ui-audio|490d233f68-1677590494|946fc23a63d535d693eb31b2eabb80c8c28d6351e2186b344ceb71b2cb1d5eb6|52"
"rpg-audio|8e99002d76-1677590336|6dbeaf8544da958d8f2adcb4a4a4b76c1ade34a05f8ab9edccd327da7375f38b|52"
"digital-audio|216eac4753-1677590265|24e6ce28b76a6d8c89cff4d331e0965ff5c3de8a73c612028e9d363cc64e4f06|63"
)
# Kenney starter kits, pinned. Each entry: <pack> <repo> <commit> <subdir>
KENNEY_PACKS=(
"arena|Starter-Kit-Basic-Scene|a6927e66ff8dd8e173660ce4825abe773c65f683|sample/Mini Arena/Models/GLB format"
"city|Starter-Kit-City-Builder|4535092b740b378b700efd9df9e27a631815b84a|models"
"fps|Starter-Kit-FPS|185fd2326d74a5cf858cffc616f87cf9696f9cc0|models"
"platformer|Starter-Kit-3D-Platformer|3fa8a04b1c01ab23db43123d4ce814a34c3fc7f0|models"
"racing|Starter-Kit-Racing|f5241ebdf00c25bc951bf4fdb7950bb1b78b4bcc|models"
)
# KayKit character packs, pinned. CC0 verified in each repo's LICENSE.txt at
# the pinned commit ("License: (Creative Commons Zero, CC0)").
#
# ALL NINE CHARACTERS SHARE ONE RIG — 41 joints, identical names in identical
# order, verified by hashing the joint-name list of every file (both packs
# produce the same digest). So one animation-driving code path serves the whole
# cast, and a clip authored against any of them plays on all of them.
# Adventurers ship 76 clips; skeletons ship those same 76 plus 19 undead extras
# (awaken, resurrect, spawn, taunt) — a strict superset, no clip is lost.
#
# The GLBs EMBED their texture (image/png in a bufferView), so unlike the Kenney
# packs there is no external URI to resolve and no path to get wrong. The
# sidecar *_texture.png files are fetched anyway because the skin loader
# deliberately ignores materials ("the caller binds its own texture"), so the
# app supplies the atlas itself.
KAYKIT_ADV_COMMIT="672074b73ba276876a19e8816ecdc5241817ab47"
KAYKIT_ADV_BASE="https://raw.githubusercontent.com/KayKit-Game-Assets/KayKit-Character-Pack-Adventures-1.0/$KAYKIT_ADV_COMMIT"
KAYKIT_ADV_DIR="addons/kaykit_character_pack_adventures/Characters/gltf"
KAYKIT_SKEL_COMMIT="15b62b9bad122f72926c10fb14d622c73819fa54"
KAYKIT_SKEL_BASE="https://raw.githubusercontent.com/KayKit-Game-Assets/KayKit-Character-Pack-Skeletons-1.0/$KAYKIT_SKEL_COMMIT"
KAYKIT_SKEL_DIR="addons/kaykit_character_pack_skeletons/Characters/gltf"
# <local-name>|<pack>|<upstream file>|<sha256>|<texture local-name>
# Rogue_Hooded has no atlas of its own upstream — it re-skins rogue_texture.
KAYKIT_CHARS=(
"knight|adv|Knight.glb|60428e3abc09ba83e595d256e3af8c5c976b46cdae599f0802fc82b4a3445168|knight_texture.png"
"barbarian|adv|Barbarian.glb|cefc311a0e10c7858b6141f5ada7e33268727564fb8ac1347aab97d000669cc6|barbarian_texture.png"
"mage|adv|Mage.glb|cf898585da33fab50c724d31605fb931eb2912e6d2280092141e98ca81ad507d|mage_texture.png"
"rogue|adv|Rogue.glb|e825437cd4d2ee9c1960b517a74a69101e33eb409ae7fa8cedc7134a998fbb7d|rogue_texture.png"
"rogue_hooded|adv|Rogue_Hooded.glb|93e6e25213009952276d9cf34f5d96a243767334c66f280db0433ddfabb91545|rogue_texture.png"
"skeleton_warrior|skel|Skeleton_Warrior.glb|178b6fda810b814c250d8a2010c24dfd9b458b9006dd323353e620b7ff118bbe|skeleton_texture.png"
"skeleton_mage|skel|Skeleton_Mage.glb|e05b0f5cfa395271c9f75fd07c0a0613c56f401ece4ab644e080001a79971075|skeleton_texture.png"
"skeleton_rogue|skel|Skeleton_Rogue.glb|4003f2b77891bb56f7e0de7d555abcb497aebbcaee35b614211f16275f9ccae3|skeleton_texture.png"
"skeleton_minion|skel|Skeleton_Minion.glb|6ffc003f895bed0b074791e0e490846210a2e2f8fc7da300aba53cc185f95968|skeleton_texture.png"
)
# <local-name>|<pack>|<upstream file>|<sha256>
KAYKIT_TEXTURES=(
"knight_texture.png|adv|knight_texture.png|5d250ccc5da020e6126bfa3839f83bd9a465a951ed223e4d13c08b1925e154d4"
"barbarian_texture.png|adv|barbarian_texture.png|7329b2ff9709e8d54c886d5ef49c08bd42b12be3bbb2facb1488a6b48b1e8a80"
"mage_texture.png|adv|mage_texture.png|ea49f094b960402635fe51db9f1864960c97271b17f2e3554e5aca1b2bbba144"
"rogue_texture.png|adv|rogue_texture.png|a4032e877c3b91939f5cdbb630349c1998fdbc3211bbd587c111125500fe4cc5"
"skeleton_texture.png|skel|skeleton_texture.png|15741a25c53e04fa9bf3beac3bc0de442359404b1ff9be863b892cb551ad3657"
)
kaykit_base() { case "$1" in adv) echo "$KAYKIT_ADV_BASE/$KAYKIT_ADV_DIR" ;; skel) echo "$KAYKIT_SKEL_BASE/$KAYKIT_SKEL_DIR" ;; esac; }
# Per-pack file manifest: "<filename> <sha256>" lines.
pack_files() {
case "$1" in
arena)
echo "banner.glb 96e1cf7924fcc871be0e7e182c9b918de381b9783ca9e96922f8db62af384056"
echo "block.glb 656e07f0a3e7d0620892aca6fc40683b4266ff6b9672ecf89f3553e5ed85a1fb"
echo "border-corner.glb 120ce330ca50fe50e51bf693eadb957abaca972c42d573a07d8e8e404560bc3c"
echo "border-straight.glb 76ef69e8858f3d17c652d92033a553db58296135a5810d1bd5e54f47b5aa40c4"
echo "bricks.glb fd1d2c033c7db8b7352ac1becdab447d09cabcc11c73adf9bb6790f9680eaa86"
echo "character-soldier.glb b0410b4a34068e56ac1af3a2571f4497f84f1e481cced895b618d1fe8136529c"
echo "column-damaged.glb 442a6856e7e2d042c9e3af155dabf6d394f7ba91b2e271ee6392da22555a072c"
echo "column.glb 5c815b5fd43d613a21afddaaa90dc2f90b80e31f03dbdde98921cb7424e836c0"
echo "floor-detail.glb 804224caf3008fcdbd57d7ccc2c4734320c58ca880895699307670d2443e324b"
echo "floor.glb 15512c5ca6e9d3eb60efd237bbbf5b8010fdea63e0d8e6623f72b59de20279ab"
echo "stairs-corner-inner.glb 2e232cecda1d633acc1b78707ee50089fd40a167293c173536d61ef064697a5c"
echo "stairs-corner.glb af8eb87dc8826e184f585ebccf0592c909da7370559cd840b218593f4b1c8a38"
echo "stairs.glb 54a3f12c720f9cb83027b5429e330d7c50578f70771fd1bf9a2e521fe131ca6f"
echo "statue.glb 5ba8ecba963f6b3ec974809e2bf0b615da7e2701cd0efc6a3c0e946efb147397"
echo "tree.glb f8632c9758a78f60aa4ca68bf0e5ac972770167a5608e179293a12552d8fc2ef"
echo "trophy.glb 253f6d5f321ab20f8f505dfe35b7404c98d2d492728e6df5f69dd29e936b8be5"
echo "wall-corner.glb 4dc0e770106a122885f41be0bb4024466a7f58bc29a13f07b6600c4babbeb777"
echo "wall-gate.glb f70a86344f202b3a8057e3a6ee9cd632ba51a5c16795cfde34cd10e906f14697"
echo "wall.glb b05ab8645136cc26e761ae2189f660a585dca88c11721771bca3fb4c1f7a807f"
echo "weapon-rack.glb bb95da3746098e36f3d87a1e1036ca70ead7fe95860191871e02f7c58ff1efe5"
echo "weapon-spear.glb 9634e12abce01a07d40d5156454bfa1123eba72af6449fccf577980e331adbeb"
echo "weapon-sword.glb e4d2219d954148dd8b04fccc7602726c34eafad58c0dc5a45268155fcf6cc8c4"
;;
city)
echo "building-garage.glb 7373b558fc9b1e27e60b60ca1ce53000af7e132d7304eeb6d49b47528a3b6806"
echo "building-small-a.glb 22ce989013bd16b1732e81798e343cf85f947e92b6c93825b18131feded48e07"
echo "building-small-b.glb 0bc8459045975158524753d6b648ff77e5f91fe8c033bc4886930b6d55543a2b"
echo "building-small-c.glb 3ea0f46fbed4e0acba7fda365cd968b3f23a6e02b9fee79dd54c99c42ffadf2f"
echo "building-small-d.glb 0ab476fbd7956cea52e590a8e68e351ce2f6fc04df158aa3f5a76b7744bf37a0"
echo "grass-trees-tall.glb d23e3b722453236bf6f4f8922b7bf64f81a7f3f804848a89c4b100ccbf13abef"
echo "grass-trees.glb cb06d03cc1c64ca7e7692c0aef3628d8e7cd4afee69093025f9ddd668653a4ce"
echo "grass.glb 3e3ec91132ad8519967aa2e4c0bedbab2aeed39b5714d66068bb75648ae73a52"
echo "pavement-fountain.glb ea2996089e90a79ba13d764106d374486bb8137666cf1cdbaebd1927ef365185"
echo "pavement.glb 60152776325d436761a13eb0c2ec9368b393bc82cf754b63cab810d79639ade9"
echo "road-corner.glb 85aec60d66c5084bb658274a1468139a5def1e9e9575c01286ed6c2069916789"
echo "road-intersection.glb 0212ffe9945b933535503372855c54dee20d54ddfc07e1c72963e67558a14183"
echo "road-split.glb 31bf582953db8315edfade4ba49d81145a78bda9757b6442dc417e14cc84b5ff"
echo "road-straight-lightposts.glb fc5340621fefdd43ab0bbf4bdaa41de9822dc545ce787fbfd92b75ce85d51e62"
echo "road-straight.glb 008a6305de778439d1a99be78a3e0945c72a9bc9cc0bade1b51a666d5db01d0b"
;;
fps)
echo "blaster-repeater.glb e76220e5ad3877d879e70fe21bd5bc76a5988b8682aa5f7976739e71caf70e05"
echo "blaster.glb 9c2110d94c1bd7e01bfe6827a3d87e14962a1f721d8b300c67bdd36f492140a1"
echo "cloud.glb 4c667239958ae8950b6bc2f55bdb59814b24fc9f6d8531eb09411150e09c87f2"
echo "enemy-flying.glb e4868f6d8cac2a8f728430229789206841ca64727d94de2d808b468e4f182f8c"
echo "grass-small.glb 8dfe00761fa833daab3192148b9b3d573f2a10aa2eff2c01a8a386dd304e7fda"
echo "grass.glb 8e11283ca11894860a4dcb8f523165eda5de39f68fda43d892efc3a3d3eb567e"
echo "platform-large-grass.glb 19ee4b9e745f757d87d584094433e3f4d86be958bcd7eeba586874f088d2697b"
echo "platform.glb 6eac72ffbda64b4414833eb49d3b160fddf169c2caccca237a07e15a20731764"
echo "wall-high.glb 946a0f875bd515323afd883553729a29ac764b405e382bf9a30d6527dac74589"
echo "wall-low.glb c42c89dead3835fde97d1ede232bf1a69d297cd41e1d6bfe7e25c87b233a2ec0"
;;
platformer)
echo "block-coin.glb ebe3a7be051566513039e6ba7d83d976fb3bf3e363fc8ee0633bdb872bb2d501"
echo "brick-particle.glb 981a84073e1dfa745a93db4ba550210049a144a4ac2c6cd333f289ff95630a72"
echo "brick.glb c9011dd30254c7a5dfdb29c61c22b6b3b4ad82186a096b90b824ab0b8dd58db8"
echo "character.glb 7112f6a08400914f9da546f3e6029e947cc9eab2b4a6da5eb99776111289efb1"
echo "cloud.glb 09c6f071d3a9ab64993248ee5c8d50debcd9c6cc4369236a01155ff7ff87f30a"
echo "coin.glb a3fb8f779a6af1cd75e5a02da77a87546ca8a59f6fe52f79f25179c4a68fd0e5"
echo "dust.glb 2f2ce449aca7791829bf2d93a7af534549f0bad1ee904d1a00df8d5329fc31dc"
echo "flag.glb 0ba84ff43f0fea0a9f0b080cfa1be54ebda36c8c35174d1ac2553ae6da9f65ae"
echo "grass-small.glb 4b94e8dd9cbdc3eedff99cbf614fe828f0c2342d21a7d0edb125b129d0422d51"
echo "grass.glb acb29df8a75b08e985e27a0ed8170b8f3d135abd24ccf5aa4d631cb2cd15cfda"
echo "platform-falling.glb d6dc9baba80af659e6d0fffc435bd1f08b1a7945942ccbb85dd9bb103b7a574d"
echo "platform-grass-large-round.glb 73c2b66eca6a36df6b5c25bb2c0594cf870066627275993a3a7857acc5a844bc"
echo "platform-large.glb d01f4eae24c895dfe467ef71c2ea510ca6bd6d8462f319741537936eae0b3f65"
echo "platform-medium.glb 64f81c8bd8bf07cc450dfcb25d18eedcc122d690028cca098bcdd48b00a02e46"
echo "platform.glb 78c5ed4da30c5a97f0747a54248bc9de05ba1b3fb6b94be527dd48599f5ef44c"
;;
racing)
echo "decoration-empty.glb 3815b26a5274173934d37cf605e320450efad4e0034040868b6aea761cfd74bc"
echo "decoration-forest.glb 664a53f0f709fef9096af3bbfb1aa76536527a616b05170c0cf4e27e33358a00"
echo "decoration-tents.glb 19dbf2a778ad75f95c7d61f12866ef7174ab69cad169d3749c5400f5e14db8a3"
echo "track-bump.glb 6db020edc53532ebc0971c9d4ad9afb11c4de67c510649ca843db4616514f43c"
echo "track-corner.glb 0ffb3d83b60456fc5a2962447111a1a64bfe943f4ad95462e1edba107be81c80"
echo "track-finish.glb 2fec3b681658d6e77e20c4342d1e3ceeaab3e8d2fbf340942cde25cc2b21975b"
echo "track-straight.glb 2d8080df1fe27e39981480809f36eba7f239e814f96bff61bb4c99793221b701"
echo "track-tents.glb eaf68bbb44e362e291b71d13015fbafc4ce22a9102bda029614972d6cc843c7f"
echo "vehicle-motorcycle.glb c97911b8dbc2d5dd9d1961b46eb4d1f132b67a6c5a84f45593fea44e0595dd92"
echo "vehicle-truck-green.glb df362d027a09b19395be0e96abd7cc2dc54608dfb920b592e64357b589f9fcd6"
echo "vehicle-truck-purple.glb 03e32f5abcbbd03da3591f42fdaae2cf7d27ed379df7ce0d1f233bea02998565"
echo "vehicle-truck-red.glb eca99bd9ab0a2b02125f915e65d1ec8f1c5a93be7c6d4840efdac6633f47772c"
echo "vehicle-truck-yellow.glb 1ebd83174eab6d2fdf69eb5d8e32bd06a23a86d9edc4fe915a4160430a7c36a5"
;;
*)
echo "unknown pack: $1" >&2
return 1
;;
esac
}
pack_count() { pack_files "$1" | wc -l | tr -d ' '; }
# Read MIRROR.toml into parallel arrays: slug, url, sha, models, core, usable.
manifest_rows() {
awk '
/^\[\[pack\]\]/ { slug=url=sha=""; models=0; core="false"; usable="false"; next }
/^slug =/ { gsub(/.*= "|"/, ""); slug=$0; next }
/^url =/ { gsub(/.*= "|"/, ""); url=$0; next }
/^sha256 =/ { gsub(/.*= "|"/, ""); sha=$0; next }
/^models =/ { gsub(/[^0-9]/, ""); models=$0; next }
/^core =/ { gsub(/.*= /, ""); core=$0; next }
/^usable =/ { gsub(/.*= /, ""); usable=$0
if (slug != "") print slug "|" url "|" sha "|" models "|" core "|" usable
next }
' "$MANIFEST"
}
want_pack() { # <slug> <core>
case "$PACKSEL" in
all) return 0 ;;
core) [[ "$2" == "true" ]] ;;
*) [[ ",$PACKSEL," == *",$1,"* ]] ;;
esac
}
if [[ "${1:-}" == "--list" ]]; then
echo "Makepad Arcade stock asset library"
echo
printf '%-12s %-6s %-10s %s\n' "PACK" "FILES" "LICENCE" "SOURCE"
for entry in "${KENNEY_PACKS[@]}"; do
IFS='|' read -r pack repo _commit _dir <<<"$entry"
printf '%-12s %-6s %-10s %s\n' "$pack" "$(pack_count "$pack")" "CC0-1.0" "kenney.nl (KenneyNL/$repo)"
done
printf '%-12s %-6s %-10s %s\n' "characters" "${#KAYKIT_CHARS[@]}" "CC0-1.0" \
"kaylousberg.com (KayKit Adventurers + Skeletons, one shared rig)"
for entry in "${KENNEY_AUDIO[@]}"; do
IFS='|' read -r pack _hash _sha count <<<"$entry"
printf '%-12s %-6s %-10s %s\n' "$pack" "$count" "CC0-1.0" "kenney.nl (ogg)"
done
if [[ -f "$MANIFEST" ]]; then
echo
echo "3D packs (from MIRROR.toml; * = in the default core set):"
tm=0; tp=0
while IFS='|' read -r slug _url _sha models core usable; do
[[ -z "$slug" ]] && continue
mark=" "; [[ "$core" == "true" ]] && mark="*"
note=""; [[ "$usable" == "true" ]] || note=" (FBX only — not fetched)"
printf '%s %-34s %5s models%s\n' "$mark" "$slug" "$models" "$note"
[[ "$usable" == "true" ]] || continue
tm=$((tm + models)); tp=$((tp + 1))
done < <(manifest_rows)
echo " ${tp} usable packs, ${tm} models total"
fi
echo
echo "Run without --list to download. Files land in resources/models/kenney/<pack>/,"
echo "resources/audio/kenney/<pack>/ and resources/characters/ — all gitignored."
echo
echo "Audio is Ogg Vorbis (Kenney ships no WAV). This tree has no vorbis decoder,"
echo "so sounds index and search but do not play yet; --transcode converts them to"
echo "WAV with ffmpeg if you have it installed."
exit 0
fi
TRANSCODE=0
for arg in "$@"; do
case "$arg" in
--transcode) TRANSCODE=1 ;;
--packs=*) PACKSEL="${arg#--packs=}" ;;
--mirror=*) MIRROR="${arg#--mirror=}" ;;
--list) : ;;
*) echo "unknown option: $arg" >&2; exit 1 ;;
esac
done
fetch() { # <url> <dest> <sha256> <label>
local url="$1" dest="$2" sha="$3" label="$4"
if [[ -f "$dest" ]] && echo "$sha $dest" | shasum -a 256 -c - >/dev/null 2>&1; then
cached=$((cached + 1))
return 0
fi
if ! curl -sSfL "$url" -o "$dest.tmp"; then
echo "ERROR: download failed: $label" >&2
echo " $url" >&2
rm -f "$dest.tmp"
exit 1
fi
if ! echo "$sha $dest.tmp" | shasum -a 256 -c - >/dev/null 2>&1; then
echo "ERROR: sha256 mismatch for $label (upstream changed or download corrupted)" >&2
echo " expected: $sha" >&2
echo " got: $(shasum -a 256 "$dest.tmp" | awk '{print $1}')" >&2
rm -f "$dest.tmp"
exit 1
fi
mv "$dest.tmp" "$dest"
fetched=$((fetched + 1))
}
# URL-encode spaces only; upstream paths use no other unsafe characters.
urlenc() { printf '%s' "$1" | sed 's/ /%20/g'; }
for entry in "${KENNEY_PACKS[@]}"; do
IFS='|' read -r pack repo commit dir <<<"$entry"
mkdir -p "$MODELS/$pack"
n=$(pack_count "$pack")
echo "kenney/$pack ($n models)"
while read -r name sha; do
[[ -z "$name" ]] && continue
url="https://raw.githubusercontent.com/KenneyNL/$repo/$commit/$(urlenc "$dir/$name")"
fetch "$url" "$MODELS/$pack/$name" "$sha" "kenney/$pack/$name"
done < <(pack_files "$pack")
done
mkdir -p "$CHARS"
echo "kaykit/characters (${#KAYKIT_CHARS[@]} rigged models, one shared 41-joint rig)"
for entry in "${KAYKIT_CHARS[@]}"; do
IFS='|' read -r local pack file sha _tex <<<"$entry"
fetch "$(kaykit_base "$pack")/$file" "$CHARS/$local.glb" "$sha" "kaykit/$local.glb"
done
for entry in "${KAYKIT_TEXTURES[@]}"; do
IFS='|' read -r local pack file sha <<<"$entry"
fetch "$(kaykit_base "$pack")/$file" "$CHARS/$local" "$sha" "kaykit/$local"
done
# ---- 3D model packs (manifest-driven) -------------------------------------
if [[ -f "$MANIFEST" ]]; then
while IFS='|' read -r slug url sha models core usable; do
[[ -z "$slug" ]] && continue
[[ "$usable" == "true" ]] || continue
want_pack "$slug" "$core" || continue
dest="$MODELS/$slug"
# The marker is written only after a COMPLETE extraction (models plus
# any texture atlas). Counting models alone declared a pack cached when
# its colormap.png had never been extracted at all — 48 of 52 packs
# rendered untextured while the script cheerfully reported them cached.
if [[ -f "$dest/.extracted" ]] && [[ $(find "$dest" -iname '*.glb' -o -iname '*.gltf' | wc -l | tr -d ' ') -ge "$models" ]]; then
cached=$((cached + 1))
continue
fi
# A mirror serves <base>/<slug>.zip; upstream keeps its own layout.
src="$url"
[[ -n "$MIRROR" ]] && src="$MIRROR/$slug.zip"
echo "kenney/$slug ($models models)"
zip="$MODELS/.$slug.zip"
mkdir -p "$MODELS"
fetch "$src" "$zip" "$sha" "models/$slug"
mkdir -p "$dest"
unzip -qo "$zip" -d "$zip.d" 2>/dev/null
chmod -R u+w "$zip.d" 2>/dev/null
# Prefer GLB and skip the OBJ/FBX/DAE copies — they are most of the
# archive and we cannot load them. NOTE GLB is NOT self-contained here:
# Kenney materials reference an external `Textures/colormap.png` shared
# by the whole pack, and that URI is RELATIVE TO THE GLB — so the
# directory structure has to survive extraction. Flattening the PNGs
# into the pack root leaves every model looking for a path that no
# longer exists, which is indistinguishable from having no texture.
# Only the atlas is wanted: the archives also carry Preview/Sample and
# per-model thumbnails, which are ~200 MB of images nothing loads.
# The GLB's URI is `Textures/colormap.png` relative to itself, and GLBs
# land in the pack root — so keep the `Textures/` tail and drop
# everything above it (archives nest it under e.g. `FBX format/`,
# which varies per pack and must not survive).
while IFS= read -r tex; do
rel="Textures/${tex##*/Textures/}"
mkdir -p "$dest/$(dirname "$rel")"
mv -f "$tex" "$dest/$rel"
done < <(find "$zip.d" -ipath '*/Textures/*.png' ! -iname 'Preview*' ! -iname 'Sample*')
if [[ $(find "$zip.d" -iname '*.glb' | wc -l | tr -d ' ') -gt 0 ]]; then
find "$zip.d" -iname '*.glb' -exec sh -c 'mv -f "$1" "$2/$(basename "$1")"' _ {} "$dest" \;
else
find "$zip.d" \( -iname '*.gltf' -o -iname '*.bin' \) -exec sh -c 'mv -f "$1" "$2/$(basename "$1")"' _ {} "$dest" \;
fi
rm -rf "$zip.d" "$zip"
: >"$dest/.extracted"
# Be a good guest: pace requests so a full run is a trickle, not a flood.
sleep 1
done < <(manifest_rows)
fi
for entry in "${KENNEY_AUDIO[@]}"; do
IFS='|' read -r pack hash sha count <<<"$entry"
dest="$AUDIO/$pack"
zip="$AUDIO/.$pack.zip"
if [[ -d "$dest" ]] && [[ $(find "$dest" -name '*.ogg' | wc -l | tr -d ' ') -ge "$count" ]]; then
cached=$((cached + 1))
continue
fi
echo "kenney/$pack ($count sounds)"
mkdir -p "$AUDIO"
fetch "https://kenney.nl/media/pages/assets/$pack/$hash/kenney_$pack.zip" "$zip" "$sha" "audio/$pack"
mkdir -p "$dest"
# Flatten: the packs nest under Audio/ or Sounds/, and we only want the
# sound files — never the bundled .url/.txt/preview cruft.
unzip -qo "$zip" -d "$zip.d"
# Some packs ship directories without the owner write bit (sci-fi-sounds
# is mode r-xr-xr-x), which blocks both the move out and the cleanup.
chmod -R u+w "$zip.d"
find "$zip.d" -name '*.ogg' -exec mv -f {} "$dest/" \;
rm -rf "$zip.d" "$zip"
done
if [[ $TRANSCODE == 1 ]]; then
if command -v ffmpeg >/dev/null 2>&1; then
echo "transcoding ogg -> wav (ffmpeg)"
find "$AUDIO" -name '*.ogg' | while read -r f; do
w="${f%.ogg}.wav"
[[ -f "$w" ]] || ffmpeg -loglevel error -y -i "$f" "$w"
done
else
echo "WARNING: --transcode needs ffmpeg on PATH; skipping" >&2
fi
fi
echo
echo "done — $fetched fetched, $cached already cached"
echo
echo "These assets are CC0 (public domain). Attribution isn't required, but is"
echo "deserved:"
echo " Kenney https://kenney.nl/assets"
echo " KayKit / Kay Lousberg https://kaylousberg.itch.io/"
echo
echo "Thank you both for giving so many high-quality assets away for free."

View file

@ -1,85 +0,0 @@
//! Print what the Zelda-scale plan actually builds.
//!
//! Layout is pure, so the whole world can be inspected without a window —
//! which is the point of splitting `plan` from `realise`. Run with:
//! cargo run -p makepad-arcade --example bigworld_probe --release
use makepad_arcade::bigworld::{self, Region};
fn main() {
let root = std::path::Path::new("apps/arcade/resources");
if !root.join("models/kenney").is_dir() {
eprintln!("run apps/arcade/download_assets.sh first");
return;
}
let t = std::time::Instant::now();
let index = makepad_game_assets::AssetIndex::build(root);
let index_ms = t.elapsed().as_millis();
let plan = bigworld::plan(&index, 7);
let s = &plan.stats;
println!(
"index {} entries in {index_ms} ms\nplan {} props ({} distinct models), {} tiles, {} npcs, {} pois, {} interactables in {} us",
index.len(),
s.props,
s.distinct_models,
s.tiles,
s.npcs,
plan.pois.len(),
plan.interactables.len(),
s.gen_us,
);
println!("\nper region:");
for (r, n) in &s.per_region {
println!(" {:<9} {n:>5}", r.name());
}
println!("\ncast:");
let (cj, civ) = bigworld::civilian_cast(&index);
let (hj, hero) = bigworld::hero_cast(&index);
println!(" civilians {cj} joints, {} members", civ.len());
println!(" heroes {hj} joints, {} members", hero.len());
println!("\nnpcs by region:");
for r in [Region::Village, Region::Castle, Region::Dungeon] {
let n: Vec<&str> = plan
.npcs
.iter()
.filter(|n| n.region == r)
.filter_map(|n| n.character.as_deref())
.collect();
println!(" {:<8} {}", r.name(), n.join(", "));
}
println!("\nsample models per region:");
for (r, _) in &s.per_region {
let mut ids: Vec<&str> = plan
.placements
.iter()
.filter(|p| p.region == *r)
.map(|p| p.model.as_str())
.collect();
ids.sort_unstable();
ids.dedup();
println!(" {:<9} {}", r.name(), ids.iter().take(5).cloned().collect::<Vec<_>>().join(", "));
}
// Reachability: every region centre must be within a short walk of a road
// tile, or a player cannot get there on foot.
println!("\nreachability (nearest road tile to each region centre):");
for (name, c) in [
("village", bigworld::VILLAGE),
("castle", bigworld::CASTLE),
("woods", bigworld::WOODS),
("dungeon", bigworld::DUNGEON),
("quarry", bigworld::QUARRY),
] {
let best = plan
.placements
.iter()
.filter(|p| p.region == Region::Roads)
.map(|p| ((p.pos.x - c.x).powi(2) + (p.pos.z - c.y).powi(2)).sqrt())
.fold(f32::INFINITY, f32::min);
println!(" {name:<8} {best:.1} units");
}
}

View file

@ -1,39 +0,0 @@
# Machine-readable attribution for the stock asset library.
#
# A published game package carries this so credit travels with the game, even
# though CC0 does not require it. Generated games should copy the rows for the
# sources they actually use.
[[source]]
name = "Kenney"
url = "https://kenney.nl/assets"
license = "CC0-1.0"
credit = "Kenney (kenney.nl) — CC0"
packs = [
"arena (Starter-Kit-Basic-Scene)",
"city (Starter-Kit-City-Builder)",
"fps (Starter-Kit-FPS)",
"platformer (Starter-Kit-3D-Platformer)",
"racing (Starter-Kit-Racing)",
"impact-sounds",
"interface-sounds",
"sci-fi-sounds",
"music-jingles",
"ui-audio",
"rpg-audio",
"digital-audio",
]
[[source]]
name = "KayKit / Kay Lousberg"
url = "https://kaylousberg.itch.io/"
license = "CC0-1.0"
credit = "KayKit / Kay Lousberg (kaylousberg.com) — CC0"
packs = [
"characters (KayKit Character Pack: Adventurers) — Knight, Barbarian, Mage, Rogue, Rogue_Hooded",
"characters (KayKit Character Pack: Skeletons) — Warrior, Mage, Rogue, Minion",
]
# All nine share one 41-joint rig, so an animation authored against any of them
# plays on every one. Adventurers ship 76 clips; skeletons ship the same 76 plus
# 19 undead extras (awaken, resurrect, spawn, taunt).
rig = "kaykit-adventurers-41j"

View file

@ -1,522 +0,0 @@
# Mirror manifest for Makepad Arcade's Kenney asset library.
#
# Every pack below is CC0 (public domain), which is what makes mirroring legal:
# anyone can re-host these bytes. This file is the reproducible description of
# the mirror — slug, canonical upstream URL, sha256 and size — so a mirror can
# be rebuilt or verified by anyone, and so a mirrored byte stream is checked
# exactly as strictly as an upstream one. A mirror is never trusted more than
# upstream; the hash is the authority, not the host.
#
# Generated by tools/scrape (see README). Columns are fixed-width for diffing.
#
# core = fetched by default; the rest need --packs=all or --packs=<slug>,...
# usable = ships glTF/GLB. The three animated-character packs are FBX-only and
# we have no FBX loader, so they are recorded but never fetched.
[meta]
source = "kenney.nl"
license = "CC0-1.0"
credit = "Kenney (kenney.nl) — CC0"
packs = 50
models = 4669
bytes = 173943741
[[pack]]
slug = "3d-road-tiles"
title = "3D Road Tiles"
url = "https://kenney.nl/media/pages/assets/3d-road-tiles/3fbfaa7ec8-1677581262/kenney_3d-road-tiles.zip"
sha256 = "93af3af6d2cef287a0eca7efb936a515574151d3cb32c202f14d8f6a99518c40"
bytes = 3683526
models = 302
core = false
usable = true
[[pack]]
slug = "animated-characters-protagonists"
title = "Animated Characters Protagonists"
url = "https://kenney.nl/media/pages/assets/animated-characters-protagonists/608191acc4-1774773108/kenney_animated-characters-protagonists.zip"
sha256 = "ec3787de70fa2200256848d74201b10f6b6c3126594e9857bf989753312c2b84"
bytes = 581441
models = 0
core = false
usable = false
[[pack]]
slug = "animated-characters-retro"
title = "Animated Characters Retro"
url = "https://kenney.nl/media/pages/assets/animated-characters-retro/93305a3c49-1774772819/kenney_animated-characters-retro.zip"
sha256 = "1d03f1fb001f3cf629425b69c898df58261a316616220ea25b58bd35298e6882"
bytes = 706472
models = 0
core = false
usable = false
[[pack]]
slug = "animated-characters-survivors"
title = "Animated Characters Survivors"
url = "https://kenney.nl/media/pages/assets/animated-characters-survivors/27b16052a7-1774772958/kenney_animated-characters-survivors.zip"
sha256 = "fdadced07a0454c9b7f0b46507be6144a072b4d03b4ffa37f225893c76c62845"
bytes = 718027
models = 0
core = false
usable = false
[[pack]]
slug = "blaster-kit"
title = "Blaster Kit"
url = "https://kenney.nl/media/pages/assets/blaster-kit/261d80a716-1753959510/kenney_blaster-kit_2.1.zip"
sha256 = "91e3093e95427d59625e7e2ce2d0399b861600160fd0b4ada7714796b67cea8c"
bytes = 1724676
models = 40
core = false
usable = true
[[pack]]
slug = "blocky-characters"
title = "Blocky Characters"
url = "https://kenney.nl/media/pages/assets/blocky-characters/8369c0cf30-1749547469/kenney_blocky-characters_20.zip"
sha256 = "5e123859aa0c1598342b600c6db197024a1d63eb9ec531398b310725f589887e"
bytes = 2148510
models = 18
core = true
usable = true
[[pack]]
slug = "brick-kit"
title = "Brick Kit"
url = "https://kenney.nl/media/pages/assets/brick-kit/46a22f3d08-1716981002/kenney_brick-kit.zip"
sha256 = "b303d293c278fab713eed28395829b18513b171d2562f7f518c3355c2896861a"
bytes = 4668567
models = 296
core = false
usable = true
[[pack]]
slug = "building-kit"
title = "Building Kit"
url = "https://kenney.nl/media/pages/assets/building-kit/0de7aaa492-1743244741/kenney_building-kit.zip"
sha256 = "2740ef5772fb5fb3d7aab881db22d129f6b68afe711b1a79e6d5e9e19cf3eec6"
bytes = 1598905
models = 79
core = false
usable = true
[[pack]]
slug = "car-kit"
title = "Car Kit"
url = "https://kenney.nl/media/pages/assets/car-kit/1a312ec241-1775131960/kenney_car-kit.zip"
sha256 = "fac7dacac5c7874348cf19729af3ef205f3d366493edaf0a827d93f4fdf3d0c4"
bytes = 4814237
models = 50
core = true
usable = true
[[pack]]
slug = "castle-kit"
title = "Castle Kit"
url = "https://kenney.nl/media/pages/assets/castle-kit/a395102d20-1711543616/kenney_castle-kit.zip"
sha256 = "921f3f73927bb23106cae34bc21d5ab4b033a9fc120475e96f714a406e3169df"
bytes = 2232589
models = 76
core = true
usable = true
[[pack]]
slug = "city-kit-commercial"
title = "City Kit (Commercial)"
url = "https://kenney.nl/media/pages/assets/city-kit-commercial/a742d900eb-1753115042/kenney_city-kit-commercial_2.1.zip"
sha256 = "f8b09b081c2bb88bcc126e2dec1cb40fd0dad7e7e591b6c26aaefe96fb35276b"
bytes = 4096974
models = 41
core = false
usable = true
[[pack]]
slug = "city-kit-industrial"
title = "City Kit (Industrial)"
url = "https://kenney.nl/media/pages/assets/city-kit-industrial/5fcb837741-1750838303/kenney_city-kit-industrial_1.0.zip"
sha256 = "99a09ff148056678c0c3b7977ad9dbce55d2f243e06fbfc7c28642accef4dd9e"
bytes = 3805564
models = 25
core = false
usable = true
[[pack]]
slug = "city-kit-roads"
title = "City Kit (Roads)"
url = "https://kenney.nl/media/pages/assets/city-kit-roads/74288c9459-1741864740/kenney_city-kit-roads.zip"
sha256 = "2c1644a293a85d98837ef788b0cbc4b9d53dffb1280fbe9a4f927b644aaba4b0"
bytes = 1716227
models = 72
core = true
usable = true
[[pack]]
slug = "city-kit-suburban"
title = "City Kit (Suburban)"
url = "https://kenney.nl/media/pages/assets/city-kit-suburban/2c871b7af2-1745479373/kenney_city-kit-suburban_20.zip"
sha256 = "5869c35cf30b1c87bdb2d197b6d325eebadd2ef08ea27f04797e8e08d77a9a39"
bytes = 3038740
models = 40
core = true
usable = true
[[pack]]
slug = "coaster-kit"
title = "Coaster Kit"
url = "https://kenney.nl/media/pages/assets/coaster-kit/546fdc554f-1731487890/kenney_coaster-kit.zip"
sha256 = "7723d02e3ea822371e12e14722e72d572fdb0384c1379c1469e0598a371f5c7a"
bytes = 6697401
models = 183
core = false
usable = true
[[pack]]
slug = "cube-pets"
title = "Cube Pets"
url = "https://kenney.nl/media/pages/assets/cube-pets/44e58e945f-1774520254/kenney_cube-pets_1.0.zip"
sha256 = "b3bdc99a2ec92c687b875718c5d01e9231d2711ed0e2845f295b474bb42a1283"
bytes = 2812444
models = 24
core = true
usable = true
[[pack]]
slug = "factory-kit"
title = "Factory Kit"
url = "https://kenney.nl/media/pages/assets/factory-kit/edaac9d4f6-1777639602/kenney_factory-kit_3.0.zip"
sha256 = "7e31fb2308e90304672bd15cd18fa9d9f02c03731a8cbc57a8e3e1c181dfb0a7"
bytes = 4511890
models = 143
core = false
usable = true
[[pack]]
slug = "fantasy-town-kit"
title = "Fantasy Town Kit"
url = "https://kenney.nl/media/pages/assets/fantasy-town-kit/efe948d309-1754222374/kenney_fantasy-town-kit_2.0.zip"
sha256 = "1a7530c09f4d2fa2cdee259876f089334f8b1f27fa86a0c4f54ef86cdd8676ef"
bytes = 3854691
models = 167
core = false
usable = true
[[pack]]
slug = "food-kit"
title = "Food Kit"
url = "https://kenney.nl/media/pages/assets/food-kit/83086fa91c-1719418518/kenney_food-kit.zip"
sha256 = "cdad90853682499b94c9fda2f87678b24bfd8f3264e0ed323f6b6a27fd7c6f6f"
bytes = 4606270
models = 200
core = true
usable = true
[[pack]]
slug = "furniture-kit"
title = "Furniture Kit"
url = "https://kenney.nl/media/pages/assets/furniture-kit/440e0608a4-1677580847/kenney_furniture-kit.zip"
sha256 = "e67652d0932cee41683f74711c03d3e192a2af9979ef8e6b237711f5482d46b0"
bytes = 5130729
models = 140
core = true
usable = true
[[pack]]
slug = "graveyard-kit"
title = "Graveyard Kit"
url = "https://kenney.nl/media/pages/assets/graveyard-kit/ba8d4b4517-1760691807/kenney_graveyard-kit_5.0.zip"
sha256 = "1a93613f2e5675f3310acf49ec9ef13ae7adeb756ac3b205bfb6cc9311a81062"
bytes = 3623408
models = 91
core = false
usable = true
[[pack]]
slug = "hexagon-kit"
title = "Hexagon Kit"
url = "https://kenney.nl/media/pages/assets/hexagon-kit/fd3b63101b-1706007730/kenney_hexagon-kit.zip"
sha256 = "d14f7643852c4dd854c7530de8514c9eb77314fe9963636077f0de1da8901236"
bytes = 2448931
models = 72
core = false
usable = true
[[pack]]
slug = "holiday-kit"
title = "Holiday Kit"
url = "https://kenney.nl/media/pages/assets/holiday-kit/3976a6496a-1733923970/kenney_holiday-kit.zip"
sha256 = "fde4d514d7297388d98058e8933ff614e071886f7ce57f9aea4b00d7698dd769"
bytes = 4482244
models = 99
core = true
usable = true
[[pack]]
slug = "marble-kit"
title = "Marble Kit"
url = "https://kenney.nl/media/pages/assets/marble-kit/56fd69e5ed-1716385090/kenney_marble-kit.zip"
sha256 = "4a289fe77fa4e71b943fb9a0df828a7987e62f665083ea9237d0bd9acd0b6dcb"
bytes = 5339532
models = 162
core = false
usable = true
[[pack]]
slug = "mini-arcade"
title = "Mini Arcade"
url = "https://kenney.nl/media/pages/assets/mini-arcade/ece1e8f320-1721638600/kenney_mini-arcade.zip"
sha256 = "2acfe5cb44d392e834f77cf5488528c7cd45427cdf5fb941ce99856276158c19"
bytes = 1317439
models = 20
core = false
usable = true
[[pack]]
slug = "mini-arena"
title = "Mini Arena"
url = "https://kenney.nl/media/pages/assets/mini-arena/88f977a0cb-1709220730/kenney_mini-arena.zip"
sha256 = "514760c2bc2457027451534a0b06a23f98beece166fb11ed2321226276b83c2c"
bytes = 731420
models = 22
core = false
usable = true
[[pack]]
slug = "mini-characters"
title = "Mini Characters"
url = "https://kenney.nl/media/pages/assets/mini-characters/bfc7e272b4-1774770718/kenney_mini-characters.zip"
sha256 = "9e1d48e6d7b8479ebbe84df71eb5bd8e1b3f0da546dea641890dccc8a02d0999"
bytes = 2403059
models = 26
core = false
usable = true
[[pack]]
slug = "mini-dungeon"
title = "Mini Dungeon"
url = "https://kenney.nl/media/pages/assets/mini-dungeon/6cd72dc849-1785314274/kenney_mini-dungeon.zip"
sha256 = "19c4648680cb1d2e8836cade96cbf9781c0c1f45fbc6d2ce41cee8239a3ec4d8"
bytes = 1796820
models = 30
core = true
usable = true
[[pack]]
slug = "mini-forest"
title = "Mini Forest"
url = "https://kenney.nl/media/pages/assets/mini-forest/44a89aed7f-1784024079/kenney_mini-forest_1.0.zip"
sha256 = "8691614018075a66458e35915b8c358c2e6178648aedadafcdf313b924aa6581"
bytes = 1118081
models = 22
core = false
usable = true
[[pack]]
slug = "mini-market"
title = "Mini Market"
url = "https://kenney.nl/media/pages/assets/mini-market/463f38da51-1729865423/kenney_mini-market.zip"
sha256 = "6f2e69090e359204ace43e4c98dc3fb8639b75e423109672b5c890d194c45203"
bytes = 1135859
models = 20
core = false
usable = true
[[pack]]
slug = "mini-skate"
title = "Mini Skate"
url = "https://kenney.nl/media/pages/assets/mini-skate/00b0c2b304-1709221152/kenney_mini-skate.zip"
sha256 = "82582f6de507e93090c16bb4802a7c361015fe246eddd571e6e96f816b12e8c6"
bytes = 721232
models = 20
core = false
usable = true
[[pack]]
slug = "minigolf-kit"
title = "Minigolf Kit"
url = "https://kenney.nl/media/pages/assets/minigolf-kit/3ae60d8b01-1741163874/kenney_minigolf-kit.zip"
sha256 = "74b7bf5cf82bf8a5225319ade1284600c64f9482ae66f0ccf1f22d2212d5a952"
bytes = 3163116
models = 126
core = false
usable = true
[[pack]]
slug = "modular-buildings"
title = "Modular Buildings"
url = "https://kenney.nl/media/pages/assets/modular-buildings/3253b4219a-1707397411/kenney_modular-buildings.zip"
sha256 = "47e1614686b4c0fe55190e88b22ff2b8b10935edf61f37f478a9b6a99477edc6"
bytes = 1825490
models = 108
core = false
usable = true
[[pack]]
slug = "modular-cave-kit"
title = "Modular Cave Kit"
url = "https://kenney.nl/media/pages/assets/modular-cave-kit/37ec3cb12d-1783667097/kenney_modular-cave-kit_1.0.zip"
sha256 = "48f37a6d4f241124cd7da17da1c6d4ed1bf1820bb149dcb233fbd5ebdd8ba996"
bytes = 7025078
models = 40
core = false
usable = true
[[pack]]
slug = "modular-dungeon-kit"
title = "Modular Dungeon Kit"
url = "https://kenney.nl/media/pages/assets/modular-dungeon-kit/7bed87605b-1771926065/kenney_modular-dungeon-kit_1.0.zip"
sha256 = "dd0aa6776db8912283cdca60161dee6a8839bbda3558eba2ea501419eb5b4623"
bytes = 6886434
models = 39
core = false
usable = true
[[pack]]
slug = "modular-space-kit"
title = "Modular Space Kit"
url = "https://kenney.nl/media/pages/assets/modular-space-kit/8261428a47-1771146076/kenney_modular-space-kit_1.0.zip"
sha256 = "f394f7fd9eaf29c9de7e090e55b69926f699841af33b0b116f5cc0088de8a4dc"
bytes = 6966606
models = 40
core = false
usable = true
[[pack]]
slug = "nature-kit"
title = "Nature Kit"
url = "https://kenney.nl/media/pages/assets/nature-kit/37ac38a37b-1677698939/kenney_nature-kit.zip"
sha256 = "fa7974a0d342bfe63c38664ba9f8ec1a4aab8ea25f099bdc56870e33588c4d9d"
bytes = 10537521
models = 329
core = true
usable = true
[[pack]]
slug = "pirate-kit"
title = "Pirate Kit"
url = "https://kenney.nl/media/pages/assets/pirate-kit/e6d4bb1525-1771333093/kenney_pirate-kit.zip"
sha256 = "667ed2caf92954ddb98f7b7cede831fe99ab75063c26b25e23d32715bee9c943"
bytes = 3154665
models = 72
core = true
usable = true
[[pack]]
slug = "platformer-kit"
title = "Platformer Kit"
url = "https://kenney.nl/media/pages/assets/platformer-kit/1585cf62b4-1775122253/kenney_platformer-kit.zip"
sha256 = "899605d237367688c6b42e41fff2206c0fb8d626163158294556353f7baa7c1b"
bytes = 4640135
models = 153
core = true
usable = true
[[pack]]
slug = "prototype-kit"
title = "Prototype Kit"
url = "https://kenney.nl/media/pages/assets/prototype-kit/4d3b7073ed-1724832076/kenney_prototype-kit.zip"
sha256 = "213b522fb12bcc9b9ac66c4f7581f7c74623293272212e40a70c39936ad3da95"
bytes = 2961396
models = 145
core = false
usable = true
[[pack]]
slug = "racing-kit"
title = "Racing Kit"
url = "https://kenney.nl/media/pages/assets/racing-kit/933b8fd9fd-1677580949/kenney_racing-kit.zip"
sha256 = "8a71ea16219315a01d00d5a90c4f6b5c090faddbc56d80ecf727e2b3b853c6c0"
bytes = 6082755
models = 112
core = true
usable = true
[[pack]]
slug = "retro-fantasy-kit"
title = "Retro Fantasy Kit"
url = "https://kenney.nl/media/pages/assets/retro-fantasy-kit/cf3f41b752-1774770658/kenney_retro-fantasy-kit.zip"
sha256 = "d3462b453018d0d150406d85af3c4ae1322ceef8f1754c4eb4e6355adfeaab0f"
bytes = 2024232
models = 105
core = false
usable = true
[[pack]]
slug = "retro-urban-kit"
title = "Retro Urban Kit"
url = "https://kenney.nl/media/pages/assets/retro-urban-kit/8314d4db22-1738147509/kenney_retro-urban-kit.zip"
sha256 = "19201cbcb0d90080dfc33b54f5b4be2089de927c2a23274180798e7166d6b6ca"
bytes = 2161463
models = 124
core = false
usable = true
[[pack]]
slug = "space-kit"
title = "Space Kit"
url = "https://kenney.nl/media/pages/assets/space-kit/20874c75ac-1677698978/kenney_space-kit.zip"
sha256 = "d5d7cdf2635ed5a43a9187deaf409b6f47484e402321128341d3c3698e9ef4d9"
bytes = 6677531
models = 153
core = true
usable = true
[[pack]]
slug = "space-station-kit"
title = "Space Station Kit"
url = "https://kenney.nl/media/pages/assets/space-station-kit/6475288f2e-1712749919/kenney_space-station-kit.zip"
sha256 = "215e79bd5415cff93665183390f0343ed9acf87780306331013b78520170c6d8"
bytes = 1835875
models = 97
core = false
usable = true
[[pack]]
slug = "survival-kit"
title = "Survival Kit"
url = "https://kenney.nl/media/pages/assets/survival-kit/4065a8185b-1712149243/kenney_survival-kit.zip"
sha256 = "c3586341b5932c87eb43d75d915434f47daed168b17ed36a03e8ca9977c7443e"
bytes = 1948174
models = 80
core = false
usable = true
[[pack]]
slug = "tower-defense-kit"
title = "Tower Defense Kit"
url = "https://kenney.nl/media/pages/assets/tower-defense-kit/a402493eaa-1726471567/kenney_tower-defense-kit.zip"
sha256 = "d4c887680b709218315e4e1c17ae18c160635dcdfc4199763eeba97c02c77f00"
bytes = 5404433
models = 160
core = true
usable = true
[[pack]]
slug = "toy-car-kit"
title = "Toy Car Kit"
url = "https://kenney.nl/media/pages/assets/toy-car-kit/42e19cc426-1736346027/kenney_toy-car-kit.zip"
sha256 = "26c11bbb77102b8dd00cdaf7b2c7ab692416d750dd064de886d809acec346782"
bytes = 5245353
models = 157
core = false
usable = true
[[pack]]
slug = "train-kit"
title = "Train Kit"
url = "https://kenney.nl/media/pages/assets/train-kit/cf8521d625-1727040883/kenney_train-kit.zip"
sha256 = "cf50d77e8cbacbf38dd50826d4bce5392db8e4f67373d3c4e583b0ed0e474475"
bytes = 5266588
models = 103
core = true
usable = true
[[pack]]
slug = "watercraft-kit"
title = "Watercraft Kit"
url = "https://kenney.nl/media/pages/assets/watercraft-kit/a335cfed49-1713519620/kenney_watercraft-pack.zip"
sha256 = "cd1470c1cf441c7f46d0944ae6d0d897242365dc97677c5079b3238965d659f3"
bytes = 1870991
models = 46
core = true
usable = true

View file

@ -1,6 +0,0 @@
# Downloaded by download_assets.sh — never committed (see repo policy in game.md).
#
# Allow-list rather than deny-list, so a pack shipping an unlisted format
# cannot slip hundreds of asset files into a commit.
*
!.gitignore

View file

@ -1,7 +0,0 @@
# Downloaded by download_assets.sh — never committed (see repo policy in game.md).
#
# Allow-list rather than deny-list: LICENSE-CC0.txt is checked in deliberately,
# everything else here is fetched.
*
!.gitignore
!LICENSE-CC0.txt

View file

@ -1,33 +0,0 @@
KayKit : Adventurers Character Pack (1.0)
Created/distributed by Kay Lousberg (www.kaylousberg.com)
Creation date: 13/03/2023 09:00
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support me by using a brand resource provided in this pack or by crediting Kay Lousberg, www.kaylousberg.com (this is not mandatory)
------------------------------
This asset pack is here thanks to all the wonderful people who support KayKit on Patreon and those who buy our EXTRA or SOURCE packs on itch.io.
And a big, special thank you to my Super Supporters:
- Brian McBarron
- Silva
------------------------------
Patreon: http://patreon.com/kaylousberg
Follow on Twitter for updates:
http://twitter.com/KayLousberg

View file

@ -1,7 +0,0 @@
# Downloaded by download_assets.sh — never committed (see repo policy in game.md).
#
# Allow-list rather than deny-list: a new pack shipping a format nobody
# listed here (3d-road-tiles ships .gltf, not .glb) would otherwise put
# hundreds of asset files into a commit by accident.
*
!.gitignore

View file

@ -1,151 +0,0 @@
// Makepad Arcade — the starting world.
//
// This is a TEMPLATE, not a demo: it is what a new world begins as, and it is
// meant to be edited. Everything here is a `game.*` verb, so the AI editing
// this file can change any of it — which is the whole point. Nothing about
// this world lives in Rust.
//
// It is deliberately readable top to bottom: sky, ground, town, cars, a
// player, something to climb. If you are adding to it, add a section.
// ---------------------------------------------------------------- sky & sun
game.sky({})
// A FIXED sun, given as a direction rather than a time of day. 38 degrees of
// elevation casts a shadow about 1.3x its caster's height: long enough to show
// what shape threw it and which way the ground slopes, short enough that the
// village does not disappear into its own shade. A moving sun re-bakes the
// world's shadows continuously and nothing on screen ever settles.
game.sun({dir: vec3(0.55, 0.62, 0.56)})
// ------------------------------------------------------------------ terrain
// `feature` is how far apart the hills are, in world units. `rim` grows the
// relief toward the edge of the map: a playable basin with a scenic horizon,
// which is the answer to terrain being either boring or unbuildable. The
// plaza keeps the town's ground genuinely flat — a road is one long box and a
// house has square feet, and neither can follow a slope.
game.terrain({
size: 260,
cells: 129,
smooth: true,
seed: 1466247766,
amp: 5,
feature: 90,
flatten: 1.25,
rim: 5,
rim_start: 0.34,
plaza: {r: 66, ramp: 26, h: 0},
color: #5a8f46
})
// -------------------------------------------------------------------- roads
// A crossroads, not a corridor. A junction is the smallest thing that makes a
// place feel like it has somewhere else to be. `collide: false` makes these
// surfaces rather than kerbs the car has to climb.
game.box({pos: vec3(0, 0.02, 0), size: vec3(116, 0.1, 14), color: #4d4d52, tag: "road", collide: false})
game.box({pos: vec3(0, 0.02, 0), size: vec3(14, 0.1, 104), color: #4d4d52, tag: "road", collide: false})
game.box({pos: vec3(-20, 0.02, 11), size: vec3(8, 0.1, 22), color: #52525a, tag: "road", collide: false})
// ------------------------------------------------------------------- houses
// `find_model` returns DISTINCT models. Ask for several and place a different
// one at each lot — five identical houses read as wallpaper however good the
// model is. Four, not more: this kit runs out of houses and starts returning
// lot pieces, one of which is a swimming pool.
let houses = game.find_model("suburban house building", {count: 4, spread: "variants"})
game.model(houses[0], {pos: vec3(-30, 0, -10), yaw: 0, scale: 4.2, tag: "house"})
game.model(houses[1], {pos: vec3(-19, 0, -10), yaw: 0, scale: 4.5, tag: "house"})
game.model(houses[2], {pos: vec3(-10, 0, -10), yaw: 0, scale: 4.2, tag: "house"})
game.model(houses[3], {pos: vec3(0.5, 0, -10), yaw: 0, scale: 4.5, tag: "house"})
game.model(houses[0], {pos: vec3(10, 0, -10), yaw: 0, scale: 4.2, tag: "house"})
game.model(houses[1], {pos: vec3(19, 0, -10), yaw: 0, scale: 4.5, tag: "house"})
game.model(houses[2], {pos: vec3(30, 0, -10), yaw: 0, scale: 4.2, tag: "house"})
// North side, set well back: the third-person camera sits about 9 units behind
// you, so a row any closer is in the shot rather than behind it.
game.model(houses[3], {pos: vec3(-25, 0, 26), yaw: 3.14159, scale: 4.0, tag: "house"})
game.model(houses[0], {pos: vec3(-14.5, 0, 26), yaw: 3.14159, scale: 4.4, tag: "house"})
game.model(houses[1], {pos: vec3(14.5, 0, 26), yaw: 3.14159, scale: 4.0, tag: "house"})
game.model(houses[2], {pos: vec3(25, 0, 26), yaw: 3.14159, scale: 4.4, tag: "house"})
// ------------------------------------------------------------------ scenery
let trees = game.find_model("tree pine", {count: 4, spread: "kinds"})
game.model(trees[0], {pos: vec3(-34, 0, -26), scale: 7, tag: "tree"})
game.model(trees[1], {pos: vec3(-24, 0, -30), scale: 6, tag: "tree"})
game.model(trees[2], {pos: vec3(12, 0, -28), scale: 7, tag: "tree"})
game.model(trees[3], {pos: vec3(28, 0, -24), scale: 6, tag: "tree"})
game.model(trees[0], {pos: vec3(38, 0, 8), scale: 7, tag: "tree"})
// --------------------------------------------------------------------- cars
// `spread: "kinds"` gives one model per FAMILY — one of each type. "variants"
// would give six of the same kind, which is right for a terrace of houses and
// wrong for traffic.
//
// Every one of these is a real vehicle: walk up to any of them and the engine
// offers to let you in. Cars need no `game.interactable` — the prompt is
// derived from the car itself.
let cars = game.find_model("ambulance delivery taxi police sedan hatchback", {count: 6, spread: "kinds"})
game.car({pos: vec3(-6, 1.2, 1.6), model: cars[0], player: true})
game.car({pos: vec3(-24, 1.2, 5.2), model: cars[1]})
game.car({pos: vec3(-13, 1.2, 5.2), model: cars[2]})
game.car({pos: vec3(7.5, 1.2, 5.2), model: cars[3]})
game.car({pos: vec3(22, 1.2, 5.2), model: cars[4]})
game.car({pos: vec3(16.5, 1.2, -4.5), model: cars[5], rot_y: 3.14159})
// ------------------------------------------------------------------- people
// A bearded hero on the full hero rig. `player_character` is the whole
// third-person binding — walker, follow camera, and getting in and out of
// cars — with crafted defaults you never have to name.
let heroes = game.find_model("barbarian", {count: 1, rigged: true})
let me = game.player_character({pos: vec3(-6, 2, 9.5), model: heroes[0]})
game.label(me, "You")
// Townsfolk who wander their own patch.
let folk = game.find_model("character person", {count: 4, spread: "kinds", rigged: true})
let a = game.character({pos: vec3(-14, 2, 8), model: folk[0], tag: "villager"})
game.wander(a, {home: vec3(-14, 0, 8), range: 14, speed: 2.0})
let b = game.character({pos: vec3(4, 2, 10), model: folk[1], tag: "villager"})
game.wander(b, {home: vec3(4, 0, 10), range: 14, speed: 2.2})
let c = game.character({pos: vec3(18, 2, 6), model: folk[2], tag: "villager"})
game.wander(c, {home: vec3(18, 0, 6), range: 12, speed: 1.9})
let d = game.character({pos: vec3(-26, 2, 2), model: folk[3], tag: "villager"})
game.wander(d, {home: vec3(-26, 0, 2), range: 12, speed: 2.1})
// -------------------------------------------------------------------- climb
// A tower you spiral around rather than a line of pads: the thing you are
// climbing stays in view, so your progress is legible from the ground.
//
// Three rules make a course feel fair rather than fiddly — every jump is
// survivable from a standstill, the whole route is visible from the bottom,
// and falling costs height rather than a life.
game.box({pos: vec3(-36, 8, 34), size: vec3(6, 16, 6), color: #857569, tag: "tower"})
// Nine ledges, easing outward as they rise so the last steps are the boldest.
game.box({pos: vec3(-30.1, 1.6, 36.5), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
game.box({pos: vec3(-31.3, 3.35, 40.4), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
game.box({pos: vec3(-35.6, 5.1, 42.3), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
game.box({pos: vec3(-40.4, 6.85, 41.1), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
game.box({pos: vec3(-43.6, 8.6, 37.2), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
game.box({pos: vec3(-44.0, 10.35, 32.1), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
game.box({pos: vec3(-41.4, 12.1, 27.6), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
game.box({pos: vec3(-36.6, 13.85, 25.4), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
game.box({pos: vec3(-31.4, 15.6, 26.5), size: vec3(4.2, 0.7, 4.2), color: #99855c, tag: "ledge"})
// Two gaps are bridged by moving platforms instead — the timing beats.
let plat_x = game.box({pos: vec3(-25, 6.5, 32), size: vec3(4.6, 0.7, 4.6), color: #5999cc, tag: "lift", body: "kinematic"})
let plat_y = game.box({pos: vec3(-39, 11, 45), size: vec3(4.6, 0.7, 4.6), color: #6bb385, tag: "lift", body: "kinematic"})
// The summit: wide, still, and obviously the end.
game.box({pos: vec3(-36, 16.8, 34), size: vec3(8, 1, 8), color: #d9b34d, tag: "goal"})
// A ramp at the base, so the climb starts from the ground rather than from a
// jump you have to already know is there.
game.box({pos: vec3(-28, 1, 25), size: vec3(6, 2, 10), color: #b89a6b, tag: "ramp", shape: "wedge"})
// The lifts. Different periods, so they are never the same crossing twice.
game.on_tick(|dt| {
let t = game.time()
game.set_vel(plat_x, vec3(cos(t * 0.55) * 4.2, 0, 0))
game.set_vel(plat_y, vec3(0, cos(t * 0.42) * 2.6, 0))
})
game.text("hint", "WASD move · mouse look · space jump", {anchor: "top_left"})

View file

@ -1,434 +0,0 @@
//! The AI shell: which agent backend to talk to, and how an utterance gets
//! there (game.md §"AI tiers").
//!
//! Two tiers, one surface. The local judge — when this device can run one —
//! decides whether the mic even heard a request; the cloud agent is the only
//! thing that writes code. Where there is no local compute, the text box IS
//! the gate and typing goes straight through.
use crate::capability::{Capabilities, Tier};
use crate::library::{classify_deterministic, Action, Librarian, Library, Manifest};
use crate::pairing::{has_key, load_key, Provider};
use makepad_ai::agent::{Agent, SessionConfig, StatelessBackendAdapter};
use makepad_ai::backend::BackendConfig;
use makepad_ai::backends::claude::ClaudeBackend;
use makepad_ai::backends::claude_code::ClaudeCodeAgent;
use makepad_ai::backends::gemini::GeminiBackend;
use makepad_ai::backends::openai::OpenAiBackend;
use makepad_converse::filter::{FilterDecision, PassthroughFilter, TranscriptFilter};
use makepad_converse::pipeline::ConversePipeline;
/// Tools the authoring session may use. Without this the CLI is launched with
/// `--tools ""` and the agent is chat-only: it answers, sounds correct, and
/// never writes game.splash.
pub const AUTHORING_TOOLS: &[&str] = &["Read", "Write", "Edit", "Glob", "Grep"];
/// Inline settings rather than a file in `cwd`: workspace settings are ignored
/// until the user accepts a trust dialog, which no kid will ever see. Edits are
/// confined to the game dir — `../**` is denied so a game cannot rewrite the
/// library around it.
pub const PERMISSION_POLICY: &str = r#"{"permissions":{
"allow":["Read","Glob","Grep","Edit(./**)","Write(./**)"],
"deny":["Bash","Edit(../**)","Write(../**)"]}}"#;
/// The session config for authoring one game in `dir`.
pub fn authoring_session(dir: Option<String>, api: &str, model: Option<String>) -> SessionConfig {
SessionConfig {
cwd: dir,
system_prompt: Some(system_prompt(api)),
model,
allowed_tools: AUTHORING_TOOLS.iter().map(|t| t.to_string()).collect(),
permission_mode: Some("dontAsk".to_string()),
settings_json: Some(PERMISSION_POLICY.to_string()),
..Default::default()
}
}
/// A worked example is worth more than any amount of prose: this is the whole
/// idiom — build the world at the top, configure engine blocks, drive it from
/// one `on_tick` — in the shape we want back.
const WORKED_EXAMPLE: &str = r#"let SPEED = 7.0
game.sky({})
game.sun({time_of_day: 10.0})
game.terrain({size: 160, cells: 129, smooth: true, seed: 3, amp: 8})
let hero = game.character({pos: vec3(0, 6, 0), color: #4a7fd6, player: true, view: "third"})
game.label(hero, "You")
let pig = game.mover({pos: vec3(6, 6, 4), size: vec3(0.9, 0.7, 1.4), color: #ffb3c1, tag: "animal"})
game.wander(pig, {home: vec3(6, 0, 4), range: 12, speed: 2.5})
let score = 0
game.text("score", "Caught: 0", {anchor: "top_left"})
game.on_touch(|a, b| {
if game.tag(b) == "animal" {
score = score + 1
game.text("score", "Caught: " + score)
game.sfx("pickup")
game.burst(game.pos(b), {kind: "spark", count: 12})
game.remove(b)
}
})
"#;
/// The system prompt is the whole game-authoring contract: the agent writes
/// splash against the `game.*` verbs and nothing else.
///
/// The rules below are not style advice — each one is a failure the eval
/// harness caught the model making (`tools/arcade_eval`). Types coerce
/// silently in a few places, so a wrong literal produces a game that reads
/// correctly and renders an empty world; saying so up front is cheaper than
/// letting it discover that through an error loop.
pub fn system_prompt(api: &str) -> String {
format!(
"You build small 3D games for kids in the Makepad Arcade engine.\n\
You edit ONE file: game.splash. Write splash script that calls the \
`game.*` verbs below the engine owns physics, vehicles, characters, \
AI brains and race logic, so never reimplement those.\n\
Keep games short and readable. Prefer engine blocks (game.car, \
game.character, game.plane, game.wander/chase/patrol, game.checkpoint/\
race) over hand-written movement.\n\n\
RULES THAT BREAK GAMES IF IGNORED:\n\
1. Positions and sizes are `vec3(x, y, z)`. An array `[x, y, z]` is \
NOT a position it silently becomes vec3(0,0,0) and every object \
stacks at the origin.\n\
2. Colors are bare hex literals: `#ff8800`. A quoted string \
\"#ff8800\" is NOT a color. If a digit is followed by `e` or `E`, use \
the `#x` prefix (`#x2ecc71`, `#x1e1e2e`) otherwise the tokenizer \
reads it as scientific notation and the file will not parse.\n\
3. `game.terrain` needs `smooth: true` for a landscape mesh. Without \
it the engine spawns one static box per cell (cells:48 = 2304 \
entities) which is slow and can destabilise physics. Keep `cells` \
between 33 and 129.\n\
4. Only use verbs from the list below. Inventing an option name is \
warned about; inventing a verb is a hard error that stops the game.\n\
5. Budget: aim well under ~400 entities. Prefer one `game.terrain` \
over a field of boxes.\n\n\
A COMPLETE GAME LOOKS LIKE THIS:\n```\n{WORKED_EXAMPLE}```\n\n\
Verbs:\n{api}"
)
}
/// Which backend this device should use, and why — the reason is worth showing
/// in settings, because "no key" and "no CLI" need different fixes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BackendChoice {
Ready(Provider),
/// A direct-API provider is selected but has no key yet: this device can
/// still play and join, and in a hosted room it can still ASK the host's
/// agent — it just cannot create on its own.
NeedsKey(Provider),
/// Nothing configured at all.
None,
}
/// Prefer a CLI agent when one exists (it runs its own tool loop and needs no
/// key); otherwise fall back to whichever direct API has a key.
pub fn choose_backend(preferred: Option<Provider>) -> BackendChoice {
if let Some(p) = preferred {
if p == Provider::ClaudeCode && !ClaudeCodeAgent::is_available() {
// Asked for the CLI but it is not installed — say so rather than
// silently using something else.
return BackendChoice::None;
}
return if has_key(p) {
BackendChoice::Ready(p)
} else {
BackendChoice::NeedsKey(p)
};
}
if ClaudeCodeAgent::is_available() {
return BackendChoice::Ready(Provider::ClaudeCode);
}
for p in [Provider::Anthropic, Provider::OpenAi, Provider::Gemini] {
if has_key(p) {
return BackendChoice::Ready(p);
}
}
BackendChoice::None
}
/// Default models per provider: small edits should not cost flagship prices
/// (game.md: "default small edits to cheaper models").
pub fn default_model(provider: Provider) -> &'static str {
match provider {
Provider::ClaudeCode => "claude-sonnet-5",
Provider::Anthropic => "claude-sonnet-5",
Provider::OpenAi => "gpt-5",
Provider::Gemini => "gemini-2.5-flash",
}
}
pub fn build_agent(provider: Provider, model: Option<String>) -> Option<Box<dyn Agent>> {
let model = model.unwrap_or_else(|| default_model(provider).to_string());
Some(match provider {
// The CLI runs its own tool loop, so it is an Agent directly.
Provider::ClaudeCode => Box::new(ClaudeCodeAgent::new()) as Box<dyn Agent>,
// The HTTP backends are stateless; the adapter owns the session and
// executes tool calls on our side (the Quest/mobile path).
Provider::Anthropic => Box::new(StatelessBackendAdapter::new(Box::new(
ClaudeBackend::new(BackendConfig::Claude {
api_key: Some(load_key(provider)?),
oauth_token: None,
model,
}),
))) as Box<dyn Agent>,
Provider::OpenAi => Box::new(StatelessBackendAdapter::new(Box::new(
OpenAiBackend::new(BackendConfig::OpenAI {
api_key: load_key(provider)?,
model,
base_url: None,
reasoning_effort: None,
}),
))) as Box<dyn Agent>,
Provider::Gemini => Box::new(StatelessBackendAdapter::new(Box::new(
GeminiBackend::new(BackendConfig::Gemini {
api_key: load_key(provider)?,
model,
}),
))) as Box<dyn Agent>,
})
}
/// Build the conversational pipeline for this device's tier.
///
/// The filter is constructed ON the worker thread (a local LLM session is not
/// Send), which is why this takes capabilities rather than a built filter.
pub fn build_pipeline(
agent: Box<dyn Agent>,
caps: &Capabilities,
voice: &str,
) -> ConversePipeline {
let tier = caps.tier();
#[cfg(feature = "local-llm")]
let qwen = caps.qwen_model.clone();
let make_filter = move || -> Box<dyn TranscriptFilter> {
#[cfg(feature = "local-llm")]
{
if matches!(tier, Tier::Voice) {
if let Some(path) = qwen {
if let Some(f) = makepad_converse::qwen_filter::QwenFilter::new(&path) {
return Box::new(f);
}
}
}
}
// No judge available: every transcript passes. Safe because in this
// tier the mic is push-to-talk, so the human is the gate.
let _ = tier;
Box::new(PassthroughFilter)
};
ConversePipeline::new(agent, make_filter, voice)
}
/// Route one utterance. Voice goes through the pipeline's filter; typed text
/// bypasses it (typing IS the gate) — but BOTH go through the librarian first,
/// so "play the racing game" never costs a cloud call.
pub struct Router {
pub tier: Tier,
}
impl Router {
/// Returns the action; `Action::AskAgent` means it must reach the cloud.
pub fn route(
&self,
utterance: &str,
library: &Library,
current: Option<&Manifest>,
librarian: Option<&mut dyn Librarian>,
) -> Action {
crate::library::route(utterance, library, current, librarian)
}
}
/// A transcript filter that defers to the deterministic librarian: anything
/// the librarian can answer locally is SKIPped from the agent's point of view,
/// because it never needs to reach the cloud at all.
pub struct LibrarianGate {
pub library: Library,
pub current: Option<Manifest>,
}
impl TranscriptFilter for LibrarianGate {
fn judge(&mut self, utterance: &str, _recent_dialog: &[String]) -> FilterDecision {
match classify_deterministic(utterance, &self.library, self.current.as_ref()) {
Action::AskAgent(text) => FilterDecision::Forward { instruction: text },
// Handled on-device; the cloud never sees it.
other => FilterDecision::Drop {
reason: format!("handled locally: {other:?}"),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_key_is_reported_not_silently_swapped() {
let _guard = crate::pairing::tests::KEY_STORE_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
// Point the key store at an empty dir so nothing is configured.
let dir = std::env::temp_dir().join(format!("arcade-ai-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("ARCADE_CONFIG_DIR", &dir);
for var in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"] {
std::env::remove_var(var);
}
assert_eq!(
choose_backend(Some(Provider::Anthropic)),
BackendChoice::NeedsKey(Provider::Anthropic)
);
std::env::set_var("ANTHROPIC_API_KEY", "sk-test");
assert_eq!(
choose_backend(Some(Provider::Anthropic)),
BackendChoice::Ready(Provider::Anthropic)
);
std::env::remove_var("ANTHROPIC_API_KEY");
std::fs::remove_dir_all(&dir).ok();
std::env::remove_var("ARCADE_CONFIG_DIR");
}
#[test]
fn the_librarian_gate_keeps_local_requests_off_the_wire() {
let library = Library {
games: vec![crate::library::GameEntry {
dir: std::path::PathBuf::from("/g/racing"),
manifest: Manifest {
name: "racing".into(),
description: "cars on a track".into(),
players: 4,
knobs: vec![],
},
}],
};
let mut gate = LibrarianGate { library, current: None };
assert!(matches!(
gate.judge("play racing", &[]),
FilterDecision::Drop { .. }
));
assert!(matches!(
gate.judge("restart", &[]),
FilterDecision::Drop { .. }
));
match gate.judge("add a giant ramp over the lake", &[]) {
FilterDecision::Forward { instruction } => assert!(instruction.contains("ramp")),
other => panic!("creative work must reach the agent, got {other:?}"),
}
}
#[test]
fn system_prompt_carries_the_verb_table() {
let p = system_prompt(&makepad_game_script::api_text());
assert!(p.contains("game.car"), "prompt must list the blocks");
assert!(p.contains("game.checkpoint"));
}
}
#[cfg(test)]
mod pair_endpoint_tests {
use crate::pair_server::{PairEvent, PairServer};
use crate::pairing::{load_key, PairError, Pairing, Provider};
use std::io::Write;
use std::net::TcpStream;
use std::time::{Duration, Instant};
/// Drive the real /pair endpoint over HTTP (not a browser): the right code
/// stores a key, the wrong one stores nothing.
///
/// The server only answers while we poll it, so the client must not wait
/// for a response — write, then pump until the event lands.
#[test]
fn pair_endpoint_accepts_the_right_code_and_refuses_the_wrong_one() {
let _guard = crate::pairing::tests::KEY_STORE_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("arcade-pair-http-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("ARCADE_CONFIG_DIR", &dir);
std::env::remove_var("OPENAI_API_KEY");
// The server cannot report its bound port back (the app shows a fixed
// one), so claim a free port first and hand it over.
let port = {
let probe = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
probe.local_addr().unwrap().port()
};
let mut server = PairServer::start(Provider::OpenAi, 4242, port).expect("pair server");
let code = server.pairing.code.clone();
std::thread::sleep(Duration::from_millis(300));
// Hold the connection open across the pump: dropping it early races
// the server's body read.
let conn = post(port, "code=0000&key=sk-should-not-store");
let events = pump(&mut server);
drop(conn);
assert_eq!(
events,
vec![PairEvent::Rejected(PairError::WrongCode)],
"wrong code must be rejected"
);
assert_eq!(load_key(Provider::OpenAi), None, "a wrong code stored a key");
let conn = post(port, &format!("code={code}&key=sk-correct-value"));
let events = pump(&mut server);
drop(conn);
assert_eq!(events, vec![PairEvent::Stored(Provider::OpenAi)]);
assert_eq!(
load_key(Provider::OpenAi).as_deref(),
Some("sk-correct-value")
);
std::fs::remove_dir_all(&dir).ok();
std::env::remove_var("ARCADE_CONFIG_DIR");
}
/// Write the request and hand back the live socket; the response needs our
/// own poll loop, so the caller must hold this until after pumping.
fn post(port: u16, body: &str) -> TcpStream {
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.set_write_timeout(Some(Duration::from_secs(2))).ok();
// Headers and body go in SEPARATE writes on purpose: the server's
// header parser wraps the socket in a BufReader, which swallows any
// body bytes that arrive in the same segment (platform bug, noted in
// the M4 report) — browsers happen to split them, so this mirrors a
// real client rather than papering over it.
let head = format!(
"POST /pair HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
s.write_all(head.as_bytes()).unwrap();
s.flush().unwrap();
std::thread::sleep(Duration::from_millis(120));
s.write_all(body.as_bytes()).unwrap();
s.flush().unwrap();
s
}
fn pump(server: &mut PairServer) -> Vec<PairEvent> {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let events = server.poll();
if !events.is_empty() {
return events;
}
if Instant::now() > deadline {
return events;
}
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn the_pairing_page_is_served_and_self_contained() {
let p = Pairing::new(Provider::Anthropic, 7);
let html = p.page_html();
// No external requests: a key must never transit a third party.
assert!(!html.contains("http://") && !html.contains("https://"), "{html}");
assert!(html.contains("action=/pair"));
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,260 +0,0 @@
//! The bridge from the script's audio queue to the synth.
//!
//! `game_script` never touches a synth — it queues [`AudioRequest`]s and the
//! host decides what they sound like (that is the hook that lets the crate
//! stay free of an audio dependency). This module is Arcade's half: drain the
//! queue each frame, resolve positional requests against the local listener,
//! and push voices.
//!
//! Positional audio is Local tier by construction (game.md): the listener is
//! *this* device's camera, so two players in the same room hear the same game
//! differently and none of it reaches the wire.
use crate::synth;
use makepad_game_script::audio3d::{place, Listener};
use makepad_game_script::dispatch::{AudioRequest, ToneWave};
fn wave(w: ToneWave) -> synth::Wave {
match w {
ToneWave::Sine => synth::Wave::Sine,
ToneWave::Square => synth::Wave::Square,
ToneWave::Saw => synth::Wave::Saw,
ToneWave::Triangle => synth::Wave::Triangle,
ToneWave::Noise => synth::Wave::Noise,
}
}
/// Play one request. Returns false when a named sound was not in the bank —
/// the caller logs it, because a silent typo costs an agent a whole test cycle.
pub fn play(request: &AudioRequest, listener: &Listener) -> bool {
match request {
AudioRequest::Sfx { name, pitch } => synth::play_named(name, *pitch),
AudioRequest::SfxAt {
name,
pitch,
at,
range,
} => {
let placement = place(listener, *at, *range);
// Out of range: nothing is queued at all, so a busy world does not
// burn its 24 voices on sounds the player cannot hear.
if placement.gain <= 0.0 {
return true;
}
synth::play_named_at(name, *pitch, placement.gain, placement.pan)
}
AudioRequest::Beep {
freq,
to,
ms,
wave: w,
gain,
} => {
synth::beep(*freq, *to, *ms / 1000.0, wave(*w), *gain, 0.0);
true
}
AudioRequest::Jingle { notes, ms } => {
synth::jingle(notes, *ms / 1000.0, synth::Wave::Triangle, 0.22);
true
}
AudioRequest::Tone {
id,
freq,
wave: w,
gain,
} => {
synth::tone(*id, *freq, wave(*w), *gain);
true
}
AudioRequest::ToneSet { id, freq, gain } => {
synth::tone_set(*id, *freq, *gain);
true
}
AudioRequest::ToneStop { id } => {
synth::tone_stop(*id);
true
}
AudioRequest::StopAllTones => {
synth::stop_all_tones();
true
}
}
}
/// Drain a frame's worth of requests. Unknown names are collected rather than
/// logged here, so the caller can route them wherever it routes diagnostics.
pub fn play_all(requests: &[AudioRequest], listener: &Listener) -> Vec<String> {
let mut unknown = Vec::new();
for request in requests {
if !play(request, listener) {
let name = match request {
AudioRequest::Sfx { name, .. } | AudioRequest::SfxAt { name, .. } => name.clone(),
_ => continue,
};
unknown.push(name);
}
}
unknown
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_widgets::makepad_platform::audio::AudioBuffer;
use makepad_widgets::*;
fn v(x: f32, y: f32, z: f32) -> Vec3f {
Vec3f { x, y, z }
}
fn buffer() -> AudioBuffer {
let mut b = AudioBuffer::new_with_size(256, 2);
b.zero();
b
}
fn peaks(buf: &AudioBuffer) -> (f32, f32) {
let p = |c: usize| {
buf.channel(c)
.iter()
.fold(0.0f32, |acc, s| acc.max(s.abs()))
};
(p(0), p(1))
}
/// Facing -z at the origin, so +x is to the listener's right.
fn listener() -> Listener {
Listener::from_yaw(v(0.0, 0.0, 0.0), 0.0)
}
fn render(requests: &[AudioRequest]) -> (f32, f32) {
synth::reset();
play_all(requests, &listener());
let mut buf = buffer();
synth::mix_into(&mut buf, 44100.0);
let out = peaks(&buf);
synth::reset();
out
}
#[test]
fn a_queued_sfx_reaches_the_mixer() {
let _guard = synth::SYNTH_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let (l, r) = render(&[AudioRequest::Sfx {
name: "jump".into(),
pitch: 1.0,
}]);
assert!(l > 0.0 && r > 0.0, "2D sfx is audible in both channels");
assert_eq!(l, r, "and centred");
}
#[test]
fn sfx_at_pans_by_direction_and_fades_with_distance() {
let _guard = synth::SYNTH_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let at = |x: f32, z: f32| AudioRequest::SfxAt {
name: "jump".into(),
pitch: 1.0,
at: v(x, 0.0, z),
range: 40.0,
};
let (l, r) = render(&[at(10.0, 0.0)]);
assert!(r > l, "a sound to the right is louder on the right");
let (l, r) = render(&[at(-10.0, 0.0)]);
assert!(l > r, "and mirrored on the left");
// Straight ahead is centred; further away is quieter.
let (near_l, near_r) = render(&[at(0.0, -4.0)]);
assert_eq!(near_l, near_r, "dead ahead is centred");
let (far_l, _) = render(&[at(0.0, -30.0)]);
assert!(far_l < near_l, "far {far_l} must be quieter than near {near_l}");
}
#[test]
fn a_sound_past_its_range_queues_no_voice_at_all() {
let _guard = synth::SYNTH_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
synth::reset();
play_all(
&[AudioRequest::SfxAt {
name: "jump".into(),
pitch: 1.0,
at: v(0.0, 0.0, -500.0),
range: 40.0,
}],
&listener(),
);
assert_eq!(
synth::live_counts().0,
0,
"out of range must not spend a voice slot"
);
synth::reset();
}
#[test]
fn an_unknown_name_is_reported_to_the_caller() {
let _guard = synth::SYNTH_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
synth::reset();
let unknown = play_all(
&[
AudioRequest::Sfx {
name: "jump".into(),
pitch: 1.0,
},
AudioRequest::Sfx {
name: "nonsense".into(),
pitch: 1.0,
},
],
&listener(),
);
assert_eq!(unknown, vec!["nonsense".to_string()]);
synth::reset();
}
#[test]
fn tone_lifecycle_runs_through_the_queue() {
let _guard = synth::SYNTH_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
synth::reset();
play_all(
&[AudioRequest::Tone {
id: 3,
freq: 220.0,
wave: ToneWave::Saw,
gain: 0.4,
}],
&listener(),
);
assert_eq!(synth::live_counts().1, 1);
// Retune, then stop: the tone releases rather than vanishing.
play_all(
&[
AudioRequest::ToneSet {
id: 3,
freq: Some(440.0),
gain: None,
},
AudioRequest::ToneStop { id: 3 },
],
&listener(),
);
for _ in 0..40 {
let mut buf = buffer();
synth::mix_into(&mut buf, 44100.0);
}
assert_eq!(synth::live_counts().1, 0);
synth::reset();
}
}

View file

@ -1,262 +0,0 @@
//! Turning an agent's file edit into a coedit transaction.
//!
//! The agent writes `game.splash` on disk (a CLI agent does it with its own
//! tools; the HTTP backends do it through the adapter). That edit is **not**
//! the source of truth: it is a *proposal* against whatever generation the
//! turn started from. This module reads the file back, submits it to the
//! intent log, and hands out whatever the merge decided.
//!
//! Going through the log even for the local agent is the whole point — a
//! shortcut here would leave the merge path exercised only by remote authors,
//! which is exactly backwards (game.md §"Collaborative editing").
use crate::coedit::{CoeditBridge, PendingEval};
use makepad_game_net::protocol::CoeditResponse;
use std::path::{Path, PathBuf};
/// What the host should do after a proposal was merged.
#[derive(Debug, PartialEq)]
pub enum Applied {
/// The head moved: evaluate this source and hot-reload.
Reload(PendingEval),
/// Nothing to do (the edit was refused, rebased, or changed nothing) —
/// the responses say why.
Nothing,
}
pub struct Authoring {
bridge: CoeditBridge,
path: PathBuf,
/// The generation the running turn was written against. Captured when the
/// turn starts, so an edit that lands after somebody else's is correctly
/// treated as stale rather than silently clobbering them.
turn_base: u64,
}
impl Authoring {
/// Start from whatever is on disk; a missing file is an empty game.
pub fn new(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let source = std::fs::read_to_string(&path).unwrap_or_default();
let bridge = CoeditBridge::new(source);
Self {
bridge,
path,
turn_base: 0,
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn bridge(&mut self) -> &mut CoeditBridge {
&mut self.bridge
}
pub fn head_generation(&self) -> u64 {
self.bridge.head_generation()
}
/// Called when a turn begins: everything the agent writes during it is a
/// proposal against this generation.
pub fn begin_turn(&mut self) {
self.turn_base = self.bridge.head_generation();
}
/// Read the agent's edit off disk and submit it as a transaction.
///
/// Returns `Nothing` when the file is unreadable or unchanged — an agent
/// that replies without editing is normal, not an error.
pub fn submit_from_disk(&mut self, intent: &str) -> Applied {
let Ok(source) = std::fs::read_to_string(&self.path) else {
return Applied::Nothing;
};
self.bridge.submit_local(intent, self.turn_base, &source);
match self.bridge.process() {
Some(pending) => {
// The merge may have produced a source that differs from what
// the agent wrote (someone else's hunk landed too). Disk must
// match the head, or the next mtime poll would re-propose the
// agent's stale text as if it were new.
let _ = std::fs::write(&self.path, &pending.source);
Applied::Reload(pending)
}
None => Applied::Nothing,
}
}
/// A remote author's transactions arrive through the same queue; process
/// them and reload if the head moved.
pub fn process_remote(&mut self) -> Applied {
match self.bridge.process() {
Some(pending) => {
let _ = std::fs::write(&self.path, &pending.source);
Applied::Reload(pending)
}
None => Applied::Nothing,
}
}
pub fn note_eval_ok(&mut self, generation: u64) {
self.bridge.note_eval_ok(generation);
}
pub fn note_eval_error(&mut self, generation: u64, message: impl Into<String>) {
self.bridge.note_eval_error(generation, message);
}
/// Responses addressed to the local agent — eval errors it must fix,
/// rebases it must re-derive.
pub fn drain_local(&mut self) -> Vec<CoeditResponse> {
self.bridge.drain_local()
}
/// Turn one response into a line for the chat. `None` for responses that
/// are pure bookkeeping (an accept needs no announcement).
pub fn describe(response: &CoeditResponse) -> Option<String> {
match response {
CoeditResponse::Accepted { .. } => None,
CoeditResponse::EvalError {
message,
last_good_generation,
..
} => Some(format!(
"That edit didn't load, so the game is still running the last \
version that worked (v{last_good_generation}):\n{message}"
)),
CoeditResponse::Rebase { generation, .. } => Some(format!(
"Someone else changed the game while you were working \
(now at v{generation}) re-read it and try that edit again."
)),
CoeditResponse::Refused { reason } => {
Some(format!("That edit was refused: {reason:?}"))
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_game_net::endpoint::HostEvent;
use makepad_game_net::protocol::{CoeditRequest, PlayerId};
const GAME: &str = "cars {\n count: 4\n}\nrules {\n laps: 3\n}\n";
fn temp_game(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"arcade-authoring-{}-{tag}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("game.splash");
std::fs::write(&path, GAME).unwrap();
path
}
/// The load-bearing test: a typed request must reach the intent log, not
/// a private path that skips merging.
#[test]
fn an_agent_edit_becomes_a_transaction_in_the_intent_log() {
let path = temp_game("submit");
let mut authoring = Authoring::new(&path);
assert_eq!(authoring.head_generation(), 0);
authoring.begin_turn();
// The agent edits the file, exactly as a CLI agent would.
std::fs::write(&path, GAME.replace("count: 4", "count: 8")).unwrap();
let applied = authoring.submit_from_disk("more cars");
let Applied::Reload(pending) = applied else {
panic!("an edit must move the head, got {applied:?}");
};
assert_eq!(pending.generation, 1, "the log advanced by one generation");
assert!(pending.source.contains("count: 8"));
assert_eq!(
authoring.head_generation(),
1,
"the head is the log's, not the file's"
);
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn a_reply_with_no_edit_proposes_nothing() {
let path = temp_game("noedit");
let mut authoring = Authoring::new(&path);
authoring.begin_turn();
// Agent answered a question without touching the file.
assert_eq!(authoring.submit_from_disk("what is this game?"), Applied::Nothing);
assert_eq!(authoring.head_generation(), 0);
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
/// A remote author landing mid-turn must rebase the local agent, and the
/// file on disk must end up matching the head — otherwise the next mtime
/// poll would re-propose the agent's stale text.
#[test]
fn a_local_edit_that_loses_a_race_is_rebased_and_disk_follows_the_head() {
let path = temp_game("race");
let mut authoring = Authoring::new(&path);
authoring.begin_turn();
// A remote Claude edits the same region first.
authoring.bridge().absorb(
&[HostEvent::Coedit {
player: PlayerId(5),
req: CoeditRequest::Submit {
intent: "eight cars".into(),
base_generation: 0,
source: GAME.replace("count: 4", "count: 8"),
},
}],
0.0,
);
let applied = authoring.process_remote();
assert!(matches!(applied, Applied::Reload(_)), "remote edit lands");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
GAME.replace("count: 4", "count: 8"),
"disk follows the head so the watcher does not re-propose"
);
// Now the local agent's conflicting edit, written against generation 0.
std::fs::write(&path, GAME.replace("count: 4", "count: 2")).unwrap();
authoring.submit_from_disk("two cars");
let responses = authoring.drain_local();
let rebase = responses
.iter()
.find(|r| matches!(r, CoeditResponse::Rebase { .. }))
.expect("the local agent must be rebased, not silently merged");
let text = Authoring::describe(rebase).expect("a rebase is worth saying out loud");
assert!(text.contains("try that edit again"), "{text}");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn an_eval_error_comes_back_as_a_line_for_the_chat() {
let path = temp_game("evalerr");
let mut authoring = Authoring::new(&path);
authoring.note_eval_ok(0);
authoring.begin_turn();
std::fs::write(&path, GAME.replace("laps: 3", "laps: oops")).unwrap();
let Applied::Reload(pending) = authoring.submit_from_disk("longer race") else {
panic!("the edit should have been accepted before it failed to eval");
};
authoring.drain_local();
authoring.note_eval_error(pending.generation, "game.splash:5:9: expected a number");
let responses = authoring.drain_local();
let text = responses
.iter()
.find_map(Authoring::describe)
.expect("an eval error must reach the proposer");
assert!(text.contains("expected a number"), "{text}");
assert!(text.contains("last version that worked"), "{text}");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,435 +0,0 @@
//! The library browser: what is installed, what the registry offers, and the
//! install / uninstall / publish buttons between them (game.md §"Game format
//! and sharing").
//!
//! The packaging and verification live in `makepad-game-pkg`; this is the
//! surface for them. Two rules it enforces on the way through:
//!
//! - A downloaded package is verified against the digest the index promised
//! *before* it is unpacked — `Registry::download` does that, and this never
//! installs bytes that skipped it.
//! - An installed game is marked `Trust::Downloaded`, so when it runs its
//! isolate is capability-stripped. A game from a stranger is untrusted code.
use makepad_game_pkg::{
library::Library as PkgLibrary, registry::IndexEntry, PkgError, Registry,
};
use makepad_widgets::*;
use std::path::PathBuf;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.ArcadeBrowserBase = #(ArcadeBrowser::register_widget(vm))
mod.widgets.ArcadeBrowser = set_type_default() do mod.widgets.ArcadeBrowserBase{
width: Fill
height: Fill
flow: Down
spacing: 8
padding: theme.space_2
View {
width: Fill
height: Fit
flow: Right
spacing: 8
align: Align{y: 0.5}
Label {
text: "Games"
draw_text.text_style: theme.font_bold{font_size: 15}
}
View { width: Fill height: 1 }
registry_input := TextInput {
width: 190
height: 32
empty_text: "registry host:port"
draw_text.text_style.font_size: 11
}
refresh_button := Button { text: "Browse" }
publish_button := Button { text: "Publish current" }
}
status_label := Label {
text: ""
draw_text.text_style: theme.font_regular{font_size: 11}
}
list := PortalList {
width: Fill
height: Fill
Row := View {
width: Fill
height: Fit
flow: Right
spacing: 8
padding: theme.space_1
align: Align{y: 0.5}
title := Label {
width: Fill
text: ""
draw_text.text_style: theme.font_regular{font_size: 12}
}
play_button := Button { text: "Play" }
action_button := Button { text: "" }
}
}
}
}
/// One row: either something installed, or something the registry offers.
#[derive(Clone, Debug)]
pub enum Row {
Installed {
slug: String,
name: String,
description: String,
players_max: u32,
},
Available {
entry: IndexEntry,
},
}
impl Row {
fn title(&self) -> String {
match self {
Row::Installed {
name,
description,
players_max,
..
} => {
let players = if *players_max > 1 {
format!(" · up to {players_max} players")
} else {
String::new()
};
if description.is_empty() {
format!("{name}{players}")
} else {
format!("{name}{description}{players}")
}
}
Row::Available { entry } => {
let size = format!(" · {} KB", (entry.size / 1024).max(1));
if entry.description.is_empty() {
format!("{}{size}", entry.name)
} else {
format!("{}{}{size}", entry.name, entry.description)
}
}
}
}
fn action(&self) -> &'static str {
match self {
Row::Installed { .. } => "Remove",
Row::Available { .. } => "Install",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum BrowserAction {
/// The user asked to play an installed game.
Play(String),
None,
}
#[derive(Script, ScriptHook, Widget)]
pub struct ArcadeBrowser {
#[source]
source: ScriptObjectRef,
#[deref]
view: View,
#[rust]
rows: Vec<Row>,
#[rust]
initialized: bool,
#[rust]
registry_base: String,
/// Set by the app so Publish knows what "current" means.
#[rust]
current_slug: Option<String>,
}
/// Where games live. Shared with the librarian's view of the same directory.
pub fn games_root() -> PathBuf {
std::env::var("ARCADE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join("arcade-games")
})
}
impl ArcadeBrowser {
pub fn set_current_game(&mut self, slug: Option<String>) {
self.current_slug = slug;
}
fn library(&self) -> PkgLibrary {
PkgLibrary::new(games_root())
}
fn set_status(&mut self, cx: &mut Cx, text: &str) {
self.label(cx, ids!(status_label)).set_text(cx, text);
}
/// Installed games, always shown — the registry is optional, the local
/// library is not.
fn reload_installed(&mut self) {
let installed: Vec<Row> = self
.library()
.list()
.into_iter()
.map(|e| Row::Installed {
slug: e.slug,
name: e.manifest.name,
description: e.manifest.description,
players_max: e.manifest.players_max,
})
.collect();
// Keep any registry rows that are not already installed.
let slugs: Vec<String> = installed
.iter()
.filter_map(|r| match r {
Row::Installed { slug, .. } => Some(slug.clone()),
_ => None,
})
.collect();
let available: Vec<Row> = self
.rows
.drain(..)
.filter(|r| match r {
Row::Available { entry } => !slugs.contains(&entry.id),
_ => false,
})
.collect();
self.rows = installed;
self.rows.extend(available);
}
fn browse_registry(&mut self, cx: &mut Cx) {
let base = self.text_input(cx, ids!(registry_input)).text();
let base = if base.trim().is_empty() {
self.registry_base.clone()
} else {
base.trim().to_string()
};
if base.is_empty() {
self.set_status(cx, "enter a registry address first");
return;
}
self.registry_base = base.clone();
// Blocking, deliberately: a registry index is a few KB on a LAN or a
// fast CDN, and a background task here would need a whole async story
// for a button nobody holds down. If this ever serves a slow remote,
// it moves to the task pump.
match Registry::new(&base).index() {
Ok(entries) => {
let n = entries.len();
self.reload_installed();
let installed: Vec<String> = self
.rows
.iter()
.filter_map(|r| match r {
Row::Installed { slug, .. } => Some(slug.clone()),
_ => None,
})
.collect();
for entry in entries {
if !installed.contains(&entry.id) {
self.rows.push(Row::Available { entry });
}
}
self.set_status(cx, &format!("{n} game(s) in the registry"));
}
Err(e) => self.set_status(cx, &format!("registry unreachable: {e}")),
}
self.redraw(cx);
}
fn install(&mut self, cx: &mut Cx, entry: IndexEntry) {
let reg = Registry::new(&self.registry_base);
// download() verifies the digest; a mismatch never reaches the unpacker.
match reg.download(&entry).map_err(|e| e.to_string()).and_then(|bytes| {
self.library()
.install(&entry.id, &bytes)
.map_err(|e: PkgError| e.to_string())
}) {
Ok(installed) => {
self.set_status(
cx,
&format!("installed {} — runs sandboxed", installed.manifest.name),
);
self.reload_installed();
}
Err(e) => self.set_status(cx, &format!("install failed: {e}")),
}
self.redraw(cx);
}
fn uninstall(&mut self, cx: &mut Cx, slug: &str) {
match self.library().uninstall(slug) {
Ok(()) => {
self.set_status(cx, &format!("removed {slug}"));
self.reload_installed();
}
Err(e) => self.set_status(cx, &format!("could not remove {slug}: {e}")),
}
self.redraw(cx);
}
fn publish(&mut self, cx: &mut Cx) {
let Some(slug) = self.current_slug.clone() else {
self.set_status(cx, "no game loaded to publish");
return;
};
if self.registry_base.is_empty() {
self.set_status(cx, "enter a registry address first");
return;
}
let packed = match self.library().pack(&slug) {
Ok(p) => p,
Err(e) => {
self.set_status(cx, &format!("could not pack {slug}: {e}"));
return;
}
};
match Registry::new(&self.registry_base).publish(&packed) {
Ok(id) => self.set_status(cx, &format!("published as {id} ({} KB)", packed.len() / 1024)),
Err(e) => self.set_status(cx, &format!("publish failed: {e}")),
}
}
/// Drain what the user clicked. Returns Play when an installed title was
/// chosen, so the app can hand it to the ScriptHost.
pub fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) -> BrowserAction {
if self.button(cx, ids!(refresh_button)).clicked(actions) {
self.browse_registry(cx);
}
if self.button(cx, ids!(publish_button)).clicked(actions) {
self.publish(cx);
}
// Row buttons: the list groups its items' actions under its own uid, so
// this resolves which row was clicked without per-row widget ids.
let list = self.portal_list(cx, ids!(list));
let mut hit: Option<(usize, bool)> = None;
for (index, item) in list.items_with_actions(actions) {
if item.button(cx, ids!(action_button)).clicked(actions) {
hit = Some((index, true));
} else if item.button(cx, ids!(play_button)).clicked(actions) {
hit = Some((index, false));
}
}
let Some((index, is_action)) = hit else {
return BrowserAction::None;
};
let Some(row) = self.rows.get(index).cloned() else {
return BrowserAction::None;
};
match (row, is_action) {
(Row::Available { entry }, true) => self.install(cx, entry),
(Row::Installed { slug, .. }, true) => self.uninstall(cx, &slug),
// Clicking the title of something installed plays it.
(Row::Installed { slug, .. }, false) => return BrowserAction::Play(slug),
(Row::Available { .. }, false) => {}
}
BrowserAction::None
}
}
impl Widget for ArcadeBrowser {
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
if !self.initialized {
self.initialized = true;
self.reload_installed();
if self.registry_base.is_empty() {
if let Ok(base) = std::env::var("ARCADE_REGISTRY") {
self.registry_base = base;
}
}
}
let rows = self.rows.clone();
while let Some(item) = self.view.draw_walk(cx, scope, walk).step() {
if let Some(mut list) = item.borrow_mut::<PortalList>() {
list.set_item_range(cx, 0, rows.len());
while let Some(index) = list.next_visible_item(cx) {
let Some(row) = rows.get(index) else { continue };
let item = list.item(cx, index, id!(Row));
item.label(cx, ids!(title)).set_text(cx, &row.title());
item.button(cx, ids!(action_button))
.set_text(cx, row.action());
// Only something already installed can be played.
item.button(cx, ids!(play_button))
.set_visible(cx, matches!(row, Row::Installed { .. }));
item.draw_all(cx, &mut Scope::empty());
}
}
}
DrawStep::done()
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
}
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_game_pkg::registry::IndexEntry;
#[test]
fn rows_describe_themselves_for_the_list() {
let installed = Row::Installed {
slug: "speedway".into(),
name: "Speedway".into(),
description: "race, 4 cars".into(),
players_max: 4,
};
let text = installed.title();
assert!(text.contains("Speedway"));
assert!(text.contains("race, 4 cars"));
assert!(text.contains("4 players"));
assert_eq!(installed.action(), "Remove");
let available = Row::Available {
entry: IndexEntry {
id: "dogfight".into(),
name: "Dogfight".into(),
description: "planes".into(),
size: 4096,
..Default::default()
},
};
assert!(available.title().contains("Dogfight"));
assert!(available.title().contains("4 KB"));
assert_eq!(available.action(), "Install");
}
#[test]
fn a_single_player_game_does_not_advertise_a_player_count() {
let row = Row::Installed {
slug: "solo".into(),
name: "Solo".into(),
description: String::new(),
players_max: 1,
};
assert_eq!(row.title(), "Solo");
}
#[test]
fn games_root_follows_the_env_override() {
// ARCADE_HOME is what the tests and the app both use to relocate the
// library; without it we fall back to the home directory.
let root = games_root();
assert!(root.is_absolute() || std::env::var("ARCADE_HOME").is_ok());
}
}

View file

@ -1,211 +0,0 @@
//! What this device can actually run (game.md §"Per-platform capability
//! fallback").
//!
//! The local models (Silero/Whisper/Qwen) need a compute backend that does not
//! exist everywhere. ONE code path, two configurations: where the chain
//! exists, the mic is a talk-gate; where it does not, the text box IS the
//! gate and typing goes straight to the cloud agent.
use std::path::{Path, PathBuf};
/// Which local models are present and runnable on this device.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct Capabilities {
/// A GPU/CPU backend makepad-ggml can actually run local models on.
pub local_compute: bool,
/// Silero VAD weights — else the mic falls back to an RMS gate.
pub vad_model: Option<PathBuf>,
/// Whisper weights — without these there is no transcription, so no voice.
pub whisper_model: Option<PathBuf>,
/// Local judge/librarian LLM — without it, typing is the gate and the
/// librarian degrades to fuzzy name matching.
pub qwen_model: Option<PathBuf>,
/// Kokoro voice pack — else replies are text only.
pub tts_voice: Option<PathBuf>,
}
/// The tier the app actually runs in. Ordered: each tier is a superset of the
/// one below for user-visible capability.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tier {
/// mic → VAD → Whisper → local judge → agent, spoken replies.
Voice,
/// mic → (RMS or VAD) → Whisper → agent, no local judge: every utterance
/// that clears the gate costs a cloud call, so the mic is push-to-talk.
VoiceUnfiltered,
/// No local models: the text box is the talk-gate.
Chatbox,
}
impl Tier {
pub fn shows_mic(self) -> bool {
!matches!(self, Tier::Chatbox)
}
pub fn label(self) -> &'static str {
match self {
Tier::Voice => "voice (VAD + local judge)",
Tier::VoiceUnfiltered => "voice (push-to-talk, no local judge)",
Tier::Chatbox => "chatbox (no local model compute)",
}
}
}
fn find(candidates: &[PathBuf]) -> Option<PathBuf> {
candidates.iter().find(|p| p.exists()).cloned()
}
fn repo_root() -> PathBuf {
// Models live untracked at the repo root (see game.md §assets rule).
std::env::var("MAKEPAD_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
}
impl Capabilities {
/// Probe the device. Env overrides win so a test can force any tier.
pub fn detect() -> Self {
let root = repo_root();
Self {
local_compute: local_compute_available(),
vad_model: env_or(
"MAKEPAD_VAD_MODEL",
&[root.join("silero_vad.onnx")],
),
whisper_model: env_or(
"MAKEPAD_WHISPER_MODEL",
&[root.join("ggml-large-v3-turbo.bin")],
),
qwen_model: env_or(
"ARCADE_QWEN_MODEL",
&[
root.join("local/models/Qwen3.5-4B-Q5_K_M.gguf"),
root.join("local/models/Qwen3.5-9B-UD-Q4_K_XL.gguf"),
],
),
tts_voice: env_or("ARCADE_TTS_VOICE", &[root.join("bm_fable.mkvoice")]),
}
}
pub fn tier(&self) -> Tier {
if !self.local_compute || self.whisper_model.is_none() {
return Tier::Chatbox;
}
if self.qwen_model.is_some() {
Tier::Voice
} else {
Tier::VoiceUnfiltered
}
}
/// One startup line naming the tier and exactly what is missing — the
/// difference between "voice is broken" and "you did not download X".
pub fn report(&self) -> String {
let mut missing = Vec::new();
if !self.local_compute {
missing.push("local model compute");
}
if self.whisper_model.is_none() {
missing.push("whisper weights");
}
if self.vad_model.is_none() {
missing.push("silero VAD (RMS gate instead)");
}
if self.qwen_model.is_none() {
missing.push("local judge/librarian LLM");
}
if self.tts_voice.is_none() {
missing.push("kokoro voice (text replies only)");
}
if missing.is_empty() {
format!("arcade: tier {} — all local models present", self.tier().label())
} else {
format!(
"arcade: tier {} — missing: {}",
self.tier().label(),
missing.join(", ")
)
}
}
}
fn env_or(var: &str, candidates: &[PathBuf]) -> Option<PathBuf> {
if let Ok(v) = std::env::var(var) {
if v.is_empty() {
return None; // explicit "pretend it is absent", for tests
}
let p = PathBuf::from(v);
return p.exists().then_some(p);
}
find(candidates)
}
/// Local model compute: a metal/CUDA-class backend the ggml stack can use.
/// `ARCADE_NO_LOCAL=1` forces the chatbox tier (the Quest/mobile shape) on a
/// machine that does have it.
pub fn local_compute_available() -> bool {
if std::env::var("ARCADE_NO_LOCAL").is_ok() {
return false;
}
// The local-llm feature is what actually links the compute backend; on
// platforms where it is off, there is nothing to run models on.
cfg!(feature = "local-llm") && cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows"))
}
/// Where games live: one directory per game.
pub fn arcade_home() -> PathBuf {
std::env::var("ARCADE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_default();
Path::new(&home).join("arcade-games")
})
}
#[cfg(test)]
mod tests {
use super::*;
fn caps(compute: bool, whisper: bool, qwen: bool) -> Capabilities {
Capabilities {
local_compute: compute,
vad_model: None,
whisper_model: whisper.then(|| PathBuf::from("w")),
qwen_model: qwen.then(|| PathBuf::from("q")),
tts_voice: None,
}
}
#[test]
fn tier_falls_back_piece_by_piece() {
// Everything present -> full voice chain.
assert_eq!(caps(true, true, true).tier(), Tier::Voice);
// No judge -> voice still works, but every utterance costs a call.
assert_eq!(caps(true, true, false).tier(), Tier::VoiceUnfiltered);
// No whisper -> nothing to transcribe, so typing is the gate.
assert_eq!(caps(true, false, true).tier(), Tier::Chatbox);
// No compute (Quest/mobile/web) -> chatbox regardless of files.
assert_eq!(caps(false, true, true).tier(), Tier::Chatbox);
}
#[test]
fn the_text_box_exists_in_every_tier_but_the_mic_does_not() {
assert!(caps(true, true, true).tier().shows_mic());
assert!(caps(true, true, false).tier().shows_mic());
assert!(!caps(false, true, true).tier().shows_mic());
}
#[test]
fn report_names_what_is_missing() {
let r = caps(true, true, false).report();
assert!(r.contains("local judge"), "{r}");
let full = Capabilities {
local_compute: true,
vad_model: Some(PathBuf::from("v")),
whisper_model: Some(PathBuf::from("w")),
qwen_model: Some(PathBuf::from("q")),
tts_voice: Some(PathBuf::from("t")),
};
assert!(full.report().contains("all local models present"), "{}", full.report());
}
}

View file

@ -1,308 +0,0 @@
//! The conversation: what the player said, what the AI is saying back, and
//! what the running game is complaining about.
//!
//! The model is a process-global so the list widget can read it during draw
//! without threading a scope through (the same shape gamemaker uses). It holds
//! only presentation state — every edit the AI proposes goes through the
//! intent log in [`crate::authoring`], never from here.
use makepad_widgets::*;
pub static CHAT: std::sync::RwLock<ChatData> = std::sync::RwLock::new(ChatData {
messages: Vec::new(),
streaming_text: String::new(),
activity: String::new(),
is_streaming: false,
last_delta: None,
});
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum ChatRole {
User,
Assistant,
/// Engine-side trouble (a failed eval, a missing sound). Shown differently
/// because the player did not say it and the AI did not either.
System,
}
#[derive(Clone, Debug)]
pub struct ChatMessage {
pub role: ChatRole,
pub text: String,
}
pub struct ChatData {
pub messages: Vec<ChatMessage>,
pub streaming_text: String,
/// What the agent is doing right now, e.g. "Changing the game". Pinned
/// under the streaming reply so a long silent tool call still moves.
pub activity: String,
pub is_streaming: bool,
pub last_delta: Option<std::time::Instant>,
}
impl ChatData {
pub fn push(role: ChatRole, text: impl Into<String>) {
if let Ok(mut data) = CHAT.write() {
data.messages.push(ChatMessage {
role,
text: text.into(),
});
}
}
pub fn begin_stream() {
if let Ok(mut data) = CHAT.write() {
data.streaming_text.clear();
data.is_streaming = true;
}
}
pub fn push_delta(text: &str) {
if let Ok(mut data) = CHAT.write() {
data.streaming_text.push_str(text);
data.last_delta = Some(std::time::Instant::now());
}
}
/// Land the streamed reply as a message. Returns the number of items the
/// list should scroll to.
pub fn end_stream() -> usize {
let Ok(mut data) = CHAT.write() else { return 0 };
let text = std::mem::take(&mut data.streaming_text);
if !text.trim().is_empty() {
data.messages.push(ChatMessage {
role: ChatRole::Assistant,
text,
});
}
data.is_streaming = false;
data.activity.clear();
data.messages.len()
}
pub fn set_activity(text: &str) {
if let Ok(mut data) = CHAT.write() {
data.activity = text.to_string();
}
}
pub fn item_count() -> usize {
match CHAT.read() {
Ok(data) => data.messages.len() + data.is_streaming as usize,
Err(_) => 0,
}
}
}
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.ArcadeChatBase = #(ArcadeChat::register_widget(vm))
mod.widgets.ArcadeChat = set_type_default() do mod.widgets.ArcadeChatBase {
width: Fill
height: Fill
list := PortalList {
width: Fill
height: Fill
flow: Down
drag_scrolling: false
auto_tail: true
smooth_tail: true
selectable: true
User := RoundedView {
width: Fill
height: Fit
margin: Inset{top: 6 bottom: 6 left: 40 right: 4}
padding: Inset{left: 12 top: 8 right: 12 bottom: 8}
show_bg: true
draw_bg +: {
color: #x2a3a5a
radius: 8.0
}
body := Label {
width: Fill
height: Fit
text: ""
draw_text.color: #xe8eef8
draw_text.text_style: theme.font_regular{font_size: 13}
}
}
Assistant := RoundedView {
width: Fill
height: Fit
margin: Inset{top: 6 bottom: 6 left: 4 right: 40}
padding: Inset{left: 12 top: 8 right: 12 bottom: 8}
show_bg: true
draw_bg +: {
color: #x232330
radius: 8.0
}
body := Label {
width: Fill
height: Fit
text: ""
draw_text.color: #xd8dee8
draw_text.text_style: theme.font_regular{font_size: 13}
}
}
System := RoundedView {
width: Fill
height: Fit
margin: Inset{top: 6 bottom: 6 left: 4 right: 4}
padding: Inset{left: 12 top: 8 right: 12 bottom: 8}
show_bg: true
draw_bg +: {
color: #x3a2a24
radius: 8.0
}
body := Label {
width: Fill
height: Fit
text: ""
draw_text.color: #xe8c9a0
draw_text.text_style: theme.font_regular{font_size: 12}
}
}
}
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct ArcadeChat {
#[deref]
view: View,
}
impl Widget for ArcadeChat {
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let Ok(data) = CHAT.read() else {
return DrawStep::done();
};
while let Some(item) = self.view.draw_walk(cx, scope, walk).step() {
let portal = item.as_portal_list();
let Some(mut list) = portal.borrow_mut() else {
continue;
};
let msg_count = data.messages.len();
list.set_item_range(cx, 0, msg_count + data.is_streaming as usize);
while let Some(item_id) = list.next_visible_item(cx) {
// The streaming reply is a virtual item past the end.
if data.is_streaming && item_id == msg_count {
let mut text = data.streaming_text.clone();
if !data.activity.is_empty() {
if !text.is_empty() {
text.push_str("\n\n");
}
text.push_str(&data.activity);
}
let widget = list.item(cx, item_id, id!(Assistant));
widget.label(cx, ids!(body)).set_text(cx, &text);
widget.draw_all_unscoped(cx);
continue;
}
let Some(msg) = data.messages.get(item_id) else {
continue;
};
let template = match msg.role {
ChatRole::User => id!(User),
ChatRole::Assistant => id!(Assistant),
ChatRole::System => id!(System),
};
let widget = list.item(cx, item_id, template);
widget.label(cx, ids!(body)).set_text(cx, &msg.text);
widget.draw_all_unscoped(cx);
}
}
DrawStep::done()
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.view.handle_event(cx, event, scope);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The chat model is a process-global; tests must not interleave.
pub static CHAT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn clear() {
let mut data = CHAT.write().unwrap();
data.messages.clear();
data.streaming_text.clear();
data.activity.clear();
data.is_streaming = false;
}
#[test]
fn a_turn_streams_then_lands_as_one_message() {
let _guard = CHAT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear();
ChatData::push(ChatRole::User, "make it rain");
ChatData::begin_stream();
assert_eq!(ChatData::item_count(), 2, "the streaming bubble counts");
ChatData::push_delta("Adding ");
ChatData::push_delta("rain!");
assert_eq!(CHAT.read().unwrap().streaming_text, "Adding rain!");
let count = ChatData::end_stream();
assert_eq!(count, 2);
let data = CHAT.read().unwrap();
assert!(!data.is_streaming);
assert_eq!(data.messages[1].role, ChatRole::Assistant);
assert_eq!(data.messages[1].text, "Adding rain!");
drop(data);
clear();
}
#[test]
fn an_empty_reply_does_not_leave_a_blank_bubble() {
let _guard = CHAT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear();
ChatData::begin_stream();
ChatData::push_delta(" \n ");
ChatData::end_stream();
assert!(
CHAT.read().unwrap().messages.is_empty(),
"whitespace-only replies are dropped"
);
clear();
}
#[test]
fn errors_are_injected_as_system_messages() {
let _guard = CHAT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear();
ChatData::push(ChatRole::System, "game.splash:4: unknown verb 'wobble'");
let data = CHAT.read().unwrap();
assert_eq!(data.messages[0].role, ChatRole::System);
assert!(data.messages[0].text.contains("wobble"));
drop(data);
clear();
}
#[test]
fn activity_rides_along_with_the_streaming_reply() {
let _guard = CHAT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear();
ChatData::begin_stream();
ChatData::set_activity("Changing the game");
assert_eq!(CHAT.read().unwrap().activity, "Changing the game");
// Completing the turn clears it: a finished reply must not keep
// claiming the AI is still working.
ChatData::push_delta("Done!");
ChatData::end_stream();
assert!(CHAT.read().unwrap().activity.is_empty());
clear();
}
}

View file

@ -1,452 +0,0 @@
//! Bridging the room's Claudes to the intent log.
//!
//! The host's own agent is **not** privileged: `submit_local` and a remote
//! `CoeditRequest::Submit` both land in the same queue and are merged by the
//! same rules. A shortcut for local edits would leave the merge path exercised
//! only by the rarer case, which is exactly backwards — the local agent is the
//! one editing every day (game.md §"Collaborative editing").
//!
//! Routing is decided in one place, [`CoeditBridge::route`]: a response for
//! [`AuthorId::LOCAL`] goes to the local agent, anything else goes out on the
//! wire addressed to its author. Nothing about an edit is broadcast to the room.
use makepad_game_coedit::{AuthorId, CoeditHost, LeaseOutcome, Limits, Outcome, Refusal, Transaction};
use makepad_game_net::endpoint::HostEvent;
use makepad_game_net::protocol::{
CoeditChange, CoeditRefusal, CoeditRequest, CoeditResponse, PlayerId,
};
/// Remote players map to authors above [`AuthorId::LOCAL`] so a client can
/// never impersonate the host's agent by holding player id 0.
fn author_of(player: PlayerId) -> AuthorId {
AuthorId(player.0.wrapping_add(1))
}
fn player_of(author: AuthorId) -> Option<PlayerId> {
(author != AuthorId::LOCAL).then(|| PlayerId(author.0.wrapping_sub(1)))
}
fn refusal_wire(reason: Refusal) -> CoeditRefusal {
match reason {
Refusal::EmptyIntent => CoeditRefusal::EmptyIntent,
Refusal::IntentTooLong => CoeditRefusal::IntentTooLong,
Refusal::SourceTooLong => CoeditRefusal::SourceTooLong,
Refusal::UnknownBase => CoeditRefusal::UnknownBase,
Refusal::QueueFull => CoeditRefusal::QueueFull,
Refusal::NoChange => CoeditRefusal::NoChange,
}
}
/// A generation the host should evaluate and hot-reload.
#[derive(Clone, Debug, PartialEq)]
pub struct PendingEval {
pub generation: u64,
pub source: String,
}
pub struct CoeditBridge {
host: CoeditHost,
outbox: Vec<(PlayerId, CoeditResponse)>,
local: Vec<CoeditResponse>,
to_eval: Option<PendingEval>,
}
impl CoeditBridge {
pub fn new(initial_source: impl Into<String>) -> Self {
Self::with_limits(initial_source, Limits::default())
}
pub fn with_limits(initial_source: impl Into<String>, limits: Limits) -> Self {
Self {
host: CoeditHost::with_limits(initial_source, limits),
outbox: Vec::new(),
local: Vec::new(),
to_eval: None,
}
}
pub fn head_generation(&self) -> u64 {
self.host.head().number
}
pub fn head_source(&self) -> &str {
&self.host.head().source
}
/// The source the world should be running: the newest generation that
/// actually evaluated.
pub fn last_good_source(&self) -> &str {
self.host.last_good_source()
}
/// Send one response to whoever it belongs to.
fn route(&mut self, author: AuthorId, res: CoeditResponse) {
match player_of(author) {
Some(player) => self.outbox.push((player, res)),
None => self.local.push(res),
}
}
/// The host's own agent proposing an edit — same queue, same merge.
pub fn submit_local(&mut self, intent: &str, base_generation: u64, source: &str) {
self.enqueue(AuthorId::LOCAL, intent, base_generation, source);
}
fn enqueue(&mut self, author: AuthorId, intent: &str, base_generation: u64, source: &str) {
let tx = Transaction {
author,
intent: intent.to_string(),
base_generation,
source: source.to_string(),
};
if let Err(reason) = self.host.enqueue(tx) {
self.route(
author,
CoeditResponse::Refused {
reason: refusal_wire(reason),
},
);
}
}
/// Feed one pump's host events.
pub fn absorb(&mut self, events: &[HostEvent], now: f64) {
for event in events {
match event {
HostEvent::Coedit { player, req } => {
let author = author_of(*player);
match req {
CoeditRequest::GetBase => {
let head = self.host.head();
let res = CoeditResponse::Base {
generation: head.number,
source: head.source.clone(),
};
self.route(author, res);
}
CoeditRequest::Submit {
intent,
base_generation,
source,
} => self.enqueue(author, intent, *base_generation, source),
CoeditRequest::AcquireLease { region, ttl } => {
let outcome = self.host.leases().acquire(author, region, *ttl, now);
let res = match outcome {
LeaseOutcome::Granted { expires_at } => {
CoeditResponse::LeaseGranted {
region: region.clone(),
expires_at,
}
}
LeaseOutcome::Held { by, expires_at } => {
CoeditResponse::LeaseHeld {
region: region.clone(),
by: by.0,
expires_at,
}
}
LeaseOutcome::TooMany => CoeditResponse::LeaseRefused {
region: region.clone(),
},
};
self.route(author, res);
}
CoeditRequest::ReleaseLease { region } => {
self.host.leases().release(author, region);
}
}
}
HostEvent::Left { player, .. } => {
self.host.forget_author(author_of(*player));
}
_ => {}
}
}
}
/// Merge everything queued. Returns the generation to evaluate, if the head
/// moved — one eval per pump, because the world can only run one source.
pub fn process(&mut self) -> Option<PendingEval> {
let outcomes = self.host.process_queue();
for (author, outcome) in outcomes {
match outcome {
Outcome::Accepted { generation, source } => {
self.route(author, CoeditResponse::Accepted { generation });
self.to_eval = Some(PendingEval { generation, source });
}
Outcome::Rebase {
generation,
base_source,
intervening,
conflict_regions,
} => {
let intervening = intervening
.into_iter()
.map(|change| CoeditChange {
generation: change.generation,
author: change.author.0,
intent: change.intent,
hunks: change
.hunks
.into_iter()
.map(|h| (h.base_start as u32, h.removed as u32, h.added as u32))
.collect(),
})
.collect();
self.route(
author,
CoeditResponse::Rebase {
generation,
base_source,
intervening,
conflict_regions: conflict_regions as u32,
},
);
}
Outcome::Refused { reason } => self.route(
author,
CoeditResponse::Refused {
reason: refusal_wire(reason),
},
),
}
}
self.to_eval.take()
}
pub fn note_eval_ok(&mut self, generation: u64) {
self.host.note_eval_ok(generation);
}
/// The generation failed to load. The world stays on last-good and the
/// author that proposed it hears about it — the room does not.
pub fn note_eval_error(&mut self, generation: u64, message: impl Into<String>) {
let Some(report) = self.host.note_eval_error(generation, message) else {
return;
};
self.route(
report.author,
CoeditResponse::EvalError {
generation: report.generation,
message: report.message,
last_good_generation: report.last_good_generation,
},
);
}
/// Responses for remote authors, ready for `Host::send_coedit`.
pub fn drain_outbox(&mut self) -> Vec<(PlayerId, CoeditResponse)> {
std::mem::take(&mut self.outbox)
}
/// Responses for the host's own agent.
pub fn drain_local(&mut self) -> Vec<CoeditResponse> {
std::mem::take(&mut self.local)
}
}
#[cfg(test)]
mod tests {
use super::*;
const GAME: &str = "cars {\n count: 4\n}\nrules {\n laps: 3\n}\n";
fn submit_event(player: u64, base: u64, intent: &str, source: &str) -> HostEvent {
HostEvent::Coedit {
player: PlayerId(player),
req: CoeditRequest::Submit {
intent: intent.to_string(),
base_generation: base,
source: source.to_string(),
},
}
}
fn edited(source: &str, from: &str, to: &str) -> String {
source.replace(from, to)
}
#[test]
fn a_remote_submission_is_accepted_and_answered_to_its_author() {
let mut bridge = CoeditBridge::new(GAME);
let source = edited(GAME, "count: 4", "count: 8");
bridge.absorb(&[submit_event(5, 0, "more cars", &source)], 0.0);
let pending = bridge.process().expect("head moved, so the host evaluates");
assert_eq!(pending.generation, 1);
assert_eq!(pending.source, source);
let outbox = bridge.drain_outbox();
assert_eq!(outbox.len(), 1);
assert_eq!(outbox[0].0, PlayerId(5), "addressed, not broadcast");
assert_eq!(outbox[0].1, CoeditResponse::Accepted { generation: 1 });
assert!(bridge.drain_local().is_empty());
}
#[test]
fn the_local_agent_uses_the_same_queue_and_merge_as_a_remote_one() {
let mut bridge = CoeditBridge::new(GAME);
// Remote lands first; the local agent wrote against the same old base.
bridge.absorb(
&[submit_event(5, 0, "more cars", &edited(GAME, "count: 4", "count: 8"))],
0.0,
);
bridge.submit_local("longer race", 0, &edited(GAME, "laps: 3", "laps: 5"));
bridge.process();
// Disjoint regions: both edits are in the head.
assert!(bridge.head_source().contains("count: 8"));
assert!(bridge.head_source().contains("laps: 5"));
assert_eq!(bridge.head_generation(), 2);
let local = bridge.drain_local();
assert_eq!(local, vec![CoeditResponse::Accepted { generation: 2 }]);
}
#[test]
fn an_overlapping_local_edit_is_rebased_exactly_like_a_remote_one() {
let mut bridge = CoeditBridge::new(GAME);
bridge.absorb(
&[submit_event(5, 0, "eight cars", &edited(GAME, "count: 4", "count: 8"))],
0.0,
);
bridge.submit_local("two cars", 0, &edited(GAME, "count: 4", "count: 2"));
bridge.process();
let local = bridge.drain_local();
let [CoeditResponse::Rebase {
generation,
base_source,
conflict_regions,
..
}] = &local[..]
else {
panic!("the local agent must be rebased too, got {local:?}");
};
assert_eq!(*generation, 1);
assert_eq!(*conflict_regions, 1);
assert!(base_source.contains("count: 8"), "handed the new base");
}
#[test]
fn an_eval_error_reaches_the_proposer_and_nobody_else() {
let mut bridge = CoeditBridge::new(GAME);
bridge.note_eval_ok(0);
bridge.absorb(
&[submit_event(5, 0, "break it", &edited(GAME, "laps: 3", "laps: oops"))],
0.0,
);
let pending = bridge.process().unwrap();
bridge.drain_outbox();
bridge.note_eval_error(pending.generation, "game.splash:5:9: expected a number");
let outbox = bridge.drain_outbox();
assert_eq!(outbox.len(), 1, "one recipient — the author");
assert_eq!(outbox[0].0, PlayerId(5));
let CoeditResponse::EvalError {
generation,
message,
last_good_generation,
} = &outbox[0].1
else {
panic!("expected an eval error, got {:?}", outbox[0].1);
};
assert_eq!(*generation, 1);
assert_eq!(*last_good_generation, 0);
assert!(message.contains("expected a number"));
assert!(bridge.drain_local().is_empty(), "the room is not told");
assert_eq!(
bridge.last_good_source(),
GAME,
"the world keeps running the last good source"
);
}
#[test]
fn a_client_cannot_impersonate_the_local_agent() {
let mut bridge = CoeditBridge::new(GAME);
// Player 0 would collide with AuthorId::LOCAL under a naive mapping.
bridge.absorb(
&[submit_event(0, 0, "sneaky", &edited(GAME, "count: 4", "count: 9"))],
0.0,
);
bridge.process();
assert!(
bridge.drain_local().is_empty(),
"a remote submission must never be answered as local"
);
assert_eq!(bridge.drain_outbox().len(), 1);
}
#[test]
fn leases_are_granted_reported_and_dropped_when_an_author_leaves() {
let mut bridge = CoeditBridge::new(GAME);
let acquire = |player: u64| HostEvent::Coedit {
player: PlayerId(player),
req: CoeditRequest::AcquireLease {
region: "vehicles".to_string(),
ttl: 30.0,
},
};
bridge.absorb(&[acquire(1)], 100.0);
bridge.absorb(&[acquire(2)], 101.0);
let outbox = bridge.drain_outbox();
assert!(matches!(outbox[0].1, CoeditResponse::LeaseGranted { .. }));
let CoeditResponse::LeaseHeld { by, .. } = outbox[1].1 else {
panic!("second author must be told who holds it, got {:?}", outbox[1].1);
};
assert_eq!(by, author_of(PlayerId(1)).0);
bridge.absorb(
&[HostEvent::Left {
player: PlayerId(1),
reason: makepad_game_net::protocol::LeaveReason::Explicit,
}],
102.0,
);
bridge.absorb(&[acquire(2)], 103.0);
assert!(
matches!(
bridge.drain_outbox().last().map(|(_, r)| r),
Some(CoeditResponse::LeaseGranted { .. })
),
"a departed author's lease must not outlive it"
);
}
#[test]
fn a_flooding_author_is_refused_rather_than_growing_the_queue() {
let mut bridge = CoeditBridge::with_limits(
GAME,
Limits {
max_pending_per_author: 2,
..Limits::default()
},
);
for i in 0..6 {
bridge.absorb(
&[submit_event(5, 0, "spam", &format!("{GAME}// {i}\n"))],
0.0,
);
}
let refusals = bridge
.drain_outbox()
.into_iter()
.filter(|(_, res)| {
matches!(
res,
CoeditResponse::Refused {
reason: CoeditRefusal::QueueFull
}
)
})
.count();
assert_eq!(refusals, 4, "two queued, four refused with a reason");
}
}

View file

@ -1,155 +0,0 @@
//! Routing a keyless client's typed request to the host's agent
//! (game.md §"AI tiers": "in a room with a host, creation requests can still
//! route through the host's agent, so BYO-key is only needed for standalone
//! creation").
//!
//! The host owns the mic, the key and the agent. A client types a request, it
//! arrives as `Intent::Authoring`, and the host queues it for its own agent.
//! The resulting edit reloads the game host-side and reaches everyone through
//! the ordinary replication path — clients never need the source, or a key.
use makepad_game_net::endpoint::HostEvent;
use makepad_game_net::protocol::{Intent, PlayerId, MAX_AUTHORING_TEXT};
/// One client's request, ready to hand to the agent.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthoringRequest {
pub player: PlayerId,
pub text: String,
}
/// Why a request was refused. Kept explicit so the host can tell the room
/// something true rather than dropping requests silently.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IntentRefusal {
Empty,
TooLong,
TooManyPending,
}
/// Collects authoring requests arriving from clients.
///
/// Bounded on purpose: this queue is filled by unauthenticated-in-spirit peers
/// (they hold the lobby key, but a kid's tablet is not a trusted terminal), and
/// every entry it holds eventually costs a paid agent call.
pub struct AuthoringInbox {
pending: Vec<AuthoringRequest>,
max_pending: usize,
refusals: Vec<(PlayerId, IntentRefusal)>,
}
impl Default for AuthoringInbox {
fn default() -> Self {
Self::new(8)
}
}
impl AuthoringInbox {
pub fn new(max_pending: usize) -> Self {
Self {
pending: Vec::new(),
max_pending,
refusals: Vec::new(),
}
}
/// Feed one pump's host events; non-authoring events are ignored.
pub fn absorb(&mut self, events: &[HostEvent]) {
for event in events {
if let HostEvent::Intent {
player,
intent: Intent::Authoring { text },
} = event
{
self.push(*player, text);
}
}
}
fn push(&mut self, player: PlayerId, text: &str) {
let trimmed = text.trim();
if trimmed.is_empty() {
self.refusals.push((player, IntentRefusal::Empty));
return;
}
if text.len() > MAX_AUTHORING_TEXT {
self.refusals.push((player, IntentRefusal::TooLong));
return;
}
if self.pending.len() >= self.max_pending {
self.refusals.push((player, IntentRefusal::TooManyPending));
return;
}
self.pending.push(AuthoringRequest {
player,
text: trimmed.to_string(),
});
}
/// Take everything queued. The caller hands these to the agent.
pub fn drain(&mut self) -> Vec<AuthoringRequest> {
std::mem::take(&mut self.pending)
}
pub fn drain_refusals(&mut self) -> Vec<(PlayerId, IntentRefusal)> {
std::mem::take(&mut self.refusals)
}
pub fn pending_len(&self) -> usize {
self.pending.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn intent_event(player: PlayerId, text: &str) -> HostEvent {
HostEvent::Intent {
player,
intent: Intent::Authoring {
text: text.to_string(),
},
}
}
#[test]
fn authoring_intents_are_queued_for_the_agent() {
let mut inbox = AuthoringInbox::default();
inbox.absorb(&[
intent_event(PlayerId(2), " make the cars faster "),
HostEvent::Intent {
player: PlayerId(3),
intent: Intent::Respawn,
},
]);
let queued = inbox.drain();
assert_eq!(queued.len(), 1, "Respawn must not reach the agent");
assert_eq!(queued[0].player, PlayerId(2));
assert_eq!(queued[0].text, "make the cars faster");
assert!(inbox.drain().is_empty(), "drain must consume");
}
#[test]
fn empty_and_oversized_requests_are_refused_not_forwarded() {
let mut inbox = AuthoringInbox::default();
let huge = "x".repeat(MAX_AUTHORING_TEXT + 1);
inbox.absorb(&[intent_event(PlayerId(1), " "), intent_event(PlayerId(1), &huge)]);
assert!(inbox.drain().is_empty());
let refusals: Vec<_> = inbox.drain_refusals().into_iter().map(|(_, r)| r).collect();
assert_eq!(
refusals,
vec![IntentRefusal::Empty, IntentRefusal::TooLong]
);
}
#[test]
fn a_flooding_client_cannot_grow_the_queue_without_bound() {
let mut inbox = AuthoringInbox::new(2);
for i in 0..50 {
inbox.absorb(&[intent_event(PlayerId(9), &format!("request {i}"))]);
}
assert_eq!(inbox.pending_len(), 2);
assert_eq!(inbox.drain_refusals().len(), 48);
}
}

View file

@ -1,16 +0,0 @@
//! Arcade as a library, so tools can reuse the REAL authoring context.
//!
//! The eval harness (`tools/arcade_eval`) must send the same system prompt and
//! the same tool policy the app sends, or it measures a fiction. Exposing the
//! modules here is what keeps the two from drifting; the app binary keeps its
//! own `mod` declarations because `app_main!` owns the process entry point.
pub use makepad_widgets;
pub mod ai;
/// The Zelda-scale world build. Layout is pure and testable; see the module
/// docs for why it is split from realisation.
pub mod bigworld;
pub mod capability;
pub mod library;
pub mod pair_server;
pub mod pairing;

View file

@ -1,376 +0,0 @@
//! The game library and the local librarian (game.md §"AI tiers").
//!
//! A game is a directory: `game.splash` + `manifest.toml`. The local model is
//! a librarian, never a codegen: it maps an utterance onto load / restart /
//! knob-set, and everything else goes to the cloud agent. Where no local model
//! exists, fuzzy name matching covers load and restart, which is most of what
//! anyone says out loud anyway.
use std::path::{Path, PathBuf};
/// A knob the manifest declares as safely settable without codegen.
#[derive(Clone, Debug, PartialEq)]
pub struct Knob {
pub name: String,
pub value: f64,
pub min: f64,
pub max: f64,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Manifest {
pub name: String,
pub description: String,
pub players: u32,
pub knobs: Vec<Knob>,
}
impl Manifest {
/// Minimal TOML reader: `key = value`, `[knobs.<name>]` tables. The full
/// format is not worth a dependency here, and a manifest that fails to
/// parse must degrade to "a game with a name", never to an error dialog.
pub fn parse(text: &str, fallback_name: &str) -> Self {
let mut m = Manifest {
name: fallback_name.to_string(),
players: 1,
..Default::default()
};
let mut knob: Option<Knob> = None;
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
if let Some(k) = knob.take() {
m.knobs.push(k);
}
if let Some(name) = header.strip_prefix("knobs.") {
knob = Some(Knob {
name: name.trim().to_string(),
value: 0.0,
min: f64::NEG_INFINITY,
max: f64::INFINITY,
});
}
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"');
match (&mut knob, key) {
(Some(k), "value") => k.value = value.parse().unwrap_or(0.0),
(Some(k), "min") => k.min = value.parse().unwrap_or(f64::NEG_INFINITY),
(Some(k), "max") => k.max = value.parse().unwrap_or(f64::INFINITY),
(None, "name") => m.name = value.to_string(),
(None, "description") => m.description = value.to_string(),
(None, "players") => m.players = value.parse().unwrap_or(1),
_ => {}
}
}
if let Some(k) = knob.take() {
m.knobs.push(k);
}
m
}
pub fn knob(&self, name: &str) -> Option<&Knob> {
self.knobs.iter().find(|k| k.name.eq_ignore_ascii_case(name))
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct GameEntry {
pub dir: PathBuf,
pub manifest: Manifest,
}
impl GameEntry {
pub fn splash_path(&self) -> PathBuf {
self.dir.join("game.splash")
}
}
#[derive(Clone, Debug, Default)]
pub struct Library {
pub games: Vec<GameEntry>,
}
impl Library {
/// Scan `root` for game directories. Missing root is an empty library, not
/// an error — a fresh device has no games yet.
pub fn scan(root: &Path) -> Self {
let mut games = Vec::new();
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let dir = entry.path();
if !dir.join("game.splash").is_file() {
continue;
}
let fallback = dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let manifest = std::fs::read_to_string(dir.join("manifest.toml"))
.map(|t| Manifest::parse(&t, &fallback))
.unwrap_or(Manifest {
name: fallback,
players: 1,
..Default::default()
});
games.push(GameEntry { dir, manifest });
}
}
games.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
Self { games }
}
pub fn find(&self, name: &str) -> Option<&GameEntry> {
self.games
.iter()
.find(|g| g.manifest.name.eq_ignore_ascii_case(name))
}
}
/// What the librarian decided to do with an utterance.
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
Load(PathBuf),
Restart,
SetKnob { name: String, value: f64 },
/// Anything creative — only the cloud agent writes code.
AskAgent(String),
}
/// The local model, when there is one. Returning None means "no opinion",
/// which falls through to the deterministic matcher — so a flaky or absent
/// local model can never block a request, only sharpen it.
pub trait Librarian {
fn classify(&mut self, utterance: &str, library: &Library) -> Option<Action>;
}
/// No local model: fuzzy name matching for load/restart, everything else to
/// the agent.
pub struct FuzzyLibrarian;
impl Librarian for FuzzyLibrarian {
fn classify(&mut self, _utterance: &str, _library: &Library) -> Option<Action> {
None
}
}
/// Deterministic fallback, and the final arbiter for what a local model
/// returns. Word-overlap scoring over name + description.
pub fn classify_deterministic(
utterance: &str,
library: &Library,
current: Option<&Manifest>,
) -> Action {
let lower = utterance.to_lowercase();
let words: Vec<&str> = lower
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() > 2)
.collect();
if words.iter().any(|w| matches!(*w, "restart" | "again" | "reset" | "replay")) {
return Action::Restart;
}
// A knob the CURRENT game declares: "make the cars faster" -> speed.
if let Some(manifest) = current {
for knob in &manifest.knobs {
let kn = knob.name.to_lowercase();
if !words.iter().any(|w| kn.contains(*w) || w.contains(&kn)) {
continue;
}
let up = words.iter().any(|w| {
matches!(*w, "faster" | "more" | "higher" | "bigger" | "increase" | "raise")
});
let down = words.iter().any(|w| {
matches!(*w, "slower" | "less" | "lower" | "smaller" | "decrease" | "reduce")
});
if up || down {
let step = if knob.value.abs() > f64::EPSILON {
knob.value * 0.25
} else {
1.0
};
let value = (knob.value + if up { step } else { -step })
.clamp(knob.min, knob.max);
return Action::SetKnob {
name: knob.name.clone(),
value,
};
}
}
}
// "play/load the dogfight game" — only match on an explicit play verb, so
// "make a racing game" still reaches the agent rather than loading one.
let wants_load = words
.iter()
.any(|w| matches!(*w, "play" | "load" | "open" | "start" | "switch"));
if wants_load {
let mut best: Option<(usize, &GameEntry)> = None;
for game in &library.games {
let hay = format!(
"{} {}",
game.manifest.name.to_lowercase(),
game.manifest.description.to_lowercase()
);
let score = words.iter().filter(|w| hay.contains(**w)).count();
if score > 0 && best.map(|(b, _)| score > b).unwrap_or(true) {
best = Some((score, game));
}
}
if let Some((_, game)) = best {
return Action::Load(game.splash_path());
}
}
Action::AskAgent(utterance.to_string())
}
/// Route an utterance: ask the local model if there is one, else (and on any
/// "no opinion") fall back to the deterministic matcher.
pub fn route(
utterance: &str,
library: &Library,
current: Option<&Manifest>,
librarian: Option<&mut dyn Librarian>,
) -> Action {
if let Some(l) = librarian {
if let Some(action) = l.classify(utterance, library) {
return action;
}
}
classify_deterministic(utterance, library, current)
}
#[cfg(test)]
mod tests {
use super::*;
fn library() -> Library {
Library {
games: vec![
GameEntry {
dir: PathBuf::from("/games/racing"),
manifest: Manifest {
name: "racing".into(),
description: "drive cars around an oval track".into(),
players: 4,
knobs: vec![Knob {
name: "speed".into(),
value: 20.0,
min: 5.0,
max: 60.0,
}],
},
},
GameEntry {
dir: PathBuf::from("/games/dogfight"),
manifest: Manifest {
name: "dogfight".into(),
description: "fly planes and shoot each other".into(),
players: 6,
knobs: vec![],
},
},
],
}
}
#[test]
fn manifest_parses_fields_and_knobs() {
let m = Manifest::parse(
r#"
name = "racing"
description = "drive cars"
players = 4
[knobs.speed]
value = 20.0
min = 5.0
max = 60.0
[knobs.gravity]
value = 18.0
"#,
"fallback",
);
assert_eq!(m.name, "racing");
assert_eq!(m.players, 4);
assert_eq!(m.knobs.len(), 2);
assert_eq!(m.knob("speed").unwrap().max, 60.0);
// An unparsable manifest still yields a usable game.
let empty = Manifest::parse("!!! not toml", "my-game");
assert_eq!(empty.name, "my-game");
}
#[test]
fn load_by_description_not_just_name() {
let lib = library();
assert_eq!(
route("play the one with the planes", &lib, None, None),
Action::Load(PathBuf::from("/games/dogfight/game.splash"))
);
assert_eq!(
route("load racing", &lib, None, None),
Action::Load(PathBuf::from("/games/racing/game.splash"))
);
}
#[test]
fn creative_requests_reach_the_agent() {
let lib = library();
// "make a racing game" must NOT load the existing racing game.
let a = route("make a new racing game with boats", &lib, None, None);
assert!(matches!(a, Action::AskAgent(_)), "{a:?}");
let a = route("add a jump ramp", &lib, None, None);
assert!(matches!(a, Action::AskAgent(_)), "{a:?}");
}
#[test]
fn knob_writes_are_parameter_edits_and_clamp() {
let lib = library();
let current = lib.find("racing").unwrap().manifest.clone();
assert_eq!(
route("make the speed faster", &lib, Some(&current), None),
Action::SetKnob { name: "speed".into(), value: 25.0 }
);
let mut slow = current.clone();
slow.knobs[0].value = 6.0;
// Clamped to the manifest's declared floor, never below.
assert_eq!(
route("speed slower", &lib, Some(&slow), None),
Action::SetKnob { name: "speed".into(), value: 5.0 }
);
}
#[test]
fn restart_is_recognised_without_a_model() {
let lib = library();
assert_eq!(route("restart please", &lib, None, None), Action::Restart);
}
#[test]
fn a_local_model_can_override_but_no_opinion_falls_through() {
struct Stub(Option<Action>);
impl Librarian for Stub {
fn classify(&mut self, _u: &str, _l: &Library) -> Option<Action> {
self.0.clone()
}
}
let lib = library();
let mut stub = Stub(Some(Action::Restart));
assert_eq!(
route("anything at all", &lib, None, Some(&mut stub)),
Action::Restart
);
// No opinion -> deterministic matcher still routes correctly.
let mut quiet = Stub(None);
let a = route("build me a maze", &lib, None, Some(&mut quiet));
assert!(matches!(a, Action::AskAgent(_)), "{a:?}");
}
}

View file

@ -1,575 +0,0 @@
// Makepad Arcade — networked AI game sandbox. Plan: repo-root game.md.
pub use makepad_widgets;
pub mod ai;
pub mod arcade_view;
pub mod audio;
pub mod bigworld;
pub mod authoring;
pub mod browser;
pub mod capability;
pub mod chat;
pub mod coedit;
pub mod intent;
pub mod library;
pub mod pair_server;
pub mod pairing;
pub mod settings;
pub mod synth;
pub mod xr_input;
use crate::authoring::{Applied, Authoring};
use crate::capability::{arcade_home, Capabilities};
use crate::chat::{ChatData, ChatRole};
// Named rather than glob-imported: `makepad_ai::*` and the widgets prelude
// both re-export a `makepad_widgets`, and the collision is a warning.
use makepad_ai::agent::{Agent, AgentEvent, PromptId, SessionId};
use makepad_widgets::*;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
use mod.widgets.ArcadeView
use mod.widgets.ArcadeSettings
use mod.widgets.ArcadeBrowser
use mod.widgets.ArcadeChat
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.inner_size: vec2(1360, 860)
window.title: "Makepad Arcade"
// Push-to-talk on Escape is set from Rust in handle_startup:
// without the `voice` feature the caption bar's VoiceWave is a
// stub View, and naming its properties here is a script error.
body +: {
flow: Down
show_bg: true
draw_bg.color: #x0b0d14
split_view := Splitter {
width: Fill
height: Fill
axis: SplitterAxis.Horizontal
align: SplitterAlign.FromA(420.0)
size: 8.0
a: View {
width: Fill
height: Fill
flow: Down
spacing: 8
padding: Inset{left: 12 top: 10 right: 8 bottom: 10}
View {
width: Fill
height: Fit
flow: Right
spacing: 8
align: Align{y: 0.5}
Label {
text: "Arcade"
draw_text.color: #xb9c4d6
draw_text.text_style: theme.font_regular{font_size: 14}
}
View { width: Fill height: 1 }
games_button := Button { text: "Games" }
settings_button := Button { text: "Settings" }
}
// Hidden until asked for: this is a game, not a
// control panel.
settings_panel := ArcadeSettings { visible: false }
browser_panel := ArcadeBrowser { visible: false }
chat := ArcadeChat {}
View {
width: Fill
height: Fit
flow: Right
spacing: 6
align: Align{y: 1.0}
// No `voice_wave` node here on purpose: the
// Window already declares one in its caption
// bar, and a second with the same id breaks
// ids!(voice_wave) and spawns a second worker.
input := TextInput {
width: Fill
height: 42
empty_text: "Ask for a game..."
}
send_button := Button { text: "Go" }
stop_button := Button { text: "Stop" visible: false }
}
status_label := Label {
width: Fill
height: Fit
text: "Starting up..."
draw_text.color: #x8fa2b8
draw_text.text_style: theme.font_regular{font_size: 11}
}
}
b: View {
width: Fill
height: Fill
flow: Overlay
arcade_view := ArcadeView{}
}
}
}
}
}
}
}
/// Frames of the "still working" indicator: an agent can spend many seconds
/// inside one tool call without emitting a word.
const SPINNER: [&str; 4] = ["", "•• ", "•••", " ••"];
const SPINNER_PERIOD: f64 = 0.18;
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
settings_open: bool,
#[rust]
browser_open: bool,
#[rust]
caps: Option<Capabilities>,
#[rust]
agent: Option<Box<dyn Agent>>,
#[rust]
session_id: Option<SessionId>,
#[rust]
current_prompt: Option<PromptId>,
/// The intent log. Every edit — local agent or remote Claude — goes
/// through it; there is deliberately no faster path for the local one.
#[rust]
authoring: Option<Authoring>,
#[rust]
speech: Option<makepad_converse::SpeechOutput>,
#[rust]
next_frame: NextFrame,
#[rust]
spinner_phase: usize,
#[rust]
spinner_at: f64,
#[rust]
focus_armed: bool,
}
impl App {
fn set_status(&self, cx: &mut Cx, text: &str) {
self.ui.label(cx, ids!(status_label)).set_text(cx, text);
}
fn game_path(&self) -> std::path::PathBuf {
arcade_home().join("current").join("game.splash")
}
/// Send whatever is in the input box. The agent edits `game.splash`; the
/// resulting file is submitted to the intent log when the turn lands.
fn send_message(&mut self, cx: &mut Cx) {
let input = self.ui.text_input(cx, ids!(input));
let text = input.text();
if text.trim().is_empty() {
return;
}
ChatData::push(ChatRole::User, text.clone());
input.set_text(cx, "");
// Voice injects into whatever holds key focus, so the input must keep
// it or a spoken sentence lands nowhere.
input.set_key_focus(cx);
let (Some(agent), Some(session_id)) = (&mut self.agent, self.session_id) else {
ChatData::push(
ChatRole::System,
"No AI is connected. Open Settings to choose a provider and add a key.",
);
self.ui.redraw(cx);
return;
};
if self.current_prompt.is_some() {
// The newest instruction wins; the partial reply stays in the log.
if let Some(prompt) = self.current_prompt.take() {
agent.cancel_prompt(cx, prompt);
}
}
if let Some(authoring) = &mut self.authoring {
authoring.begin_turn();
}
ChatData::begin_stream();
ChatData::set_activity("Thinking");
self.current_prompt = Some(agent.send_prompt(cx, session_id, &text));
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, true);
if let Some(speech) = self.speech.as_mut() {
speech.unhush();
}
self.ui.redraw(cx);
}
fn cancel_request(&mut self, cx: &mut Cx) {
if let Some(speech) = self.speech.as_mut() {
speech.stop();
}
let (Some(agent), Some(prompt)) = (&mut self.agent, self.current_prompt.take()) else {
return;
};
agent.cancel_prompt(cx, prompt);
ChatData::end_stream();
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
self.set_status(cx, "Stopped.");
self.ui.redraw(cx);
}
/// Submit the agent's edit to the intent log and reload if the head moved.
fn land_edit(&mut self, cx: &mut Cx, intent: &str) {
let Some(authoring) = &mut self.authoring else {
return;
};
if let Applied::Reload(pending) = authoring.submit_from_disk(intent) {
let path = authoring.path().to_path_buf();
let generation = pending.generation;
let view = self.ui.widget(cx, ids!(arcade_view));
let error = view
.borrow_mut::<crate::arcade_view::ArcadeView>()
.and_then(|mut v| v.load_game(cx, &path));
match error {
Some(error) => {
// The world keeps running last-good; the proposer hears
// about it (and nobody else does).
if let Some(a) = &mut self.authoring {
a.note_eval_error(generation, error);
}
}
None => {
if let Some(a) = &mut self.authoring {
a.note_eval_ok(generation);
}
}
}
}
self.drain_authoring(cx);
}
/// Route coedit responses addressed to this device's agent into the chat.
fn drain_authoring(&mut self, cx: &mut Cx) {
let Some(authoring) = &mut self.authoring else {
return;
};
let lines: Vec<String> = authoring
.drain_local()
.iter()
.filter_map(Authoring::describe)
.collect();
for line in lines {
ChatData::push(ChatRole::System, line);
}
self.ui.redraw(cx);
}
fn draw_activity(&mut self, cx: &mut Cx) {
let activity = match chat::CHAT.read() {
Ok(data) => data.activity.clone(),
Err(_) => return,
};
if activity.is_empty() {
return;
}
self.set_status(cx, &format!("{} {activity}", SPINNER[self.spinner_phase]));
cx.redraw_all();
}
/// Turn a tool call into something a child can understand.
fn describe_tool(tool_name: &str, subject: &str) -> Option<String> {
let file = subject.rsplit('/').next().unwrap_or(subject);
match tool_name {
"Edit" | "Write" => Some("Changing the game".to_string()),
"Read" => Some(format!("Looking at {file}")),
"Glob" | "Grep" => Some("Looking around".to_string()),
"Bash" => Some("Working on it".to_string()),
_ => None,
}
}
}
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.button(cx, ids!(settings_button)).clicked(actions) {
self.settings_open = !self.settings_open;
self.ui
.widget(cx, ids!(settings_panel))
.set_visible(cx, self.settings_open);
self.ui.redraw(cx);
}
if self.ui.button(cx, ids!(games_button)).clicked(actions) {
self.browser_open = !self.browser_open;
self.ui
.widget(cx, ids!(browser_panel))
.set_visible(cx, self.browser_open);
self.ui.redraw(cx);
}
if self.ui.button(cx, ids!(send_button)).clicked(actions) {
self.send_message(cx);
}
if self.ui.button(cx, ids!(stop_button)).clicked(actions) {
self.cancel_request(cx);
}
if self.ui.text_input(cx, ids!(input)).returned(actions).is_some() {
self.send_message(cx);
}
// A transcript arrives as a synthetic TextInput event, which only a
// TextInput holding key focus consumes. Take focus the moment the mic
// opens, or the words are dispatched into the tree and dropped.
#[cfg(feature = "voice")]
{
let voice_wave = self.ui.voice_wave(cx, ids!(voice_wave));
for action in
actions.filter_widget_actions_cast::<VoiceWaveAction>(voice_wave.widget_uid())
{
if let VoiceWaveAction::RecordVoice(true) = action {
// Quiet the speaker while the mic is open, or Whisper
// transcribes the AI's own voice back at it.
if let Some(speech) = self.speech.as_mut() {
speech.stop();
}
self.ui.text_input(cx, ids!(input)).set_key_focus(cx);
}
}
}
let browser = self.ui.widget(cx, ids!(browser_panel));
let action = match browser.borrow_mut::<crate::browser::ArcadeBrowser>() {
Some(mut b) => b.handle_actions(cx, actions),
None => crate::browser::BrowserAction::None,
};
if let crate::browser::BrowserAction::Play(slug) = action {
// Anything the browser lists was installed from a package, so it
// runs sandboxed — a game from a stranger is untrusted code.
let path = crate::browser::games_root()
.join(&slug)
.join(makepad_game_pkg::GAME_FILE);
let view = self.ui.widget(cx, ids!(arcade_view));
let err = view
.borrow_mut::<crate::arcade_view::ArcadeView>()
.and_then(|mut v| {
v.load_game_with_trust(cx, &path, makepad_game_script::Trust::Downloaded)
});
if let Some(err) = err {
log!("arcade: {slug} failed to load: {err}");
}
self.browser_open = false;
self.ui
.widget(cx, ids!(browser_panel))
.set_visible(cx, false);
self.ui.redraw(cx);
}
}
fn handle_startup(&mut self, cx: &mut Cx) {
let caps = Capabilities::detect();
log!("{}", caps.report());
let tier = caps.tier();
// The mic only exists where the chain behind it does; the text box is
// in every tier (game.md §"Per-platform capability fallback").
if !tier.shows_mic() {
self.ui
.widget(cx, ids!(voice_wave))
.set_visible(cx, false);
} else {
// Escape is the big friendly push-to-talk key. Only meaningful
// with the `voice` feature, where VoiceWave is the real widget.
#[cfg(feature = "voice")]
{
let mut wave = self.ui.widget(cx, ids!(voice_wave));
script_apply_eval!(cx, wave, { ptt_use_escape: true });
}
}
// No explicit preference: prefer a CLI agent (no key needed), else
// whichever direct API has a key. Settings can override per device.
let backend = ai::choose_backend(None);
match &backend {
ai::BackendChoice::Ready(provider) => {
if let Some(mut agent) = ai::build_agent(*provider, None) {
let dir = self.game_path().parent().map(|p| p.to_path_buf());
if let Some(dir) = &dir {
let _ = std::fs::create_dir_all(dir);
}
let config = ai::authoring_session(
dir.map(|d| d.to_string_lossy().to_string()),
&makepad_game_script::api_text(),
None,
);
self.session_id = Some(agent.create_session(cx, config));
self.agent = Some(agent);
}
}
ai::BackendChoice::NeedsKey(p) => {
ChatData::push(
ChatRole::System,
format!("{p:?} needs an API key — open Settings to add one, or pair from another device."),
);
}
ai::BackendChoice::None => {
ChatData::push(
ChatRole::System,
"No AI backend found. Open Settings to choose a provider.",
);
}
}
// The intent log starts from whatever game is on disk.
let path = self.game_path();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
self.authoring = Some(Authoring::new(&path));
if path.exists() {
let view = self.ui.widget(cx, ids!(arcade_view));
let err = view
.borrow_mut::<crate::arcade_view::ArcadeView>()
.and_then(|mut v| v.load_game(cx, &path));
if let Some(err) = err {
log!("arcade: startup eval failed: {err}");
}
}
// Speech: the worker fills `playback`, the audio callback drains it.
// Absent voice pack -> text-only replies, which is a tier, not a fault.
let speech = caps
.tts_voice
.as_ref()
.map(|_| makepad_converse::SpeechOutput::new("bm_fable.mkvoice"));
let playback = speech.as_ref().map(|s| s.playback());
cx.audio_output(0, move |info, output| {
output.zero();
// Game SFX first, speech layered on top of it.
crate::synth::mix_into(output, info.sample_rate);
if let Some(playback) = &playback {
if let Ok(mut playback) = playback.lock() {
playback.mix_into(output, info.sample_rate);
}
}
});
self.speech = speech;
self.set_status(cx, &format!("Ready — {}", tier.label()));
self.caps = Some(caps);
self.focus_armed = true;
self.next_frame = cx.new_next_frame();
}
fn handle_audio_devices(&mut self, cx: &mut Cx, devices: &AudioDevicesEvent) {
cx.use_audio_outputs(&devices.default_output());
}
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
crate::makepad_widgets::script_mod(vm);
makepad_game_render::script_mod(vm);
crate::arcade_view::script_mod(vm);
crate::settings::script_mod(vm);
crate::browser::script_mod(vm);
crate::chat::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
if let Some(frame) = self.next_frame.is_event(event) {
if self.focus_armed {
self.focus_armed = false;
self.ui.text_input(cx, ids!(input)).set_key_focus(cx);
}
// Drain the game's audio queue against the local listener. Doing
// it here rather than in the view keeps the synth (a host concern)
// out of the engine crates.
let view = self.ui.widget(cx, ids!(arcade_view));
let drained = view
.borrow_mut::<crate::arcade_view::ArcadeView>()
.map(|mut v| v.drain_audio());
if let Some((requests, listener)) = drained {
for name in audio::play_all(&requests, &listener) {
// A silent typo costs an agent a whole test cycle.
log!("arcade: unknown sound '{name}'");
}
}
if chat::CHAT.read().map(|d| d.is_streaming).unwrap_or(false)
&& frame.time - self.spinner_at >= SPINNER_PERIOD
{
self.spinner_at = frame.time;
self.spinner_phase = (self.spinner_phase + 1) % SPINNER.len();
self.draw_activity(cx);
}
self.next_frame = cx.new_next_frame();
}
let Some(agent) = &mut self.agent else { return };
for event in agent.handle_event(cx, event) {
match event {
AgentEvent::TextDelta { text, .. } => {
ChatData::push_delta(&text);
if let Some(speech) = self.speech.as_mut() {
speech.feed(&text);
}
cx.redraw_all();
}
AgentEvent::ToolRequest {
tool_name,
tool_input,
..
} => {
if let Some(activity) = Self::describe_tool(&tool_name, &tool_input) {
ChatData::set_activity(&activity);
}
// An edit landed: propose it now so the world rebuilds
// while the agent keeps working.
if matches!(tool_name.as_str(), "Edit" | "Write") {
self.land_edit(cx, "edit");
}
}
AgentEvent::TurnComplete { .. } => {
ChatData::end_stream();
if let Some(speech) = self.speech.as_mut() {
speech.flush();
}
self.current_prompt = None;
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
// Catch a final edit the tool stream did not announce.
self.land_edit(cx, "turn");
self.set_status(cx, "Ready.");
self.ui.text_input(cx, ids!(input)).set_key_focus(cx);
cx.redraw_all();
}
AgentEvent::SessionError { error, .. } => {
ChatData::push(ChatRole::System, format!("Session error: {error}"));
cx.redraw_all();
}
AgentEvent::PromptError { prompt_id, error } => {
if self.current_prompt == Some(prompt_id) {
ChatData::end_stream();
self.current_prompt = None;
self.ui.widget(cx, ids!(stop_button)).set_visible(cx, false);
}
ChatData::push(ChatRole::System, format!("Error: {error}"));
cx.redraw_all();
}
AgentEvent::SessionReady { .. } => {}
}
}
}
}

View file

@ -1,125 +0,0 @@
//! The `/pair` HTTP endpoint: a keyboard-less device serves this on the LAN so
//! a nearby computer can paste an API key into it.
//!
//! Uses platform/network's http_server. The device shows the URL and a 4-digit
//! code; the page echoes the code back with the key. See pairing.rs for the
//! (deliberately modest) security posture.
use crate::pairing::{PairError, Pairing, Provider};
use makepad_widgets::makepad_platform::makepad_network::http_server::*;
use makepad_widgets::makepad_platform::makepad_network::{NetworkConfig, NetworkRuntime};
use std::net::SocketAddr;
use std::sync::mpsc;
pub struct PairServer {
pub pairing: Pairing,
pub addr: SocketAddr,
requests: mpsc::Receiver<HttpServerRequest>,
_runtime: NetworkRuntime,
}
/// What the last poll did, so the UI can say something true.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PairEvent {
Stored(Provider),
Rejected(PairError),
}
/// The port the device advertises: fixed, because the user has to type it.
pub const DEFAULT_PAIR_PORT: u16 = 8790;
impl PairServer {
pub fn start(provider: Provider, seed: u64, port: u16) -> Option<Self> {
let runtime = NetworkRuntime::new(NetworkConfig::default());
let (tx, rx) = mpsc::channel::<HttpServerRequest>();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
runtime.start_http_server(HttpServer {
listen_address: addr,
// A key is a few hundred bytes; anything larger is not a key.
post_max_size: 8 * 1024,
request: tx,
});
Some(Self {
pairing: Pairing::new(provider, seed),
addr,
requests: rx,
_runtime: runtime,
})
}
/// Non-blocking: drain whatever arrived since the last call.
pub fn poll(&mut self) -> Vec<PairEvent> {
let mut events = Vec::new();
while let Ok(request) = self.requests.try_recv() {
match request {
HttpServerRequest::Get { headers, response_sender } => {
let (status, body) = if headers.path == "/pair" {
(200, self.pairing.page_html())
} else {
(404, "not found".to_string())
};
send(&response_sender, status, "text/html", body.into_bytes());
}
HttpServerRequest::Post { headers, body, response } => {
if headers.path != "/pair" {
send(&response, 404, "text/plain", b"not found".to_vec());
continue;
}
let text = String::from_utf8_lossy(&body).to_string();
match self.pairing.accept(&text) {
Ok(key) => {
// Store, then report WITHOUT the key in the message.
match crate::pairing::store_key(self.pairing.provider, &key) {
Ok(()) => {
events.push(PairEvent::Stored(self.pairing.provider));
send(
&response,
200,
"text/html",
b"<h2>Connected.</h2><p>You can close this page.</p>"
.to_vec(),
);
}
Err(_) => send(
&response,
500,
"text/html",
b"<h2>Could not store the key on the device.</h2>".to_vec(),
),
}
}
Err(err) => {
events.push(PairEvent::Rejected(err));
let msg: &[u8] = match err {
PairError::WrongCode => {
b"<h2>Wrong code.</h2><p>Check the code on the device.</p>"
}
PairError::Malformed => b"<h2>Missing code or key.</h2>",
};
send(&response, 400, "text/html", msg.to_vec());
}
}
}
// Pairing speaks only GET/POST; sockets and messages are
// not part of this surface.
_ => {}
}
}
events
}
}
fn send(
sender: &mpsc::Sender<HttpServerResponse>,
status: u16,
content_type: &str,
body: Vec<u8>,
) {
let _ = sender.send(HttpServerResponse {
header: format!(
"HTTP/1.1 {status} OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
),
body,
});
}

View file

@ -1,280 +0,0 @@
//! BYO API keys, and the LAN paste flow for devices with no keyboard
//! (game.md §"AI tiers": no QR, no third-party site — the target device serves
//! the page itself and the key crosses only the local network).
//!
//! Security notes, deliberately modest and written down rather than implied:
//! - The key is stored in a file under the app config dir at mode 0600. That
//! is not a keystore; on Android/iOS this must move to the platform keystore
//! before those ship.
//! - The pairing page is plaintext HTTP on the LAN. Arcade already assumes a
//! trusted LAN (multiplayer runs on it). The 4-digit code is there to stop
//! pasting into the WRONG device in a room with several, not to stop an
//! attacker who is already on the wire.
//! - The key is never logged, never replicated to peers, and never written
//! into a game package.
use std::io::Write;
use std::path::PathBuf;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Provider {
ClaudeCode,
Anthropic,
OpenAi,
Gemini,
}
impl Provider {
pub fn label(self) -> &'static str {
match self {
Provider::ClaudeCode => "Claude Code (CLI)",
Provider::Anthropic => "Anthropic API",
Provider::OpenAi => "OpenAI API",
Provider::Gemini => "Gemini API",
}
}
/// Only the direct-HTTP providers need a key; a CLI agent carries its own
/// auth, which is why a PC with the CLI never has to pair at all.
pub fn needs_key(self) -> bool {
!matches!(self, Provider::ClaudeCode)
}
pub fn slug(self) -> &'static str {
match self {
Provider::ClaudeCode => "claude_code",
Provider::Anthropic => "anthropic",
Provider::OpenAi => "openai",
Provider::Gemini => "gemini",
}
}
pub fn from_slug(s: &str) -> Option<Self> {
Some(match s {
"claude_code" => Provider::ClaudeCode,
"anthropic" => Provider::Anthropic,
"openai" => Provider::OpenAi,
"gemini" => Provider::Gemini,
_ => return None,
})
}
}
pub fn config_dir() -> PathBuf {
if let Ok(dir) = std::env::var("ARCADE_CONFIG_DIR") {
return PathBuf::from(dir);
}
let home = std::env::var("HOME").unwrap_or_default();
PathBuf::from(home).join(".config").join("makepad-arcade")
}
fn key_path(provider: Provider) -> PathBuf {
config_dir().join(format!("{}.key", provider.slug()))
}
/// Persist a key 0600. Returns an error rather than logging the key on any
/// failure path — an error message that quotes the secret is a leak.
pub fn store_key(provider: Provider, key: &str) -> std::io::Result<()> {
let dir = config_dir();
std::fs::create_dir_all(&dir)?;
let path = key_path(provider);
let mut file = std::fs::File::create(&path)?;
file.write_all(key.trim().as_bytes())?;
file.flush()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn load_key(provider: Provider) -> Option<String> {
// An env var wins, so CI and headless runs never touch the config dir.
if let Ok(k) = std::env::var(match provider {
Provider::Anthropic => "ANTHROPIC_API_KEY",
Provider::OpenAi => "OPENAI_API_KEY",
Provider::Gemini => "GOOGLE_API_KEY",
Provider::ClaudeCode => "ARCADE_UNUSED_KEY",
}) {
if !k.is_empty() {
return Some(k);
}
}
std::fs::read_to_string(key_path(provider))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn has_key(provider: Provider) -> bool {
!provider.needs_key() || load_key(provider).is_some()
}
/// A pairing session: the device shows `code`, the browser must echo it.
pub struct Pairing {
pub code: String,
pub provider: Provider,
}
impl Pairing {
/// The code only has to be unguessable-enough for a room, and it must not
/// depend on a RNG crate — tick-derived entropy is plenty here.
pub fn new(provider: Provider, seed: u64) -> Self {
let n = (seed.wrapping_mul(2654435761) >> 16) % 10_000;
Self {
code: format!("{n:04}"),
provider,
}
}
pub fn page_html(&self) -> String {
format!(
"<!doctype html><meta name=viewport content=\"width=device-width\">\
<title>Pair Arcade</title>\
<style>body{{font:16px system-ui;margin:40px auto;max-width:30rem}}\
input{{font:inherit;width:100%;padding:.6rem;margin:.4rem 0}}\
button{{font:inherit;padding:.6rem 1.2rem}}</style>\
<h2>Connect an API key</h2>\
<p>Provider: <b>{}</b></p>\
<p>Type the 4-digit code shown on the device, then paste the key.</p>\
<form method=POST action=/pair>\
<input name=code placeholder=\"4-digit code\" inputmode=numeric autocomplete=off>\
<input name=key placeholder=\"paste API key\" autocomplete=off>\
<button type=submit>Connect</button></form>",
self.provider.label()
)
}
/// Parse `code=..&key=..` (form or query). Returns the key only when the
/// code matches, so a stray POST to the wrong device stores nothing.
pub fn accept(&self, body: &str) -> Result<String, PairError> {
let mut code = None;
let mut key = None;
for pair in body.split('&') {
let Some((k, v)) = pair.split_once('=') else {
continue;
};
let v = url_decode(v);
match k {
"code" => code = Some(v),
"key" => key = Some(v),
_ => {}
}
}
let (Some(code), Some(key)) = (code, key) else {
return Err(PairError::Malformed);
};
if code.trim() != self.code {
return Err(PairError::WrongCode);
}
let key = key.trim().to_string();
if key.is_empty() {
return Err(PairError::Malformed);
}
Ok(key)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PairError {
WrongCode,
Malformed,
}
fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
match u8::from_str_radix(hex, 16) {
Ok(b) => {
out.push(b as char);
i += 3;
}
Err(_) => {
out.push('%');
i += 1;
}
}
}
b => {
out.push(b as char);
i += 1;
}
}
}
out
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// `ARCADE_CONFIG_DIR` is process-global, so every test that touches the
/// key store must hold this or they clobber each other in parallel.
pub(crate) static KEY_STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn wrong_code_is_refused_and_stores_nothing() {
let p = Pairing::new(Provider::Anthropic, 12345);
assert_eq!(p.accept("code=0000&key=sk-test"), Err(PairError::WrongCode));
assert_eq!(p.accept("key=sk-test"), Err(PairError::Malformed));
assert_eq!(
p.accept(&format!("code={}&key=", p.code)),
Err(PairError::Malformed)
);
}
#[test]
fn right_code_yields_the_key_url_decoded() {
let p = Pairing::new(Provider::Anthropic, 999);
let body = format!("code={}&key=sk-ant%2Dabc+def", p.code);
assert_eq!(p.accept(&body).unwrap(), "sk-ant-abc def");
}
#[test]
fn code_is_four_digits() {
for seed in [0u64, 1, 7, 12345, u64::MAX] {
let p = Pairing::new(Provider::OpenAi, seed);
assert_eq!(p.code.len(), 4, "{}", p.code);
assert!(p.code.chars().all(|c| c.is_ascii_digit()));
}
}
#[test]
fn cli_providers_need_no_key() {
assert!(!Provider::ClaudeCode.needs_key());
assert!(has_key(Provider::ClaudeCode));
assert!(Provider::Anthropic.needs_key());
}
#[test]
fn keys_round_trip_at_0600_and_never_appear_in_the_page() {
let _guard = KEY_STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("arcade-key-test-{}", std::process::id()));
std::env::set_var("ARCADE_CONFIG_DIR", &dir);
store_key(Provider::OpenAi, "sk-secret-value").unwrap();
assert_eq!(load_key(Provider::OpenAi).as_deref(), Some("sk-secret-value"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.join("openai.key"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
let page = Pairing::new(Provider::OpenAi, 1).page_html();
assert!(!page.contains("sk-secret-value"));
std::fs::remove_dir_all(&dir).ok();
std::env::remove_var("ARCADE_CONFIG_DIR");
}
}

View file

@ -1,291 +0,0 @@
//! The settings panel: pick a provider and model, connect a key, and see
//! which capability tier this device actually got (game.md §"AI tiers").
//!
//! M4 wired the provider/model/key selection as API only; this is the surface
//! for it. Key entry is masked and, on a device with no comfortable keyboard,
//! the "Pair from another device" button starts the `/pair` endpoint so a
//! nearby computer can paste the key in instead.
use crate::capability::{Capabilities, Tier};
use crate::pair_server::{PairEvent, PairServer, DEFAULT_PAIR_PORT};
use crate::pairing::{self, Provider};
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.ArcadeSettingsBase = #(ArcadeSettings::register_widget(vm))
mod.widgets.ArcadeSettings = set_type_default() do mod.widgets.ArcadeSettingsBase{
width: Fill
height: Fit
flow: Down
spacing: 10
padding: theme.space_2
View {
width: Fill
height: Fit
flow: Right
spacing: 8
align: Align{y: 0.5}
Label {
text: "AI backend"
draw_text.text_style: theme.font_regular{font_size: 13}
}
View { width: Fill height: 1 }
provider_dropdown := DropDown {
width: 130
labels: ["..."]
draw_text.text_style.font_size: 11
}
model_dropdown := DropDown {
width: 150
labels: ["..."]
draw_text.text_style.font_size: 11
}
}
View {
width: Fill
height: Fit
flow: Right
spacing: 8
align: Align{y: 0.5}
key_input := TextInput {
width: Fill
height: 38
is_password: true
empty_text: "paste API key"
}
save_key_button := Button { text: "Save" }
pair_button := Button { text: "Pair from another device" }
}
tier_label := Label {
text: "detecting..."
draw_text.text_style: theme.font_regular{font_size: 11}
}
pair_label := Label {
text: ""
draw_text.text_style: theme.font_regular{font_size: 11}
}
}
}
/// Providers offered in the picker, in menu order.
const PROVIDERS: &[Provider] = &[
Provider::ClaudeCode,
Provider::Anthropic,
Provider::OpenAi,
Provider::Gemini,
];
/// Model choices per provider. First entry is the default.
fn models_for(provider: Provider) -> &'static [&'static str] {
match provider {
Provider::ClaudeCode => &["claude-fable-5", "claude-opus-5", "claude-sonnet-5"],
Provider::Anthropic => &["claude-fable-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"],
Provider::OpenAi => &["gpt-5", "gpt-5-mini"],
Provider::Gemini => &["gemini-3-pro", "gemini-3-flash"],
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct ArcadeSettings {
#[source]
source: ScriptObjectRef,
#[deref]
view: View,
#[rust]
initialized: bool,
#[rust]
provider_index: usize,
#[rust]
model_index: usize,
#[rust]
caps: Option<Capabilities>,
/// Live only while pairing; dropped when a key lands, which stops serving.
#[rust]
pair_server: Option<PairServer>,
#[rust]
next_frame: NextFrame,
}
impl ArcadeSettings {
fn provider(&self) -> Provider {
PROVIDERS[self.provider_index.min(PROVIDERS.len() - 1)]
}
fn tier(&mut self) -> Tier {
let caps = self.caps.get_or_insert_with(Capabilities::detect);
caps.tier()
}
fn refresh_labels(&mut self, cx: &mut Cx) {
let provider = self.provider();
let models: Vec<String> = models_for(provider).iter().map(|m| m.to_string()).collect();
self.drop_down(cx, ids!(model_dropdown)).set_labels(cx, models);
self.drop_down(cx, ids!(model_dropdown))
.set_selected_item(cx, self.model_index.min(models_for(provider).len() - 1));
let tier = self.tier();
let key_state = if !provider.needs_key() {
"no key needed".to_string()
} else if pairing::has_key(provider) {
"key connected".to_string()
} else {
"no key — join/play only".to_string()
};
let text = format!("{} · {}", tier.label(), key_state);
self.label(cx, ids!(tier_label)).set_text(cx, &text);
}
fn start_pairing(&mut self, cx: &mut Cx) {
let provider = self.provider();
// Tick-derived seed: the code only has to be unguessable within a room.
let seed = (cx.seconds_since_app_start() * 1_000_000.0) as u64;
match PairServer::start(provider, seed, DEFAULT_PAIR_PORT) {
Some(server) => {
let text = format!(
"Open http://{}:{}/pair on a computer, then enter code {}",
local_ip_hint(),
server.addr.port(),
server.pairing.code
);
self.label(cx, ids!(pair_label)).set_text(cx, &text);
self.pair_server = Some(server);
// Poll the endpoint until a key lands.
self.next_frame = cx.new_next_frame();
}
None => {
self.label(cx, ids!(pair_label))
.set_text(cx, "could not start the pairing server");
}
}
}
fn poll_pairing(&mut self, cx: &mut Cx) {
let Some(server) = self.pair_server.as_mut() else {
return;
};
let events = server.poll();
let mut done = false;
for event in events {
match event {
PairEvent::Stored(provider) => {
self.label(cx, ids!(pair_label))
.set_text(cx, &format!("{} key connected", provider.label()));
done = true;
}
PairEvent::Rejected(err) => {
self.label(cx, ids!(pair_label))
.set_text(cx, &format!("rejected: {err:?}"));
}
}
}
if done {
// Stop serving the moment we have what we came for.
self.pair_server = None;
self.refresh_labels(cx);
} else {
self.next_frame = cx.new_next_frame();
}
}
}
/// Best-effort LAN address for the instructions. Not authoritative — the user
/// can read it off their router if this guesses wrong.
fn local_ip_hint() -> String {
use std::net::UdpSocket;
// Connecting a UDP socket picks the interface without sending anything.
UdpSocket::bind("0.0.0.0:0")
.and_then(|s| {
s.connect("8.8.8.8:80")?;
s.local_addr()
})
.map(|a| a.ip().to_string())
.unwrap_or_else(|_| "<this device>".to_string())
}
impl Widget for ArcadeSettings {
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
if !self.initialized {
self.initialized = true;
let labels: Vec<String> = PROVIDERS.iter().map(|p| p.label().to_string()).collect();
self.drop_down(cx.cx, ids!(provider_dropdown))
.set_labels(cx.cx, labels);
self.refresh_labels(cx.cx);
}
self.view.draw_walk(cx, scope, walk)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
if self.next_frame.is_event(event).is_some() {
self.poll_pairing(cx);
}
self.widget_match_event(cx, event, scope);
self.view.handle_event(cx, event, scope);
}
}
impl WidgetMatchEvent for ArcadeSettings {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) {
if let Some(index) = self.drop_down(cx, ids!(provider_dropdown)).changed(actions) {
self.provider_index = index;
self.model_index = 0;
self.refresh_labels(cx);
}
if let Some(index) = self.drop_down(cx, ids!(model_dropdown)).changed(actions) {
self.model_index = index;
}
if self.button(cx, ids!(save_key_button)).clicked(actions) {
let key = self.text_input(cx, ids!(key_input)).text();
let provider = self.provider();
let message = if key.trim().is_empty() {
"nothing to save".to_string()
} else {
match pairing::store_key(provider, key.trim()) {
Ok(()) => {
// Never leave the key sitting in a widget.
self.text_input(cx, ids!(key_input)).set_text(cx, "");
format!("{} key stored", provider.label())
}
Err(err) => format!("could not store key: {err}"),
}
};
self.label(cx, ids!(pair_label)).set_text(cx, &message);
self.refresh_labels(cx);
}
if self.button(cx, ids!(pair_button)).clicked(actions) {
self.start_pairing(cx);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_provider_offers_at_least_one_model() {
for provider in PROVIDERS {
assert!(
!models_for(*provider).is_empty(),
"{} has no models",
provider.label()
);
}
}
#[test]
fn provider_labels_are_distinct() {
let mut seen = Vec::new();
for provider in PROVIDERS {
assert!(!seen.contains(&provider.label()), "duplicate provider label");
seen.push(provider.label());
}
}
}

View file

@ -1,543 +0,0 @@
//! Game sound effects: a tiny polyphonic synthesizer.
//!
//! Games ship no audio assets — the art style is procedural, so the sound is
//! too: a named bank of kid-game staples plus raw `beep`/`jingle`/`tone`
//! primitives, mixed additively into the app's audio output callback.
//!
//! Ported from `examples/gamemaker/src/synth.rs`, which this deliberately does
//! not share yet: the shared home for it is `libs/game/audio`, and creating
//! that crate means editing gamemaker (tape-parity-critical) — out of this
//! task's scope. The one behavioural addition here is **stereo**: a voice
//! carries a pan, so `game.sfx_at` can actually be heard to one side. Merging
//! the two copies is a mechanical follow-up, gated by the tape.
use makepad_widgets::makepad_platform::audio::AudioBuffer;
use std::sync::Mutex;
/// Percussive envelope attack, long enough to avoid clicks.
const ATTACK_SECS: f32 = 0.004;
/// Voice cap: oldest voice is dropped, a stuck script can't build a wall of sound.
const MAX_VOICES: usize = 24;
/// Sustained voice cap — engine hums, wind, sirens. Small on purpose.
const MAX_TONES: usize = 6;
/// Parameter smoothing rate (≈30ms to target) so per-tick retuning from
/// `game.tone_set` glides instead of zipper-stepping.
const TONE_SMOOTH_RATE: f32 = 33.0;
const TONE_ATTACK_RATE: f32 = 60.0;
const TONE_RELEASE_RATE: f32 = 18.0;
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Wave {
Sine,
Square,
Saw,
Triangle,
Noise,
}
impl Wave {
pub fn parse(name: &str) -> Wave {
match name {
"sine" => Wave::Sine,
"saw" => Wave::Saw,
"triangle" | "tri" => Wave::Triangle,
"noise" => Wave::Noise,
_ => Wave::Square,
}
}
}
/// Per-channel gains for a pan in -1..1. Centre keeps full volume in both
/// channels (rather than equal-power, which would make every 2D sound
/// quieter than it is today just for having gained a pan field).
fn pan_gains(pan: f32) -> (f32, f32) {
let pan = pan.clamp(-1.0, 1.0);
(1.0 - pan.max(0.0), 1.0 + pan.min(0.0))
}
struct Voice {
wave: Wave,
freq_from: f32,
freq_to: f32,
len: f32,
gain: f32,
/// -1 left .. 0 centre .. +1 right. Positional sounds set this; the 2D
/// verbs leave it at 0 and behave exactly as before.
pan: f32,
/// Seconds until the voice starts — how jingles sequence notes.
delay: f32,
t: f32,
phase: f32,
noise: u32,
}
/// A looping tone: no envelope end, retunable without retriggering. This is
/// the "car engine note" primitive one-shot beeps can't fake (60 envelope
/// restarts a second).
struct Tone {
id: u64,
wave: Wave,
freq: f32,
freq_target: f32,
gain: f32,
gain_target: f32,
/// Attack/release level; a released tone fades out and is dropped.
level: f32,
releasing: bool,
phase: f32,
noise: u32,
}
pub struct Synth {
voices: Vec<Voice>,
tones: Vec<Tone>,
}
/// Shared with the audio callback. The script side only ever pushes voices;
/// the audio thread only ever advances and drops them.
static SYNTH: Mutex<Synth> = Mutex::new(Synth {
voices: Vec::new(),
tones: Vec::new(),
});
/// Start a sustained tone under a host-minted id (`Ctx::next_tone`), so the
/// script gets its handle back synchronously from a drained queue.
pub fn tone(id: u64, freq: f32, wave: Wave, gain: f32) {
let Ok(mut synth) = SYNTH.lock() else { return };
if synth.tones.len() >= MAX_TONES {
synth.tones.remove(0);
}
synth.tones.push(Tone {
id,
wave,
freq: freq.clamp(20.0, 8000.0),
freq_target: freq.clamp(20.0, 8000.0),
gain: 0.0,
gain_target: gain.clamp(0.0, 1.0),
level: 0.0,
releasing: false,
phase: 0.0,
noise: 0x51ed_2705,
});
}
/// Retune a running tone — smoothed, never retriggered.
pub fn tone_set(id: u64, freq: Option<f32>, gain: Option<f32>) {
let Ok(mut synth) = SYNTH.lock() else { return };
if let Some(tone) = synth.tones.iter_mut().find(|t| t.id == id) {
if let Some(freq) = freq {
tone.freq_target = freq.clamp(20.0, 8000.0);
}
if let Some(gain) = gain {
tone.gain_target = gain.clamp(0.0, 1.0);
}
}
}
pub fn tone_stop(id: u64) {
let Ok(mut synth) = SYNTH.lock() else { return };
if let Some(tone) = synth.tones.iter_mut().find(|t| t.id == id) {
tone.releasing = true;
}
}
/// A rebuilt world must never inherit a stuck engine hum: the script queues
/// this on every eval/reset.
pub fn stop_all_tones() {
let Ok(mut synth) = SYNTH.lock() else { return };
for tone in synth.tones.iter_mut() {
tone.releasing = true;
}
}
/// One tone, optionally gliding from `freq` to `to` over its length.
pub fn beep(freq: f32, to: f32, secs: f32, wave: Wave, gain: f32, delay: f32) {
beep_panned(freq, to, secs, wave, gain, delay, 0.0);
}
pub fn beep_panned(
freq: f32,
to: f32,
secs: f32,
wave: Wave,
gain: f32,
delay: f32,
pan: f32,
) {
let Ok(mut synth) = SYNTH.lock() else { return };
if synth.voices.len() >= MAX_VOICES {
synth.voices.remove(0);
}
synth.voices.push(Voice {
wave,
freq_from: freq.clamp(20.0, 8000.0),
freq_to: to.clamp(20.0, 8000.0),
len: secs.clamp(0.01, 3.0),
gain: gain.clamp(0.0, 1.0),
pan: pan.clamp(-1.0, 1.0),
delay: delay.max(0.0),
t: 0.0,
phase: 0.0,
noise: 0x2f6e2b1,
});
}
/// Note names, e.g. "C4 E4 G4 C5" (sharps as "F#5"). Unknown tokens are rests,
/// so a slightly-wrong jingle still plays instead of erroring at a kid.
pub fn jingle(notes: &str, note_secs: f32, wave: Wave, gain: f32) {
let step = note_secs.clamp(0.03, 1.0);
for (index, token) in notes.split_whitespace().enumerate() {
if let Some(freq) = note_freq(token) {
beep(freq, freq, step * 0.9, wave, gain, index as f32 * step);
}
}
}
fn note_freq(token: &str) -> Option<f32> {
let bytes = token.as_bytes();
let semitone = match bytes.first()?.to_ascii_uppercase() {
b'C' => 0,
b'D' => 2,
b'E' => 4,
b'F' => 5,
b'G' => 7,
b'A' => 9,
b'B' => 11,
_ => return None,
};
let mut index = 1;
let mut sharp = 0;
if bytes.get(index) == Some(&b'#') {
sharp = 1;
index += 1;
}
let octave: i32 = token.get(index..)?.parse().ok()?;
let midi = (octave + 1) * 12 + semitone + sharp;
Some(440.0 * 2f32.powf((midi as f32 - 69.0) / 12.0))
}
/// The kid-game staple bank, at a position. `gain_scale` is the distance
/// attenuation and `pan` the direction; a 2D `sfx` passes (1.0, 0.0) and gets
/// exactly the historical sound.
pub fn play_named_at(name: &str, pitch: f32, gain_scale: f32, pan: f32) -> bool {
let p = pitch.clamp(0.25, 4.0);
let g = gain_scale.clamp(0.0, 1.0);
// Local helpers so the bank recipes stay readable and identical to the
// gamemaker originals apart from the scale/pan pass-through.
let b = |freq: f32, to: f32, secs: f32, wave: Wave, gain: f32, delay: f32| {
beep_panned(freq, to, secs, wave, gain * g, delay, pan)
};
let j = |notes: &str, secs: f32, wave: Wave, gain: f32| {
let step = secs.clamp(0.03, 1.0);
for (index, token) in notes.split_whitespace().enumerate() {
if let Some(freq) = note_freq(token) {
beep_panned(
freq,
freq,
step * 0.9,
wave,
gain * g,
index as f32 * step,
pan,
);
}
}
};
match name {
"jump" => b(260.0 * p, 540.0 * p, 0.12, Wave::Square, 0.22, 0.0),
"shoot" => b(880.0 * p, 180.0 * p, 0.09, Wave::Square, 0.20, 0.0),
"zap" => {
b(1200.0 * p, 90.0 * p, 0.18, Wave::Saw, 0.22, 0.0);
b(600.0, 600.0, 0.10, Wave::Noise, 0.12, 0.0);
}
"grab" => b(320.0 * p, 180.0 * p, 0.12, Wave::Sine, 0.25, 0.0),
"angry" => b(150.0 * p, 90.0 * p, 0.25, Wave::Square, 0.22, 0.0),
"calm" => b(390.0 * p, 520.0 * p, 0.20, Wave::Sine, 0.20, 0.0),
"rescue" => j("E5 G5", 0.09, Wave::Triangle, 0.22),
"shove" => b(200.0, 200.0, 0.06, Wave::Noise, 0.30, 0.0),
// Firework: a noise burst sweeping down into a long tail. Noise
// because a shell is broadband — a tone reads as a laser, not a bang.
"board" => b(220.0 * p, 330.0 * p, 0.11, Wave::Sine, 0.22, 0.0),
"coin" => j("B5 E6", 0.07, Wave::Triangle, 0.20),
"hurt" => b(300.0 * p, 120.0 * p, 0.15, Wave::Saw, 0.22, 0.0),
"win" => j("C5 E5 G5 C6", 0.10, Wave::Triangle, 0.22),
"lose" => j("E4 C4 A3", 0.14, Wave::Square, 0.20),
"squeak" => b(900.0 * p, 1400.0 * p, 0.08, Wave::Sine, 0.18, 0.0),
"roar" => {
b(220.0 * p, 60.0 * p, 0.5, Wave::Saw, 0.28, 0.0);
b(300.0, 300.0, 0.35, Wave::Noise, 0.14, 0.0);
}
"bark" => {
b(520.0 * p, 520.0 * p, 0.06, Wave::Square, 0.30, 0.0);
b(340.0 * p, 340.0 * p, 0.06, Wave::Square, 0.30, 0.06);
}
"moo" => b(200.0 * p, 150.0 * p, 0.35, Wave::Square, 0.18, 0.0),
"clank" => {
b(980.0 * p, 980.0 * p, 0.07, Wave::Square, 0.30, 0.0);
b(300.0 * p, 300.0 * p, 0.07, Wave::Square, 0.30, 0.07);
}
"whip" => b(420.0 * p, 1500.0 * p, 0.09, Wave::Square, 0.25, 0.0),
_ => return false,
}
true
}
/// 2D form: full volume, centred.
pub fn play_named(name: &str, pitch: f32) -> bool {
play_named_at(name, pitch, 1.0, 0.0)
}
fn wave_sample(wave: Wave, phase: f32, noise: &mut u32) -> f32 {
match wave {
Wave::Sine => (phase * std::f32::consts::TAU).sin(),
Wave::Square => {
if phase < 0.5 {
1.0
} else {
-1.0
}
}
Wave::Saw => 2.0 * phase - 1.0,
Wave::Triangle => 1.0 - 4.0 * (phase - 0.5).abs(),
Wave::Noise => {
*noise ^= *noise << 13;
*noise ^= *noise >> 17;
*noise ^= *noise << 5;
(*noise as f32 / u32::MAX as f32) * 2.0 - 1.0
}
}
}
/// Mix all live voices additively into `output`. Runs on the audio thread —
/// no allocation, one brief lock. Callers zero or fill the buffer first.
pub fn mix_into(output: &mut AudioBuffer, sample_rate: f64) {
let Ok(mut synth) = SYNTH.lock() else { return };
if synth.voices.is_empty() && synth.tones.is_empty() {
return;
}
let dt = 1.0 / sample_rate as f32;
let frames = output.frame_count();
let channels = output.channel_count();
for frame in 0..frames {
// Sustained tones are non-positional (an engine hum belongs to the
// whole scene), so they land in the centre sum.
let mut centre = 0.0f32;
let mut left = 0.0f32;
let mut right = 0.0f32;
for tone in synth.tones.iter_mut() {
tone.freq += (tone.freq_target - tone.freq) * (TONE_SMOOTH_RATE * dt).min(1.0);
tone.gain += (tone.gain_target - tone.gain) * (TONE_SMOOTH_RATE * dt).min(1.0);
if tone.releasing {
tone.level -= TONE_RELEASE_RATE * dt;
} else {
tone.level = (tone.level + TONE_ATTACK_RATE * dt).min(1.0);
}
if tone.level <= 0.0 {
continue;
}
tone.phase = (tone.phase + tone.freq * dt).fract();
let raw = wave_sample(tone.wave, tone.phase, &mut tone.noise);
centre += raw * tone.level * tone.gain;
}
for voice in synth.voices.iter_mut() {
if voice.delay > 0.0 {
voice.delay -= dt;
continue;
}
if voice.t >= voice.len {
continue;
}
let u = voice.t / voice.len;
let freq = voice.freq_from + (voice.freq_to - voice.freq_from) * u;
voice.phase = (voice.phase + freq * dt).fract();
let raw = wave_sample(voice.wave, voice.phase, &mut voice.noise);
let attack = (voice.t / ATTACK_SECS).min(1.0);
let envelope = attack * (1.0 - u) * (1.0 - u);
let sample = raw * envelope * voice.gain;
if voice.pan == 0.0 {
centre += sample;
} else {
let (gl, gr) = pan_gains(voice.pan);
left += sample * gl;
right += sample * gr;
}
voice.t += dt;
}
if centre != 0.0 || left != 0.0 || right != 0.0 {
for channel in 0..channels {
// Channel 0 is left, 1 is right; anything beyond a stereo pair
// (or a mono device) gets the unpanned sum so nothing is lost.
let sample = match channel {
0 if channels >= 2 => centre + left,
1 if channels >= 2 => centre + right,
_ => centre + left + right,
};
output.channel_mut(channel)[frame] += sample.clamp(-0.9, 0.9);
}
}
}
synth.voices.retain(|v| v.delay > 0.0 || v.t < v.len);
synth.tones.retain(|t| !(t.releasing && t.level <= 0.0));
}
/// Drop every live voice and tone. Used when a world is torn down so a new
/// game never inherits the previous one's noise.
pub fn reset() {
let Ok(mut synth) = SYNTH.lock() else { return };
synth.voices.clear();
synth.tones.clear();
}
/// Live voice + tone count, for tests and diagnostics.
pub fn live_counts() -> (usize, usize) {
match SYNTH.lock() {
Ok(synth) => (synth.voices.len(), synth.tones.len()),
Err(_) => (0, 0),
}
}
/// The synth is a process-global, so tests that assert on voice counts must
/// not interleave — including tests in the modules that drive it.
#[cfg(test)]
pub static SYNTH_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
use makepad_widgets::makepad_platform::audio::AudioBuffer;
fn buffer() -> AudioBuffer {
let mut b = AudioBuffer::new_with_size(256, 2);
b.zero();
b
}
fn peak(buf: &AudioBuffer, channel: usize) -> f32 {
buf.channel(channel)
.iter()
.fold(0.0f32, |acc, s| acc.max(s.abs()))
}
#[test]
fn a_named_sfx_produces_audible_samples() {
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset();
assert!(play_named("jump", 1.0), "jump is in the bank");
let mut buf = buffer();
mix_into(&mut buf, 44100.0);
assert!(peak(&buf, 0) > 0.0, "a played sfx must be audible");
reset();
}
#[test]
fn an_unknown_sfx_name_is_reported_not_played() {
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset();
assert!(!play_named("not-a-sound", 1.0));
assert_eq!(live_counts().0, 0, "an unknown name must queue nothing");
}
#[test]
fn pan_moves_the_sound_between_the_channels() {
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// Hard right: the right channel is louder than the left.
reset();
beep_panned(440.0, 440.0, 0.2, Wave::Square, 0.5, 0.0, 1.0);
let mut buf = buffer();
mix_into(&mut buf, 44100.0);
let (l, r) = (peak(&buf, 0), peak(&buf, 1));
assert!(r > l, "panned right: left {l} right {r}");
assert_eq!(l, 0.0, "hard right must be silent on the left");
// Hard left is the mirror image.
reset();
beep_panned(440.0, 440.0, 0.2, Wave::Square, 0.5, 0.0, -1.0);
let mut buf = buffer();
mix_into(&mut buf, 44100.0);
let (l, r) = (peak(&buf, 0), peak(&buf, 1));
assert!(l > r, "panned left: left {l} right {r}");
assert_eq!(r, 0.0);
reset();
}
#[test]
fn a_centred_sound_is_equal_and_keeps_full_volume() {
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset();
beep(440.0, 440.0, 0.2, Wave::Square, 0.5, 0.0);
let mut centred = buffer();
mix_into(&mut centred, 44100.0);
let (l, r) = (peak(&centred, 0), peak(&centred, 1));
assert_eq!(l, r, "centre must be identical in both channels");
assert!(l > 0.0);
reset();
}
#[test]
fn distance_attenuation_scales_the_bank() {
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset();
play_named_at("jump", 1.0, 1.0, 0.0);
let mut loud = buffer();
mix_into(&mut loud, 44100.0);
let near = peak(&loud, 0);
reset();
play_named_at("jump", 1.0, 0.2, 0.0);
let mut quiet = buffer();
mix_into(&mut quiet, 44100.0);
let far = peak(&quiet, 0);
assert!(far < near, "distant {far} must be quieter than near {near}");
assert!(far > 0.0, "still audible inside range");
reset();
}
#[test]
fn tones_sustain_until_stopped_and_reset_clears_them() {
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset();
tone(7, 220.0, Wave::Saw, 0.4);
let mut buf = buffer();
mix_into(&mut buf, 44100.0);
assert_eq!(live_counts().1, 1, "a tone sustains across buffers");
assert!(peak(&buf, 0) > 0.0);
tone_stop(7);
for _ in 0..40 {
let mut buf = buffer();
mix_into(&mut buf, 44100.0);
}
assert_eq!(live_counts().1, 0, "a stopped tone releases and is dropped");
tone(8, 220.0, Wave::Saw, 0.4);
stop_all_tones();
for _ in 0..40 {
let mut buf = buffer();
mix_into(&mut buf, 44100.0);
}
assert_eq!(live_counts().1, 0, "stop_all_tones releases everything");
reset();
}
#[test]
fn the_voice_cap_holds_under_a_runaway_script() {
let _guard = super::SYNTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset();
for _ in 0..200 {
beep(440.0, 440.0, 1.0, Wave::Sine, 0.2, 0.0);
}
assert_eq!(live_counts().0, MAX_VOICES, "oldest voices are dropped");
reset();
}
#[test]
fn note_names_parse_to_concert_pitch() {
// A4 is the tuning reference; C5 is three semitones above A4's octave.
let a4 = note_freq("A4").unwrap();
assert!((a4 - 440.0).abs() < 0.01, "A4 = {a4}");
let a5 = note_freq("A5").unwrap();
assert!((a5 - 880.0).abs() < 0.02, "A5 = {a5}");
assert!(note_freq("H9").is_none(), "unknown tokens are rests");
}
}

View file

@ -1,310 +0,0 @@
//! XR controllers and hands → the same per-player input every other device
//! sends (game.md §"XR input → player input").
//!
//! An XR player is not a special case in the simulation: the headset fills a
//! [`PadState`] and a camera yaw, exactly as a gamepad or a phone would, and
//! the packet that goes on the wire is indistinguishable. That is what lets a
//! Quest in the corner race against a laptop without the sim knowing which is
//! which.
//!
//! Handedness follows the room, not the game: the left stick walks, the right
//! stick turns, and either hand's trigger shoots — so a left-handed player is
//! never worse off.
use makepad_widgets::makepad_platform::event::xr::{XrController, XrHand, XrState};
use makepad_widgets::*;
/// Sticks below this read as centred — Quest sticks rest a hair off zero and
/// would otherwise walk the player into a wall while nobody touches them.
const STICK_DEAD_ZONE: f32 = 0.15;
/// Trigger/grip pull that counts as a press.
const ANALOG_PRESS: f32 = 0.5;
/// One frame of XR intent, ready to merge into a player's input.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct XrIntent {
/// Left stick, camera-relative walk axes (x = strafe, z = forward).
pub axis_x: f64,
pub axis_z: f64,
/// Right stick x, applied to the player's camera yaw (snap or smooth is
/// the shell's choice; this is the raw request).
pub turn: f32,
pub jump: bool,
pub shoot: bool,
pub grab: bool,
pub reset: bool,
/// Head yaw in tracking space: what "forward" means for this player this
/// tick. Travels in their input packet, so movement resolves against
/// their own view without replicating a camera.
pub head_yaw: f32,
}
fn dead_zoned(v: f32) -> f64 {
if v.abs() < STICK_DEAD_ZONE {
0.0
} else {
// Rescale past the dead zone so the first live degree is gentle
// rather than a jump to 0.15.
let sign = if v < 0.0 { -1.0 } else { 1.0 };
let scaled = (v.abs() - STICK_DEAD_ZONE) / (1.0 - STICK_DEAD_ZONE);
(sign * scaled.min(1.0)) as f64
}
}
/// Yaw of a pose's forward vector, projected onto the floor plane.
///
/// A head tipped to look at your feet still means "forward is that way", so
/// the y component is dropped rather than normalised into the answer.
pub fn flat_yaw(orientation: Quat) -> f32 {
let f = orientation.rotate_vec3(&vec3f(0.0, 0.0, -1.0));
if f.x.abs() < 1.0e-6 && f.z.abs() < 1.0e-6 {
// Looking straight up or down: no usable heading, keep the old one.
return 0.0;
}
// Matches the engine's camera convention: forward = (sin y, -cos y).
f.x.atan2(-f.z)
}
fn controller_active(c: &XrController) -> bool {
c.buttons & XrController::ACTIVE != 0
}
/// Map a headset state to game intent. Controllers win when held; hands
/// take over when they are the only thing tracked, so putting a controller
/// down mid-game does not freeze the player.
pub fn intent_from_xr(state: &XrState) -> XrIntent {
let mut intent = XrIntent {
head_yaw: flat_yaw(state.head_pose.orientation),
..Default::default()
};
let left = &state.left_controller;
let right = &state.right_controller;
let any_controller = controller_active(left) || controller_active(right);
if any_controller {
if controller_active(left) {
intent.axis_x = dead_zoned(left.stick.x);
// Stick forward is +y in XR, and forward is -z in the game.
intent.axis_z = -dead_zoned(left.stick.y);
intent.grab = left.grip >= ANALOG_PRESS;
intent.shoot |= left.trigger >= ANALOG_PRESS;
intent.jump |= left.buttons & XrController::CLICK_X != 0;
intent.reset |= left.buttons & XrController::CLICK_Y != 0;
}
if controller_active(right) {
intent.turn = if right.stick.x.abs() < STICK_DEAD_ZONE {
0.0
} else {
right.stick.x
};
intent.grab |= right.grip >= ANALOG_PRESS;
intent.shoot |= right.trigger >= ANALOG_PRESS;
intent.jump |= right.buttons & XrController::CLICK_A != 0;
intent.reset |= right.buttons & XrController::CLICK_B != 0;
}
return intent;
}
// Hands-only: pinch is the whole vocabulary. Index pinch is the trigger
// (shoot), middle pinch is grab — cheap, reliable, and no gesture the
// player has to learn beyond "pinch to act".
let hand_shoot = |h: &XrHand| h.pinch_index();
let hand_grab = |h: &XrHand| h.pinch_middle();
intent.shoot = hand_shoot(&state.left_hand) || hand_shoot(&state.right_hand);
intent.grab = hand_grab(&state.left_hand) || hand_grab(&state.right_hand);
intent
}
/// Fold XR intent into the pad half of a player's input. Digital keys are
/// untouched, so a headset and a keyboard can drive the same player.
pub fn apply_intent_to_pad(intent: &XrIntent, pad: &mut PadState) {
let was_jump = pad.jump;
let was_shoot = pad.shoot;
let was_grab = pad.grab;
let was_reset = pad.reset;
pad.axis_x = intent.axis_x;
pad.axis_z = intent.axis_z;
pad.jump = intent.jump;
pad.shoot = intent.shoot;
pad.grab = intent.grab;
pad.reset = intent.reset;
// Edges, so a held trigger fires once — same contract as the gamepad.
pad.jump_pressed = intent.jump && !was_jump;
pad.shoot_pressed = intent.shoot && !was_shoot;
pad.grab_pressed = intent.grab && !was_grab;
pad.reset_pressed = intent.reset && !was_reset;
}
pub use makepad_game_sim::PadState;
/// The stage this device starts in, from `ARCADE_XR`.
///
/// `mr` is the Quest default in game.md — passthrough on, world shrunk onto
/// the floor. `vr` is full-scale for headsets with no passthrough. Anything
/// else (including unset) stays flat, which is what desktop and mobile want.
pub fn stage_from_env() -> Stage {
match std::env::var("ARCADE_XR").as_deref() {
Ok("mr") | Ok("MR") => {
let scale = std::env::var("ARCADE_XR_SCALE")
.ok()
.and_then(|s| s.parse::<f32>().ok())
.filter(|s| *s > 0.0)
.unwrap_or(DIORAMA_SCALE);
// Planted on the floor, 1.5m in front of where the player starts.
Stage::mr_diorama(vec3f(0.0, 0.0, -1.5), 0.0, scale)
}
Ok("vr") | Ok("VR") => Stage::vr_full_scale(),
_ => Stage::flat(),
}
}
use makepad_game_render::stage::{Stage, DIORAMA_SCALE};
#[cfg(test)]
mod tests {
use super::*;
fn active(mut c: XrController) -> XrController {
c.buttons |= XrController::ACTIVE;
c
}
fn state_with(left: XrController, right: XrController) -> XrState {
XrState {
left_controller: left,
right_controller: right,
..Default::default()
}
}
#[test]
fn resting_sticks_do_not_walk_the_player() {
let left = active(XrController {
stick: vec2f(0.08, -0.10),
..Default::default()
});
let intent = intent_from_xr(&state_with(left, XrController::default()));
assert_eq!(intent.axis_x, 0.0);
assert_eq!(intent.axis_z, 0.0);
}
#[test]
fn left_stick_walks_with_game_forward_sign() {
let left = active(XrController {
// Full forward push on the stick.
stick: vec2f(0.0, 1.0),
..Default::default()
});
let intent = intent_from_xr(&state_with(left, XrController::default()));
// Forward in the game is -z.
assert!(intent.axis_z < -0.9, "{intent:?}");
assert_eq!(intent.axis_x, 0.0);
let left = active(XrController {
stick: vec2f(1.0, 0.0),
..Default::default()
});
let intent = intent_from_xr(&state_with(left, XrController::default()));
assert!(intent.axis_x > 0.9, "{intent:?}");
}
#[test]
fn either_trigger_shoots_and_grips_grab() {
let left = active(XrController {
trigger: 1.0,
..Default::default()
});
assert!(intent_from_xr(&state_with(left, XrController::default())).shoot);
let right = active(XrController {
trigger: 1.0,
grip: 1.0,
..Default::default()
});
let intent = intent_from_xr(&state_with(XrController::default(), right));
assert!(intent.shoot && intent.grab);
}
#[test]
fn face_buttons_jump_and_reset() {
let right = active(XrController {
buttons: XrController::CLICK_A,
..Default::default()
});
assert!(intent_from_xr(&state_with(XrController::default(), right)).jump);
let right = active(XrController {
buttons: XrController::CLICK_B,
..Default::default()
});
assert!(intent_from_xr(&state_with(XrController::default(), right)).reset);
}
#[test]
fn hands_drive_when_no_controller_is_tracked() {
let mut state = XrState::default();
// Nothing is ACTIVE, so the hand path owns input. Pinch lives in the
// flags bitfield — the runtime's own detection — not the raw
// strength array.
state.right_hand.flags |= XrHand::PINCH_INDEX;
let intent = intent_from_xr(&state);
assert!(intent.shoot, "index pinch should shoot: {intent:?}");
assert!(!intent.grab);
state.right_hand.flags &= !XrHand::PINCH_INDEX;
state.left_hand.flags |= XrHand::PINCH_MIDDLE;
assert!(intent_from_xr(&state).grab);
}
#[test]
fn a_tracked_controller_beats_a_pinching_hand() {
// Hands stay tracked while holding controllers; a curled finger on
// the grip must not read as a pinch and fire the gun.
let mut state = state_with(
active(XrController::default()),
active(XrController::default()),
);
state.right_hand.flags |= XrHand::PINCH_INDEX;
assert!(!intent_from_xr(&state).shoot);
}
#[test]
fn head_yaw_matches_the_engine_camera_convention() {
// Facing -z (the default look direction) is yaw 0.
let state = XrState::default();
assert!(intent_from_xr(&state).head_yaw.abs() < 1.0e-5);
// Quarter turn to face +x.
let mut state = XrState::default();
state.head_pose.orientation =
Quat::from_axis_angle(vec3f(0.0, 1.0, 0.0), std::f32::consts::FRAC_PI_2);
let yaw = intent_from_xr(&state).head_yaw;
assert!(
(yaw.abs() - std::f32::consts::FRAC_PI_2).abs() < 1.0e-4,
"{yaw}"
);
}
#[test]
fn pad_edges_fire_once_per_press() {
let mut pad = PadState::default();
let held = XrIntent {
shoot: true,
..Default::default()
};
apply_intent_to_pad(&held, &mut pad);
assert!(pad.shoot && pad.shoot_pressed);
// Still held next tick: no new edge.
apply_intent_to_pad(&held, &mut pad);
assert!(pad.shoot && !pad.shoot_pressed);
// Released, then pressed again.
apply_intent_to_pad(&XrIntent::default(), &mut pad);
assert!(!pad.shoot && !pad.shoot_pressed);
apply_intent_to_pad(&held, &mut pad);
assert!(pad.shoot_pressed);
}
}

View file

@ -361,13 +361,10 @@ fn worker_main(
let mut tool_calls: Vec<(String, String)> = Vec::new();
loop {
if cancel.load(Ordering::Relaxed) {
// Close the dangling assistant turn so the KV stays valid,
// and drop any tool calls the cancelled turn produced — an
// interrupted command must not keep acting.
// Close the dangling assistant turn so the KV stays valid.
if let Some(im_end) = im_end {
let _ = session.append_token(im_end);
}
tool_calls.clear();
break;
}
if generated >= MAX_NEW_TOKENS

View file

@ -100,7 +100,7 @@ script_mod! {
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.inner_size: vec2(3400, 2050)
window.inner_size: vec2(1280, 840)
pass.clear_color: vec4(0.08, 0.10, 0.12, 1.0)
body +: {
View{
@ -111,15 +111,12 @@ script_mod! {
map := MapView{
width: Fill
height: Fill
// GPU-opt benchmark scene: AMS side view at the
// lowest zoom that shows 3D geometry.
center_lon: 4.8952
center_lat: 52.3702
zoom: 15.6
tilt: 60.0
zoom: 13.0
min_zoom: 3.0
mbtiles_path: "local/maps/world.mkmap"
detail_mbtiles_path: "local/maps/world.mkmap"
mbtiles_path: "local/maps/europe-shortbread.mbtiles"
detail_mbtiles_path: "local/maps/europe-osm-detail.mbtiles"
bridge_dz_mbtiles_path: "local/maps/nl-bridge-dz.mbtiles"
buildings_3d: true
}
@ -793,30 +790,6 @@ pub struct App {
/// Last nav banner instruction spoken, so each maneuver is announced once.
#[rust]
last_spoken_banner: String,
/// In-flight dispatcher prompt, for user-override cancellation.
#[rust]
current_prompt: Option<PromptId>,
/// Tool calls executed for the current prompt (loop budget).
#[rust]
tool_rounds: usize,
/// Previous (name, args) this turn — breaks identical-call loops.
#[rust]
last_tool_call: Option<(String, String)>,
}
/// Pull "ctx USED/MAX" out of the local timing status line.
fn parse_ctx_usage(timing: &str) -> Option<(usize, usize)> {
let at = timing.rfind("ctx ")?;
let rest = &timing[at + 4..];
let (used, max) = rest.trim().split_once('/')?;
Some((
used.trim().parse().ok()?,
max.trim()
.split(|c: char| !c.is_ascii_digit())
.next()?
.parse()
.ok()?,
))
}
fn read_secret(name: &str) -> Option<String> {
@ -873,12 +846,6 @@ impl App {
/// dispatcher instead. Claude, when a key exists, otherwise serves as
/// the cloud_ask escalation tool.
fn init_agent(&mut self, cx: &mut Cx) {
// AI on by default again (perf campaign over); MAKEPAD_NO_AI=1
// keeps the GPU clear of the 9B/4B/whisper chain when profiling
// the map renderer.
if std::env::var_os("MAKEPAD_NO_AI").is_some() {
return;
}
let api_key = read_secret("ANTHROPIC_API_KEY");
let cloud_dispatch = std::env::var("MAKEPAD_ROUTE_CLOUD").is_ok() && api_key.is_some();
@ -1046,11 +1013,7 @@ impl App {
self.push_line(cx, "⚠ no agent available");
return;
}
// A new turn obsoletes whatever the voice was still saying — and a
// typed command while the dispatcher runs is always an override.
if self.busy {
self.interrupt_turn(cx);
}
// A new turn obsoletes whatever the voice was still saying.
if let Some(speech) = &mut self.speech {
speech.stop();
}
@ -1072,64 +1035,11 @@ impl App {
self.trip.digest()
);
let (agent, session) = (self.agent.as_mut().unwrap(), self.session.unwrap());
let prompt_id = agent.send_prompt(cx, session, &prompt);
self.current_prompt = Some(prompt_id);
self.tool_rounds = 0;
self.last_tool_call = None;
agent.send_prompt(cx, session, &prompt);
self.busy = true;
self.set_status(cx, "thinking…");
}
/// User override: cancel the in-flight dispatcher turn NOW. The worker
/// stops per-token, closes the assistant turn and drops its tool calls.
fn interrupt_turn(&mut self, cx: &mut Cx) {
if !self.busy {
return;
}
if let (Some(agent), Some(prompt_id)) = (self.agent.as_mut(), self.current_prompt) {
agent.cancel_prompt(cx, prompt_id);
}
self.busy = false;
self.voice_queue.clear();
if let Some(speech) = &mut self.speech {
speech.stop();
}
self.push_entry(cx, EntryKind::Info, "⏹ interrupted");
self.set_status(cx, "ready");
}
/// Does this utterance override the current activity? Only the LEADING
/// words count — "add a charging stop" must not match on "stop".
fn is_override_command(text: &str) -> bool {
let lower = text.to_lowercase();
lower
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.take(2)
.any(|w| {
matches!(
w,
"stop" | "cancel" | "nevermind" | "never" | "forget" | "wait" | "actually"
)
})
}
/// "stop" / "nevermind" with no follow-up command: just halt.
fn is_pure_stop(text: &str) -> bool {
let words: Vec<&str> = text
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.collect();
words.len() <= 3
&& words.iter().all(|w| {
matches!(
w.to_lowercase().as_str(),
"ok" | "okay" | "no" | "stop" | "cancel" | "nevermind" | "never" | "mind"
| "it" | "that" | "forget" | "computer"
)
})
}
fn on_agent_event(&mut self, cx: &mut Cx, event: AgentEvent) {
match event {
AgentEvent::SessionReady { .. } => {
@ -1170,35 +1080,17 @@ impl App {
speech.flush();
}
self.busy = false;
self.current_prompt = None;
let timing = self
.local_timing
.as_ref()
.and_then(|t| t.lock().ok().map(|s| s.clone()))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "ready".to_string());
// The session is append-only: once the context is nearly
// full it cannot recover — restart with a fresh session
// (mmap makes the reload cheap; chat history stays in the
// transcript, the model just loses conversational memory).
if let Some((used, max)) = parse_ctx_usage(&timing) {
if used * 10 > max * 9 {
self.push_entry(
cx,
EntryKind::Info,
&format!("⚠ context {used}/{max} — restarting local session"),
);
self.agent = None;
self.session = None;
self.init_agent(cx);
}
}
self.set_status(cx, &timing);
}
AgentEvent::PromptError { error, .. } => {
self.commit_pending(cx);
self.busy = false;
self.current_prompt = None;
self.push_line(cx, &format!("{error}"));
self.set_status(cx, "error — try again");
}
@ -1504,21 +1396,9 @@ impl App {
GateResult::Send { raw, instruction } => {
let _ = &raw;
self.push_entry(cx, EntryKind::Info, "→ directed");
if self.busy && Self::is_override_command(&instruction) {
// "ok nevermind, stop, go do X now" — cancel the turn;
// send the rest unless it was a bare stop.
self.interrupt_turn(cx);
if !Self::is_pure_stop(&instruction) {
self.send_user_prompt(cx, &instruction);
}
} else if self.busy {
if self.busy {
self.push_entry(cx, EntryKind::Info, "(queued until current turn ends)");
self.voice_queue.push(instruction);
} else if Self::is_pure_stop(&instruction) {
// Nothing running — just quiet the voice.
if let Some(speech) = &mut self.speech {
speech.stop();
}
} else {
self.send_user_prompt(cx, &instruction);
}
@ -1561,28 +1441,6 @@ impl App {
fn run_tool(&mut self, cx: &mut Cx, tool_use_id: &str, name: &str, input: &str) {
let compact: String = input.chars().take(120).collect();
self.push_entry(cx, EntryKind::Tool, &format!("{name} {compact}"));
// Loop breakers: greedy decoding can wedge the dispatcher into
// re-issuing the same call forever (seen: identical geo_search
// spam). Repeats and over-budget turns get a corrective tool
// result instead of execution.
self.tool_rounds += 1;
let this_call = (name.to_string(), input.to_string());
let repeated = self.last_tool_call.as_ref() == Some(&this_call);
self.last_tool_call = Some(this_call);
if repeated || self.tool_rounds > 10 {
let nudge = if repeated {
"Error: identical tool call repeated — you already have this result. \
Do NOT call this tool again; answer the user now with what you know."
} else {
"Error: tool budget for this request is exhausted. \
Stop calling tools and answer the user now with what you have."
};
self.push_entry(cx, EntryKind::Info, "⛔ tool loop broken");
if let (Some(agent), Some(session)) = (self.agent.as_mut(), self.session) {
agent.send_tool_result(cx, session, tool_use_id, nudge, true);
}
return;
}
// images_search runs async over cx.http_request; the tool result is
// sent when the thumbnails land.
if name == "images_search" {

View file

@ -21,15 +21,6 @@ pub fn defs() -> Vec<ToolDefinition> {
pub fn search(ctx: &mut ToolCtx, args: &JsonValue) -> Result<String, String> {
let query = arg_str(args, "query").ok_or("missing query")?.to_string();
// A degenerate/repetitious query (greedy-decoding loops produce them)
// makes the fuzzy index scan crawl. Refuse with guidance instead.
if query.len() > 64 || query.split_whitespace().count() > 8 {
return Err(format!(
"query too long ({} chars) — search for ONE short place name or category, \
e.g. 'Oudegracht 399 Utrecht' or 'hotel museumkwartier'",
query.len()
));
}
let limit = arg_usize(args, "limit").unwrap_or(8).clamp(1, 20);
let (center_lon, center_lat) = ctx.map_center();
let near = LonLat {

256
blur.md Normal file
View file

@ -0,0 +1,256 @@
# Numbered Overlays And Multi-Level Gauss Blur
## Goal
Replace the current single overlay channel with numbered overlay levels, then add two Gauss capture levels:
- Gauss level 0: captures the base window scene before any overlay.
- Gauss level 1: captures the base scene plus overlay level 0.
This lets glass on overlay level 0 blur/lens the app background, while glass on overlay level 1 can blur/lens the already-composited level-0 UI. It also keeps the rule clear: a glass surface samples only completed lower levels, never itself or peers in the same level.
## Current State
The current overlay path is singular:
- `draw/src/cx_2d.rs` stores one `overlay_id`, one `overlay_pass_id`, and `overlay_draw_depth`.
- `draw/src/overlay.rs` owns one overlay root `DrawList`.
- `draw/src/draw_list_2d.rs::begin_overlay_reuse()` appends a widget draw list to that one overlay root.
- `widgets/src/window.rs` owns one `overlay: Overlay`.
- `widgets/src/gauss_view.rs::request_window_gauss()` only succeeds while `cx.is_drawing_overlay()` is true.
- `Window::begin()` chooses either the normal pass or the `gauss_scene` capture pass, then points the one overlay root at the final window pass.
That means all overlay widgets are in one ordering bucket. A glass popup can sample the base app scene, but another glass layer above it cannot sample the popup as part of its blurred backing. Overlapping glass in the same overlay level also cannot produce nested blur because all glass is sampling the same lower snapshot.
## Proposed Model
Introduce ordered overlay levels:
```rust
pub type OverlayLevel = u8;
pub const OVERLAY_LEVEL_BASE: OverlayLevel = 0;
pub const OVERLAY_LEVEL_FLOATING: OverlayLevel = 1;
pub const OVERLAY_LEVEL_TOP: OverlayLevel = 2;
```
Level order is strict:
1. Main scene
2. Overlay level 0
3. Overlay level 1
4. Overlay level 2 and higher
Within a level, existing append order and `begin_overlay_last` behavior still apply. Across levels, numeric level decides ordering.
## Public API Shape
Keep existing calls as level-0 compatibility:
```rust
draw_list.begin_overlay_reuse(cx); // means level 0
draw_list.begin_overlay_last(cx); // means level 0, last within level 0
```
Add explicit level APIs:
```rust
draw_list.begin_overlay_level_reuse(cx, level);
draw_list.begin_overlay_level_last(cx, level);
```
Widgets that own overlay draw lists should gain a live property:
```rust
#[live(0)]
overlay_level: u8,
```
Then `Tooltip`, `PopupNotification`, `PopupMenu`, `Modal`, dock ghost overlays, and the new glass layer can choose where they belong without custom render code.
## Render Context Changes
Replace the single overlay target fields in `Cx2d` with a small overlay target table plus current overlay draw state:
```rust
pub struct OverlayTarget {
pub draw_list_id: DrawListId,
pub pass_id: Option<DrawPassId>,
}
pub struct Cx2d<'a, 'b> {
overlay_targets: SmallVec<[Option<OverlayTarget>; 4]>,
current_overlay_level: Option<OverlayLevel>,
overlay_draw_depth: usize,
...
}
```
Required helpers:
```rust
pub fn is_drawing_overlay(&self) -> bool;
pub fn current_overlay_level(&self) -> Option<OverlayLevel>;
pub fn overlay_target(&self, level: OverlayLevel) -> Option<&OverlayTarget>;
```
`DrawList2d::begin_overlay_level_*` should:
- Look up the target for the requested level.
- Set this draw list's pass to the target pass, falling back to the current pass if no explicit pass exists.
- Store this draw list under that level's overlay root.
- Set `current_overlay_level` while drawing.
- Increment/decrement `overlay_draw_depth` as today.
## Overlay Stack
Replace or wrap `Overlay` with an `OverlayStack` owned by `Window`:
```rust
pub struct OverlayStack {
levels: Vec<Overlay>,
}
```
Responsibilities:
- Allocate one root overlay draw list per level.
- During window begin, publish all active overlay targets into `Cx2d`.
- During window end, append each overlay root in ascending level order.
- Flush stale sublists per level, using the same redraw-id cleanup as today's single `Overlay::end()`.
Backward compatibility can be kept by making `Overlay` a thin level-0 wrapper or by preserving `Overlay::begin()` as `OverlayStack::begin_level(0)`.
## Two Gauss Capture Levels
Extend the per-window Gauss state from one request/snapshot to two indexed capture states:
```rust
pub const WINDOW_GAUSS_LEVELS: usize = 2;
struct GaussCaptureState {
requested_last_frame: bool,
requested_this_frame: bool,
capture_active: bool,
snapshot: Option<GaussBlurSnapshot>,
}
struct GaussWindowEntry {
generation: u64,
levels: [GaussCaptureState; WINDOW_GAUSS_LEVELS],
}
```
Add APIs:
```rust
pub fn request_window_gauss_level(cx: &mut Cx2d, level: usize) -> Option<GaussBlurSnapshot>;
pub fn request_window_gauss(cx: &mut Cx2d) -> Option<GaussBlurSnapshot>;
```
Default behavior:
- If drawing overlay level 0, `request_window_gauss()` requests Gauss level 0.
- If drawing overlay level 1 or higher, `request_window_gauss()` requests Gauss level 1.
- Explicit `request_window_gauss_level()` exists for advanced widgets and tests.
This mapping keeps existing `GaussRoundedView` source-compatible while allowing higher overlays to sample a later scene.
## Window Render Order
When no Gauss is requested, rendering can remain almost identical:
1. Draw main scene to the window pass.
2. Draw overlay roots in numeric order.
When only Gauss level 0 is requested:
1. Draw main scene into `gauss_stack[0].scene`.
2. Build blur chain 0.
3. Draw scene 0 texture to the window pass.
4. Draw overlay level 0+ to the window pass. Level-0 glass samples snapshot 0.
When Gauss level 1 is requested:
1. Draw main scene into `gauss_stack[0].scene`.
2. Build blur chain 0.
3. Draw/copy scene 0 into `gauss_stack[1].scene`.
4. Draw overlay level 0 into `gauss_stack[1].scene`. Level-0 glass samples snapshot 0.
5. Build blur chain 1 from `gauss_stack[1].scene`.
6. Draw scene 1 texture to the window pass.
7. Draw overlay level 1+ to the window pass. Level-1 glass samples snapshot 1.
This avoids feedback loops:
- Level 0 never samples itself.
- Level 1 samples base plus completed level 0.
- Same-level glass overlap is still not nested blur, by design.
## Gauss Stack Ownership
`Window` currently owns one `GaussStack`. Change that to two stacks:
```rust
gauss_stacks: [GaussStack; WINDOW_GAUSS_LEVELS],
```
To reduce duplicated code:
- Keep `GaussStack` as the render-texture/mip-chain owner.
- Add a helper that renders one stack from an input draw phase.
- Reuse existing `DrawGaussDownsample`, `DrawGaussUpsample`, and `DrawGaussScene`.
The second stack needs a way to start with the previous level's scene texture. A simple first implementation can draw `gauss_stack[0].scene_texture` into `gauss_stack[1].scene` with `DrawGaussScene`, then draw overlay level 0 over it.
## Widget Migration
Add `overlay_level` to:
- `PopupNotification`
- `Tooltip`
- `PopupMenu`
- `Modal`
- Dock drag ghost overlay
- `mod.widgets.glass.Layer`
Defaults:
- Existing behavior stays level 0.
- Menus/tooltips that must float over glass app chrome can opt into level 1.
- Debug/drag/cursor-like overlays can use level 2 or `begin_overlay_level_last()`.
## Compatibility Rules
- Existing apps keep working because `begin_overlay_reuse()` maps to level 0.
- Existing `GaussRoundedView` keeps working because `request_window_gauss()` maps from current overlay level.
- Non-overlay Gauss requests should still return `None`.
- Existing single-level popup demos should look unchanged.
## Tests And Validation
Add focused tests or demos:
1. Base scene only: no overlay, no Gauss request, same as current render.
2. Level-0 glass over detailed background: samples Gauss level 0.
3. Level-1 glass popup over level-0 glass nav: samples Gauss level 1 and visibly blurs/lenses the nav.
4. Same-level overlapping glass: does not recursively blur peers.
5. `begin_overlay_last()` remains last only within its level.
6. Stale overlay draw lists are removed independently per level.
Runtime validation should use Studio release runs and screenshots, because this is a UI/render-stack change.
## Implementation Phases
1. Add numbered overlay plumbing to `Cx2d`, `DrawList2d`, and `OverlayStack`, keeping old APIs as level-0 wrappers.
2. Migrate `Window` from one `Overlay` to `OverlayStack` with one level enabled first.
3. Add `overlay_level` fields to overlay widgets and verify old demos are unchanged.
4. Refactor Gauss window global state to indexed capture states.
5. Add two `GaussStack`s to `Window` and implement level-1 capture/composite.
6. Update `request_window_gauss()` to choose a snapshot from the current overlay level.
7. Build a small demo with level-0 app chrome and level-1 popup glass to prove nested blur.
## Open Decisions
- Maximum overlay levels: fixed small array versus dynamic vector. A fixed small array is simpler and likely enough initially.
- Whether level 2 should get a third Gauss capture later. For now it should render above level 1 while sampling level-1's snapshot if it uses glass.
- Whether pass compositing should draw full scene textures at each capture level or draw only overlay deltas. Full scene textures are simpler and safer first; delta compositing can be optimized later.

161
bridge.md Normal file
View file

@ -0,0 +1,161 @@
# bridge.md — road elevation & grade separation for the 3D car-follow view
How we get overpasses, bridges, tunnels and stacked interchanges to render with
real depth in the perspective/ortho car-follow GPS view. Companion to `map.md`
(renderer), `gps.md` (interaction), `datasources.md` §2 (elevation sources; this
doc expands its "Road elevation / grade separation" subsection). Researched and
endpoint-checked 2026-07-29.
## 1. The data reality: OSM will not store road z
- **OSM has no plans to add elevation to road geometry — it's a settled
position, not an oversight.** The data model is strictly 2D lat/lon. `ele=*`
exists but is for summits/POIs; the wiki is explicit that OSM is not an
elevation database. No active proposal changes this.
- **Overture landed in the same place.** Their transportation `level` property
is relative stacking order only, documented as "an approximation for
rendering, not a precise indication of elevation". Geometry is 2D.
- The freshest ecosystem effort (OSM2World's Prototype-Fund project, June 2026 —
see §3) plans *around* the missing z, not toward adding it. At most it may
propose new helper *tags*.
Conclusion: waiting for upstream z is not a strategy. Everyone derives it;
so will we — at tile/nav build time, baked into our own tiles.
## 2. What road geometry does carry (all already in our PBF)
| Tag | Meaning | Use for us |
|---|---|---|
| `layer=*` | relative stacking, 5..5, per way | the load-bearing input: crossing order |
| `bridge=*` / `tunnel=*` | way is elevated / buried; ways are split at the structure ends | where decks start/end; abutment anchors |
| `incline=*` | signed grade on a way (%, or up/down) | constraint on ramps where mapped |
| `maxheight=*` | legal clearance *under* a structure | lower bound on deck z at that crossing |
| `height=*`, `ele=*` on bridge ways | rare, occasionally present | direct evidence when it exists |
| `bridge:structure`, `man_made=bridge` areas | structure type / deck outline polygon | rendering style; polygon for AHN sampling (§5) |
`layer` is relative and only meaningful *at crossings* — two `layer=1` bridges
in different towns share nothing. Treat it as a partial order between ways whose
geometries intersect, never as an absolute height.
## 3. Prior art
- **OSM2World** is the reference implementation of derive-at-build: an elevation
connector graph is initialized at terrain height, then a constraint pass
enforces equal z at shared nodes, minimum vertical clearance where a higher
`layer` crosses a lower one, tunnels below terrain, and grade
smoothing/clamping (heat-equation smoothing was merged as PR #176).
- **OSM2World "OSM-3D-Terrain"** (Prototype Fund, roadmap 2026-06-03) is
productizing exactly this into 3D tiles with LOD — and explicitly flags that
elevation adjustments must not create discontinuities at tile boundaries.
Same problem class as ours; worth tracking their output formats.
- **MapLibre / the open vector-map world**: nobody renders true grade-separated
interchanges yet. Terrain drape squashes bridges onto the valley floor;
Terrain3D work in MapLibre Native doesn't address elevated roads. Doing this
properly is a visible differentiator for our renderer.
- Commercial nav stacks survey slope/z per link themselves — same conclusion
from the other direction: the community map gives topology and stacking,
measured/derived z comes from elsewhere.
## 4. The derivation recipe (tile/nav build, Europe-wide)
Runs offline in `tools/map_tiles` / `nav-build`; the renderer only ever sees
baked results.
1. **Init**: every road/rail vertex gets z = DEM height at (x,y)
(Mapterhorn PMTiles / AHN, per `datasources.md` §2).
2. **Never sample the DEM mid-bridge or mid-tunnel.** Bridge/tunnel ways are
split at the structure boundary, so the endpoints are the abutments:
interpolate z between abutments (linear; spline if the deck is long).
Sampling under the deck is the classic artifact — the bridge sagging into
the river valley — and it equally poisons the per-edge climb/descent numbers
baked into `region.graph` for EV routing (datasources.md, EV thread).
3. **Constraint pass** over crossing pairs and shared nodes:
- shared node ⇒ equal z (ramps connect continuously);
- `layer(a) > layer(b)` at a geometric crossing ⇒
`z_a ≥ z_b + clearance`; default clearance ≈ 5.5 m per layer step
(≥ 4.7 m legal clearance + ~0.8 m deck structure), overridden upward by
`maxheight` where tagged;
- `tunnel` ⇒ z ≤ terrain cover (default ~2 m below surface at portals,
deeper mid-tunnel is fine);
- respect `incline=*` where mapped.
4. **Smooth + clamp**: grade smoothing along each way chain (moving
average or heat-equation style), then clamp per class — motorway ≤ ~6%,
links ≤ ~10%, service/other ≤ ~15%. Approach ramps to a lifted deck extend
into the adjacent non-bridge ways; ease in/out (cosine) so decks don't pop.
5. **Tile-boundary continuity**: solve on a buffered neighborhood (same trick
as our existing tile pipeline uses for geometry clipping) so a vertex near
the edge gets the same z in both tiles.
6. **Bake**: per-vertex **Δz above DEM** (not absolute z), quantized (dm is
plenty), on road/rail geometry in our tiles. Δz-above-DEM keeps the field
independent of DEM version/resolution mismatches at render time and is 0 for
~99% of vertices ⇒ compresses to almost nothing.
## 5. NL upgrade: measured deck heights instead of solved ones
Where surveyed data exists, replace the constraint solution:
- **RWS DTB** (Digitaal Topografisch Bestand, open on PDOK): cm-grade surveyed
x/y/z on lines/points/areas covering exactly the RWS-managed motorway network
— i.e. precisely where the multi-level interchanges are. Z is in the PDOK
atom shapefiles and (since recently) the WFS as well. Join to OSM ways by
proximity+bearing, sample z along the DTB lines.
- **AHN** (open LiDAR): the **DSM keeps bridge decks, the DTM removes them**
so DSM DTM over a bridge polygon (`man_made=bridge` from OSM, or BGT
"overbruggingsdeel") ≈ deck height directly. The LAZ point clouds classify
structures explicitly if we want it from the source.
- **Kadaster 3D Basisvoorziening** (open, 3dfier-generated): ready-made 3D
terrain + LoD1 bridge decks as meshes — the ingest-meshes-instead-of-solving
option, and a validation set for our solver either way.
Europe-wide the solver result stands; NL gets true-to-survey interchanges.
Validation loop: run the solver in NL, diff against DTB/AHN, tune clearance and
clamp constants until the residuals are boring, then trust it elsewhere.
## 6. Renderer integration
- Tiles carry Δz per road vertex; the renderer adds it on top of the same
terrain drape it already uses (see map terrain/3D displacement architecture
notes). Decks lift, tunnels sink, everything else stays glued to the drape.
- 2D/top-down mode keeps using `layer` draw order exactly as today — Δz only
engages in tilted/3D camera modes, so this is purely additive.
- Depth: elevated decks get real depth-tested geometry; the existing
bridge-casing styling (open item in the carto-quality list) becomes the deck
side/edge treatment in 3D.
- What sells the effect: a drop shadow / AO blob under decks onto whatever is
below, and portal treatment at tunnel mouths (clip or fade the tunnel line
under terrain, keep a dimmed dashed hint in nav mode when the route goes
through it).
- Car-follow camera: route position includes Δz, so the chase camera rides over
the flyover instead of clipping through it; on stacked interchanges the
route's own level is unambiguous because it comes from the graph edge, not
from GPS altitude (which is too noisy to pick a deck).
## 7. Build order
- **M0 — fake it visually**: no DEM needed. Δz = `layer` × 5.5 m on
bridge/tunnel ways with cosine ease-in/out extending into approach ways.
Gets stacked interchanges reading correctly in one step; wrong absolute
heights, right relative ones.
- **M1 — solve it**: recipe of §4 in the tile build, bake Δz-above-DEM,
renderer consumes it. Requires the DEM ingest that hillshade (path B) needs
anyway.
- **M2 — measure it (NL)**: DTB/AHN override per §5 + solver validation.
- **M3 — polish**: deck shadows/AO, tunnel portals, casing-as-deck-edge,
`region.graph` climb/descent switched to the same z source.
## Sources (checked 2026-07-29)
- OSM wiki: [Altitude](https://wiki.openstreetmap.org/wiki/Altitude),
[Key:ele](https://wiki.openstreetmap.org/wiki/Key:ele),
[Key:layer](https://wiki.openstreetmap.org/wiki/Key:layer),
[Key:maxheight](https://wiki.openstreetmap.org/wiki/Key:maxheight)
- OSM2World: [OSM-3D-Terrain Prototype-Fund roadmap (2026-06-03)](https://osm2world.org/blog/2026/06/03/ptf-roadmap-2026-osm-3d-terrain/),
[elevation smoothing PR #176](https://github.com/tordanik/OSM2World/pull/176)
- Overture: [shape & connectivity](https://docs.overturemaps.org/schema/concepts/by-theme/transportation/shape-connectivity/),
[segments & connectors](https://docs.overturemaps.org/guides/transportation/segments-and-connectors/)
- NL: [RWS DTB on PDOK](https://www.pdok.nl/introductie/-/article/digitaal-topografisch-bestand-dtb-),
[DTB open data (RWS)](https://rijkswaterstaat.nl/zakelijk/open-data/digitaal-topografisch-bestand),
[AHN DTM](https://data.overheid.nl/dataset/47567-actueel-hoogtebestand-nederland--ahn--dtm),
[AHN DSM (AHN4)](https://data.overheid.nl/dataset/36461-actueel-hoogtebestand-nederland-dsm--ahn4-),
[Kadaster 3D Basisvoorziening](https://www.kadaster.nl/zakelijk/producten/geo-informatie/3d-producten/3d-basisvoorziening)
- Renderer gap: [MapLibre Terrain3D roadmap](https://maplibre.org/roadmap/maplibre-native/terrain3d/)

433
datasources.md Normal file
View file

@ -0,0 +1,433 @@
# Open data sources for map overlays
Survey of open datasources we can overlay on the Europe map, researched and
endpoint-verified 2026-07-27 (curl-probed where noted). Netherlands-first, Europe-wide
where possible. Companion to `gps.md` (interaction layer) and `map.md` (renderer).
> **Implementation**: `libs/geodata` (CLI: `geodata`) is the bulk-download +
> layer-database builder born from this survey — see `libs/geodata/README.md`
> for the per-layer database schemas and which layers are built vs planned.
License verdicts used below:
- **OPEN** — CC0 / public domain / no conditions. Bundle, redistribute, ship.
- **ATTR** — CC-BY-style. Fine, needs an attribution screen.
- **NC** — non-commercial only. Fine for the hobby build, blocks a commercial app.
- **RESTRICTED** — permission required / unusable. Listed to warn.
## How overlays plug into our stack
Five integration paths, roughly in order of how much new machinery each needs:
- **A. Tile-time attribute join** (import pipeline, `tools/map_tiles`): join external
attributes onto OSM features while building our own tiles. Zero new renderer work —
the style layer just gets new tags to color by.
- **B. Raster overlay layer** (new renderer feature): decode raster tiles (PNG/WebP/COG)
onto textures, alpha-blend above the base map, optional time dimension for animation
(radar frames). One feature, many layers: radar, hillshade, aerial, satellite, model
fields. The tile scheduling/fade machinery in `view.rs` generalizes to this.
- **C. Live point/vector overlays** (existing `overlay.rs` machinery from the nav work):
markers, polylines, colored road segments fed by a background poller. Transit
vehicles, planes, ships, chargers, traffic coloring.
- **D. Routing-graph enrichment** (`libs/map_nav` + import): per-edge attributes
(elevation profile, live travel times, bridge state) that change route costs, not
pixels.
- **E. 3D import** (box3d/xr track): real building meshes and terrain meshes.
---
## 1. Weather
### OPERA / EUMETNET European rain radar — the headline find
The pan-European radar composite became genuinely open via the EU high-value-datasets
push. The **EUMETNET Open Radar Data (ORD) API** on MeteoGate serves OPERA composites:
max reflectivity at **1 km / 5 min** and surface rain rate + 1-h accumulation at
2 km / 15 min, all of Europe, as **ODIM HDF5 and cloud-optimized GeoTIFF**, rolling
24 h + archive to 2012. Anonymous tier verified live
(`https://api.meteogate.eu/eu-eumetnet-weather-radar/collections` returns 200); free
API key raises limits; MQTT push available. **ATTR (CC BY 4.0).**
Integration: B — COG → reproject to mercator → colormap → animated raster frames.
### KNMI Data Platform (NL detail + nowcast)
5-min NL reflectivity composite (`radar_reflectivity_composites/2.0`) plus
`radar_forecast/1.0` — a **+2 h nowcast in 5-min steps**, i.e. the "rain in the next
two hours" animation comes precomputed. HARMONIE-AROME forecast grids (GRIB1) too.
REST file API + MQTT push; free key (shared anonymous key exists). **ATTR (CC BY 4.0).**
Format: KNMI HDF5, polar-stereographic — needs decode + reprojection (or accept the
2 km OPERA rain-rate product for NL too and skip HDF5 entirely at first).
### Wind fields — the animated particle/arrow layer (verified 2026-07-28)
The windy.com / earth.nullschool effect needs a gridded **10 m u/v field** (not
speed/direction points): u/v becomes a small RG texture per forecast step, a shader
advects fading particles through it, steps crossfade for the time animation — the
same technique as Beccario's earth and Mapbox's WebGL wind demo; their
grib2json/leaflet-velocity JSON is literally this grid, we just decode GRIB2/NetCDF
ourselves. All sources below carry 10 m u/v (+ gusts); curl-probed 2026-07-28:
- **NOAA GFS via NOMADS** — global 0.25°, 4 runs/day, hourly steps to 120 h. The
filter CGI subsets **by variable and bbox server-side**: 10 m U+V over a NL box
returned a **939-byte** GRIB2
(`filter_gfs_0p25.pl?...&var_UGRD=on&var_VGRD=on&lev_10_m_above_ground=on&subregion=&...`).
**OPEN (US public domain — the only no-attribution source).** The bootstrap
source for the shader work: bytes per frame, no key, and it's what
windy/nullschool themselves run on.
- **ECMWF open data (IFS)** — global 0.25°, GRIB2, 4 runs/day, 3-h steps to 144 h
(6-h to 240 h), no registration. Each file has an `.index` sidecar with byte
offsets per param → one HTTP range request pulls a global 10u/10v field (~870 KB).
Better European skill than GFS. `data.ecmwf.int` verified fine; the AWS mirror
(`ecmwf-forecasts`, eu-central-1) throttles hard (SlowDown after a handful of
rapid hits). **ATTR (CC BY 4.0).**
- **KNMI UWC-West HARMONIE — `uwcw-ha-det-nl-s1`** — NL at **2 km**, hourly runs,
60 h horizon, one NetCDF per parameter: `wind-speed-components-hagl` (u/v at 10 m
+ boundary-layer levels, ~57 MB/run) or speed/direction/gust-only files
(~27 MB each). Verified landing hourly. Free registered key; the shared anonymous
key is 50 req/min shared and rate-limited repeatedly while probing — register.
**ATTR (CC BY 4.0).** The NL-detail layer once the particle pass exists. Its
predecessors (`harmonie_arome_cy43_p1` = 850 MB GRIB1 tars/run,
`uwcw_extra_lv_ha43_nl_2km`) are deprecated; the Europe-wide 0.05° DINI domain
sits in `harmonie_arome_cy43_p3` (GRIB1 tars).
- **DWD ICON-D2 / ICON-EU** — D2: 2.2 km covering Germany + all of Benelux, hourly
`u_10m`/`v_10m`, **regular-lat-lon variants available** (~1 MB bz2 per step),
plain HTTPS directory, no key
(`opendata.dwd.de/weather/nwp/icon-d2/grib/<run>/u_10m/`). ICON-EU at 6.5 km
likewise. **ATTR (GeoNutzV).** The middle tier between ECMWF and KNMI 2 km.
- **Live station wind (observed arrows)**: KNMI
**`10-minute-in-situ-meteorological-observations`** — ~156 KB NetCDF every
10 min, all NL automatic stations incl. wind poles, verified fresh at probe time.
**ATTR.** Path C arrow markers + a "now" calibration for the model field.
(Replaces `Actuele10mindataKNMIstations`, which stopped updating 2025-09-29.)
- Shortcut worth knowing: **Open-Meteo's AWS open-data bucket** republishes
ECMWF/ICON/HARMONIE grids pre-merged in their custom `.om` chunk format —
convenient, but the raw GRIB2/NetCDF above is plain enough to skip the extra
format dependency.
Integration: path B's raster/time machinery + one new GPU particle pass (u/v → RG
texture per step, advect + fade, interpolate between steps); arrows are the cheap
first render of the same field. The identical u/v grid later feeds the EV headwind
term (EV thread, point 4).
### Forecast field overlays (cloud, snow, precip)
- **ECMWF open data** — IFS/AIFS at 0.25°, GRIB2, 4 runs/day, no registration,
mirrors on AWS S3. **ATTR (CC BY 4.0).** Cloud cover, snow depth, and the other
synoptic fields (wind: see above).
- **DWD ICON-EU** — 6.5 km Europe grid, GRIB2, plain HTTPS directory, no signup
(`opendata.dwd.de`). **ATTR (GeoNutzV).** Sharper than ECMWF over central Europe;
ICON-D2 (2.2 km) covers eastern NL fringe.
- **Open-Meteo** — JSON point/route forecasts; accepts coordinate lists →
**forecast along a route in one call** (EV: wind + temperature per leg). Data is
CC-BY; hosted API free for **non-commercial** (~10k calls/day); AGPL self-host
option. **ATTR data / NC hosted API.**
- **MET Norway api.met.no** — CC-BY point forecasts, no key (mandatory User-Agent);
solid backup.
### Satellite cloud layer
**EUMETSAT EUMETView** — WMS/WMTS at `view.eumetsat.int/geoserver`, no registration:
Meteosat MTG imagery, full disc every 10 min, rapid-scan Europe 5 min. **ATTR.**
Integration: B, trivially (it's already a tile service; cache politely).
### Flagged unsuitable
- **RainViewer** — free tier now personal/educational only, nowcast+satellite removed
from the public endpoint, max zoom 7. Prototyping only. **NC.**
- **Buienradar** — imagery not georeferenced; apps require written permission.
**RESTRICTED** (and redundant: KNMI is its upstream).
- **Blitzortung lightning** — network participants only, no commercial use.
**RESTRICTED.** No open EU lightning alternative exists.
---
## 2. Buildings, elevation, imagery
### Building age (the NL building-age map) — nearly free for us
Dutch OSM buildings originate from the community BAG import and already carry
**`ref:bag`** (BAG pand id) and **`start_date`** (= bouwjaar) — the construction year
is **already in the Europe PBF on disk**. v1 building-age coloring is therefore pure
path A: keep `start_date` on buildings at tile build, add an age→color ramp to the
style. For authoritative/fresh data later: **BAG via PDOK**`bag-light.gpkg`
(7.8 GB GeoPackage, monthly, **OPEN — CC0**), exact join on `ref:bag` =
`identificatie`. Prior art: Waag's "All 9.8M buildings" map (2013).
### Building heights + real 3D (NL)
**3D BAG** (TU Delft, release 2025.09, ~10.8M buildings): per-building height stats
(ground/roof percentiles, ridge) derived from LiDAR — join by the same BAG id at tile
build (path A) and extrude in the renderer; plus LoD2.2 roof-shape meshes as
**3D Tiles 1.1 / CityJSON / OBJ** per-tile downloads for the real-3D showcase
(path E). **ATTR (CC BY 4.0).** OSM's own `building:levels` covers only ~6% globally
and worse in NL — 3D BAG is the answer here.
Europe-wide fallback: **EUBUCCO v0.2** (322M buildings, height ~43%, age ~16%,
**ODbL share-alike**) or JRC **DBSM R2025** (harmonized EU stock, ODbL).
### Elevation (the EV-routing input)
- **AHN** (NL LiDAR): 0.5 m DTM/DSM GeoTIFF/COG via PDOK atom/WCS, AHN4 complete,
AHN5 rolling in. **OPEN.** Best-in-class; also validates 3D BAG heights.
- **Copernicus DEM GLO-30** (30 m, Europe/global): anonymous COGs on AWS
(`s3://copernicus-dem-30m/`). **ATTR** (credit notice). The pan-European base under
AHN. (EU-DEM is discontinued; the 10 m EEA product is access-restricted — skip.)
- **Ready-made terrain tiles**: **Mapterhorn** — terrarium-encoded terrain tiles for
Europe built from Copernicus + national LiDAR, distributed as a single **PMTiles**
file (`download.mapterhorn.com/planet.pmtiles`), open data, offline-friendly —
the fastest route to hillshade + 3D terrain. AWS/Mapzen terrain tiles still exist
(**ATTR**) but carry stale EU-DEM-era data for Europe.
Uses: hillshade raster overlay (B), 3D terrain meshes (E), and **per-edge climb/descent
baked into `region.graph`** (D) — see the EV section.
### Road elevation / grade separation (overpasses in the 3D car-follow view)
Researched 2026-07-29: **OSM will not store road z, and there is no plan to.** The
data model is 2D lat/lon; `ele=*` is for summits/POIs ("OSM is not an elevation
database") and no active proposal changes that. Overture's transportation schema
keeps the same design — `level` is relative stacking only, explicitly "not a
precise indication of elevation". What road geometry does carry (all already in
our PBF): `layer=*` (relative 5..5), `bridge`/`tunnel`, `incline=*`, `maxheight`
(clearance *under*), occasionally `height` on bridge ways.
The ecosystem plan-of-record is **derive z at build time: DEM + OSM semantics +
constraint solving**. OSM2World is the reference implementation (init every road
vertex at terrain height → equal-z at shared nodes → minimum vertical clearance at
bridge-over-road crossings → tunnels below terrain → grade smoothing + per-class
incline clamps), and its Prototype-Fund **"OSM-3D-Terrain"** project (roadmap
June 2026) is productizing exactly this into 3D tiles with LOD and tile-boundary
continuity — worth watching. No open vector-map renderer draws true
grade-separated interchanges yet (MapLibre's terrain drape squashes bridges flat),
so doing it properly is a differentiator.
Our recipe (tile/nav build):
1. Init road vertices at DEM height (Mapterhorn/AHN, above).
2. Constraint pass; **never sample the DEM mid-bridge/-tunnel** — interpolate
between abutments. (The classic artifact — elevation profile dipping into the
valley under the viaduct — would also poison the EV climb numbers in the EV
thread below.)
3. Bake per-vertex Δz-above-DEM into our tiles; the renderer lifts decks and sinks
tunnels from the same terrain drape.
4. NL upgrade — measured deck heights instead of solved ones:
- **RWS DTB** (PDOK, open): cm-grade surveyed x/y/z lines/points/areas covering
exactly the RWS motorway network incl. interchanges; z now in the WFS too.
- **AHN**: DSM keeps bridge decks, DTM removes them → DSMDTM over bridge
polygons ≈ deck height; the LAZ point clouds classify structures directly.
- **Kadaster 3D Basisvoorziening** (open, 3dfier-generated): ready-made 3D
terrain + LoD1 bridge decks, if ingesting meshes beats solving.
### Aerial & satellite imagery
- **PDOK Luchtfoto** (NL): 25 cm nationwide 2×/year + 8 cm HR flights, WMTS +
**bulk GeoTIFF download** via the Beeldmateriaal dataroom. **ATTR (CC BY 4.0)**
offline self-tiling is explicitly legal. NL "satellite view" solved.
- **Sentinel-2**: EOX cloudless mosaics are **NC** for 20182024 (only the 2016 layer
is CC-BY) — trap. The open path is compositing our own cloudless mosaic from raw
Sentinel-2 L2A COGs on the Copernicus Data Space (**OPEN/ATTR**, one-off batch job).
- Germany: NRW 10 cm orthophotos are license-zero (no attribution!); Belgium via
geo.be WMTS (ATTR). Per-country patchwork.
- **GHSL** (JRC): built-up surface 10 m, building height 100 m, population rasters.
**ATTR.** Coarse Europe-wide fallbacks and a population-density shading layer.
---
## 3. Mobility & live transport
### OVapi / NDOV — all Dutch public transport, live, CC0
The backbone NL feed, verified live (sub-minute freshness): static GTFS (~223 MB,
daily) + GTFS-RT protobufs — `vehiclePositions.pb` (~218 KB!), `tripUpdates.pb`,
`trainUpdates.pb`, `alerts.pb` at `gtfs.ovapi.nl` / `gtfs.openov.nl`. **No key, no
registration, OPEN (CC0).** Every bus/tram/metro/train in the country as a live layer
for one small protobuf poll every ~30 s (path C: colored vehicle markers; tap for
line/delay). Volunteer-run — identify with a User-Agent, poll politely; NDOV loket
(free registration, ZeroMQ push) is the harder-core fallback. The NS API is redundant
for positions (proprietary terms; OVapi carries all rail already).
### NDW — national road traffic, minute cadence, no key
`https://opendata.ndw.nu/` — plain HTTPS directory of gzipped DATEX II XML refreshed
every minute, **OPEN**, verified live: point speeds/intensities from 24k+ sites
(`trafficspeed.xml.gz`), segment travel times, situation feed (jams, closures,
wrong-way drivers, **live bridge openings** — six bridges stood open at check time),
matrix-sign states, roadworks/events planning, temporary speed limits, emission
zones, truck parking, and a **national EV-charger dataset in OCPI JSON**. Effort sits
in DATEX II parsing + matching their location references onto our road segments; the
payoff is double: traffic coloring overlay (C) *and* live routing weights + bridge
penalties (D). Historical archive via Dexter.
### Europe-wide transit
No single EU GTFS-RT exists. Practical ladder: NL = OVapi; DE = **gtfs.de** mirrors
(CC-BY, no login, incl. a free Germany-wide GTFS-RT feed, verified); rest via the
**Transitous** project (1,800+ cleaned feeds, re-published openly; hosted MOTIS API is
non-commercial fair-use — self-host MOTIS with their feed list if it pinches) and the
`european-transport-feeds` / Mobility Database catalogs.
### Live vehicles for fun (path C, cheap to add)
- **OpenSky** — live aircraft state vectors, bbox REST query, OAuth2; registered free
tier ≈ one NL-bbox poll/22 s. **NC free tier.** Feeding an ADS-B receiver doubles
the quota.
- **aisstream.io** — free WebSocket AIS ship positions (good near NL coast/ports);
thin license text — fine for hobby, clarify before commercial. Official NL inland
AIS is deliberately closed — don't plan on it.
- **OV-fiets GBFS**`gbfs.openov.nl/ovfiets/gbfs.json`, live rental-bike
availability per station, 60 s TTL, **OPEN**. Perfect companion to transit routing.
- **Autobahn API** (DE) — no-key REST JSON: roadworks, closures, webcams, chargers
per motorway. **OPEN.** Easiest German traffic entry point.
### EV charging
**NDW's OCPI feed** for NL (open, national, refreshed ~daily) + **OpenChargeMap** for
the rest of Europe (CC-BY, free key, rate-limited). Neither gives live occupancy for
free. OSM's `amenity=charging_station` lags reality in NL — use it only as fallback.
### Cycling
NL junction networks (knooppunten) are fully and actively mapped **in OSM** — already
in our data; render as an overlay style. The official Fietsplatform database is
**RESTRICTED** (purpose-limited license) — avoid.
### Water
**Rijkswaterstaat WaterWebservices** (new endpoint
`ddapi20-waterwebservices.rijkswaterstaat.nl` — old one is being switched off in
2026): water levels every ~10 min, JSON, no registration, **OPEN**. Plus
vaarweginformatie.nl fairway notices for lock/bridge operating times.
---
## 4. Environment & statistics
### Air quality
- **Luchtmeetnet** (RIVM, official NL): ~100 stations, hourly NO2/PM/O3 + LKI index +
interpolated concentrations at arbitrary lat/lon. JSON API
(`api.luchtmeetnet.nl/open_api`), no auth, 100 req/5 min; bulk CSV at
`data.rivm.nl/data/luchtmeetnet/`. **ATTR (CC BY 4.0).** Primary NL live layer.
- **Sensor.Community**: crowdsourced PM, densest network in DE/NL; bbox-filterable
JSON (`data.sensor.community`), daily CSV archive. **OPEN (ODbL-family, attr).**
Noisy per-sensor — hex-bin into a heatmap. RIVM **Samen Meten** (SensorThings API)
offers RIVM-calibrated versions of the same citizen sensors.
- **EEA** (Europe): the old file dumps are discontinued; current Download Service
serves Parquet timeseries + modelled rasters (**ATTR**). Europe-wide
historical/modelled; use Luchtmeetnet for NL live.
### Noise
**RIVM "Geluid in Nederland"** is the winner: whole-NL 10 m Lden/Lnight rasters per
source (highways, municipal roads, rail, aviation, industry, wind turbines) as
**CC0 GeoTIFF downloads** (`data.rivm.nl/data/alo/...`) + WMS. Bake our own raster
tiles (path B). Official END round-4 contour *polygons* (also CC0) on PDOK/haleconnect
if we want crisp vectors; EEA publishes the Europe-wide equivalents (**ATTR**, country
completeness varies).
### Land cover
**CORINE CLC2018** (100 m, Europe) — still the latest until CLC2024 lands mid-2026;
GeoTIFF via land.copernicus.eu (EU-Login) or a no-login EEA WMS mirror. **ATTR.**
**CLC+ Backbone 10 m** (2023 edition) is the modern high-res version — gorgeous
green/urban/water backdrop at z13+. Both EPSG:3035 → one ingest-time reprojection.
### Protected nature
**PDOK Natura 2000**: direct 10.7 MB GeoPackage, **CC0**, verified — ship it offline
as vector-tile polygons (path A). Also CC0 on PDOK: Wetlands/Ramsar (3.8 MB),
Nationale Parken (3.25 MB), Natuurnetwerk Nederland. Europe: EEA Natura 2000 bundle
(2.3 GB SHP/GPKG, **ATTR**). **WDPA/Protected Planet is RESTRICTED** (redistribution
ban incl. offline maps) — not needed, the open sets cover Europe.
### Flood risk (very Dutch, very good)
- **LIWO** (Rijkswaterstaat): public GeoServer, no login — max water depth per
probability class, with **WCS** access to pull actual depth grids for offline
baking. De-facto open, attribute RWS.
- **Klimaateffectatlas WMS**: the same national data with a clean **CC BY 4.0**
citation surface; three probability classes as UI granularity.
- **PDOK ROR** flood risk/hazard zone polygons: **CC0**, tiny — the cheap
"am I in a flood zone?" layer.
- **JRC Europe river flood hazard** v3.1.1: depth GeoTIFFs per return period,
~90 m, WGS84, anonymous FTP-style download (RP100 = 323 MB), **ATTR**. The
Europe-wide layer.
### Demographics
- **CBS Wijk- en Buurtkaart** (2025 on PDOK): neighborhood polygons with population,
age, income, density, housing — the canonical NL choropleth. GPKG ~261 MB.
**ATTR (cite CBS + Kadaster).**
- **CBS 100 m / 500 m grids** (Vierkantstatistieken, direct zips from
`download.cbs.nl/vierkant/`): per-cell population, age/sex, housing, WOZ value,
energy consumption — **the best 3D-extrusion layer we could ship** (100 m at high
zoom, 500 m as LOD). **ATTR.** Cells under 5 residents are suppressed.
- **Eurostat GISCO Census 2021 grid V3** (May 2026): 1 km grid, 30 countries,
13 variables, GeoParquet/GPKG/GeoTIFF. **ATTR.** Pairs with the CBS grid inside NL.
- **CBS StatLine OData**: join any statistic onto wijk/buurt codes at ingest, no key.
- **GHSL** population/built-up rasters (see §2) as the global fallback.
### Historic maps
No official open Topotijdreis WMTS exists. The clean route: **RCE Bonnebladen ~1900**
via RCE's geo services (**ATTR**, pre-1930 imagery PD by age) — a "year slider"
overlay anchored on ~1900 vs today. The per-year Esri NL "tijdreis" tile services
work but are legally gray (unofficial). Pan-European: Arcanum is commercial, David
Rumsey is **NC** — skip both.
### Wikipedia / Wikidata ("what's around me")
Wikipedia geosearch API (no key, radius ≤10 km, descriptive User-Agent), Wikidata
SPARQL for bulk coordinates (**coordinates CC0**), Commons geosearch for geotagged
photos. Best value-per-effort in this whole document: article-card popups on map
points (path C), cacheable offline. NL Wikipedia + Rijksmonumenten together make a
genuinely good heritage layer.
### Fun extras (all verified, all NL unless noted)
- **Rijksmonumenten** (~63k national monuments): RCE WFS/WMS, nightly refresh,
**ATTR**.
- **Kadastrale kaart (BRK)**: PDOK serves **native MVT vector tiles** — drop-in
cadastral parcel lines at high zoom, **ATTR**. (The BAG OGC API also serves native
MVT with bouwjaar — an alternative to our own join for quick experiments.)
- **Groningen earthquakes**: KNMI FDSN event API, **ATTR** — bubble map.
- **Trees**: Amsterdam serves ~300k trees with species/year as **native MVT**
(`api.data.amsterdam.nl/v1/mvt/bomen`); Rotterdam etc. on data.overheid.nl.
- **Solar potential per roof**: Zonatlas-derived national dataset, Public Domain
Mark, but dated — verify before building on it.
- **Energy labels** (EP-Online/RVO): monthly dump, free key, joins on BAG ids —
**no open license: usable, don't redistribute.** AG choropleth per building.
- **AEDs, windmills, etc.**: no open national registers — OSM/Overpass has them.
---
## The EV thread (elevation + chargers + weather, combined)
The pieces above compose into proper EV routing (extends `gps.md` M3):
1. **Energy-aware graph**: at `nav-build` time, sample AHN (NL) / GLO-30 (Europe)
along every edge → per-edge climb/descent meters packed next to length/speed in
`region.graph`. Cost function becomes physics: rolling + aero (speed²) + mass ×
climb regen × descent. Flat NL barely notices; the first Ardennen/Alpen trip does.
2. **Range visualization**: energy-cost Dijkstra from current position + state of
charge → **reachable-area polygon** shaded on the map ("can I make it without
charging?"), recomputed as you drive.
3. **Charger-aware planning**: NDW OCPI + OCM chargers as graph POIs; multi-stop
route planning picks charging stops by detour cost + connector/power filtering.
4. **Weather correction**: Open-Meteo along-route wind + temperature → headwind term
in the aero cost and a cold-battery efficiency factor. This is what makes highway
range predictions honest.
## Licensing traps (one-screen summary)
| Source | Trap |
|---|---|
| RainViewer | personal/educational only now; free tier gutted |
| Buienradar | apps need written permission — use KNMI (its upstream) |
| Blitzortung | participants only, no commercial |
| EOX Sentinel-2 cloudless | 2018+ mosaics are CC-BY-**NC**; only 2016 is CC-BY |
| Copernicus EEA-10 DEM | access-restricted — use GLO-30 |
| EUBUCCO / DBSM | ODbL share-alike — mind derived-database obligations |
| Fietsplatform routes | purpose-limited license — OSM has the networks anyway |
| NS API | proprietary terms and redundant — OVapi is CC0 |
| Transitous hosted API | non-commercial fair-use — self-host MOTIS instead |
| OpenSky / aisstream / Open-Meteo hosted | free tiers are non-commercial |
| WDPA / Protected Planet | redistribution ban (incl. offline maps) — use Natura 2000/CDDA |
| David Rumsey / Arcanum historic maps | NC / commercial — use RCE Bonnebladen |
| EP-Online energy labels | no open license — use, don't redistribute |
| Esri NL "tijdreis" historic tiles | no stated terms — legally gray, unofficial |
| Everything NL-official (BAG, AHN, luchtfoto, NDW, OVapi, RWS, RIVM noise, PDOK nature/flood) | no traps — CC0/CC-BY, bundle away |
## Suggested build order
1. **Building age (path A)**`start_date` is already in our PBF; a tag to keep +
a color ramp. One day of work for the most beautiful quick win; the Waag map from
OSM data we already have.
2. **Raster overlay layer in the renderer (path B)** — the enabling feature. First
consumer: hillshade from Mapterhorn PMTiles (static, offline, no cadence
pressure). Second: rain radar animation (OPERA COG → colormapped frames + the
KNMI 2 h nowcast for NL) — the "amazing" one. Third: the wind particle layer
(§1 wind fields) — reuses the same time-dimension machinery plus one GPU
particle pass; bootstrap on GFS bbox subsets (bytes per frame, no key).
3. **Live transit + traffic (path C)** — OVapi vehicle positions as moving markers;
NDW travel-time coloring on trunk roads. Both are single-file pollers feeding the
existing overlay machinery.
4. **Elevation into the graph (path D)** — EV/bike energy-aware routing + range
polygon; quiet infrastructure, big payoff once routes leave the polder.
5. **3D BAG + AHN terrain (path E)** — the showcase: LoD2.2 roofs + real terrain in
the xr/3D track.
Once paths AC exist, §4 provides cheap follow-on layers that reuse them: Natura 2000
polygons (10.7 MB CC0 GPKG → path A), RIVM noise + JRC flood rasters (CC0/CC-BY
GeoTIFFs → path B), Wikipedia/Rijksmonumenten points (path C), and the CBS 100 m grid
as the first 3D-extrusion statistics layer (path E).

View file

@ -14,9 +14,6 @@ pub struct Cx2d<'a, 'b> {
pub(crate) overlay_id: Option<DrawListId>,
pub(crate) overlay_pass_id: Option<DrawPassId>,
pub(crate) overlay_draw_depth: usize,
/// Increments once per overlay sub-list begun this frame; stamped onto the
/// sub-list so the Overlay can composite in draw order.
pub(crate) overlay_seq: u64,
//pub (crate) overlay_sweep_lock: Option<Rc<RefCell<Area>>>,
pub(crate) turtles: Vec<Turtle>,
@ -49,7 +46,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
overlay_id: None,
overlay_pass_id: None,
overlay_draw_depth: 0,
overlay_seq: 0,
cx,
turtle_clips: Vec::with_capacity(1024),
finished_rows: Vec::with_capacity(1024),

View file

@ -248,14 +248,6 @@ impl DrawList2d {
cx.draw_lists[overlay_id].store_sub_list(redraw_id, self.draw_list.id());
}
// Stamp the draw ORDER. `store_sub_list` above only decides which slot
// this list occupies (first free, kept forever) — it says nothing about
// paint order, which is what a caller drawing one glass surface over
// another actually means. `Overlay::end` sorts by this.
cx.overlay_seq += 1;
let seq = cx.overlay_seq;
cx.draw_lists[self.draw_list.id()].overlay_order = seq;
if !self.overlay_active {
self.overlay_active = true;
cx.overlay_draw_depth += 1;

View file

@ -51,44 +51,6 @@ pub struct VectorVertex {
pub zbias: f32,
}
/// Packed VectorVertex: 12 f32 slots carrying the 19 logical fields —
/// f16 pairs / unorm8x4 bitcast into single slots, unpacked in the vertex
/// shader (unpack2f16/unpack4u8). Halves vertex fetch bandwidth; the
/// precision-critical slots (positions, stroke_mult sentinels, param4
/// icon-floor composite, param5 depth ladder, zbias 1e-6 steps) stay f32.
#[derive(Clone, Script, ScriptHook)]
pub struct VectorVertexPacked {
#[live]
pub x: f32,
#[live]
pub y: f32,
/// f16(u) | f16(v)
#[live]
pub uv: f32,
/// unorm8 r|g|b|a
#[live]
pub color: f32,
#[live]
pub stroke_mult: f32,
#[live]
pub stroke_dist: f32,
/// f16(param0) | f16(shape_id)
#[live]
pub p0s: f32,
/// f16(param1) | f16(param2)
#[live]
pub p12: f32,
/// f16(param3) | f16(clip_radius, clamped)
#[live]
pub p3c: f32,
#[live]
pub param4: f32,
#[live]
pub param5: f32,
#[live]
pub zbias: f32,
}
#[derive(Clone, Script, ScriptHook)]
pub struct PbrVertex {
#[live]
@ -101,37 +63,6 @@ pub struct PbrVertex {
pub tangent: Vec4f, // tangent xyz + handedness
}
/// Packed mesh vertex: 6 f32 slots instead of PbrVertex's 16, for streams
/// where fetch bandwidth matters more than the last bit of precision —
/// CPU-skinned characters (re-uploaded every frame), terrain, shadow meshes.
///
/// Same idea as [`VectorVertexPacked`]: position stays f32 because it is
/// precision-critical, everything else is an f16 pair or a unorm8 quad
/// bitcast into one slot and unpacked in the vertex shader
/// (`unpack2f16` / `unpack4u8`). Normals are octahedral-encoded, which is
/// what lets a 3-component unit vector fit in two f16 lanes.
#[derive(Clone, Script, ScriptHook)]
pub struct GameMeshVertex {
// Flat f32 fields, not a Vec3f: std140 pads a vec3 to 16 bytes, which
// would not match the Rust repr(C) size. VectorVertexPacked does the
// same for the same reason.
#[live]
pub px: f32,
#[live]
pub py: f32,
#[live]
pub pz: f32,
/// f16(oct.x) | f16(oct.y) — octahedral unit normal.
#[live]
pub nrm: f32,
/// f16(u) | f16(v)
#[live]
pub uv: f32,
/// unorm8 r|g|b|a
#[live]
pub color: f32,
}
#[derive(Clone, Script, ScriptHook)]
pub struct CubeVertex {
#[live]
@ -188,17 +119,10 @@ pub fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
set_script_value_to_pod!(vm, geom.VectorVertex);
let vgen = shared(vm, id!(VectorGeom), GeometryGen::from_triangle_2d);
set_script_value!(vm, geom.VectorGeom = vgen);
set_script_value_to_pod!(vm, geom.VectorVertexPacked);
let vpgen = shared(vm, id!(VectorGeomPacked), GeometryGen::from_triangle_2d_packed);
set_script_value!(vm, geom.VectorGeomPacked = vpgen);
// PBR geometry: vertex type + placeholder geom (overridden at draw time)
set_script_value_to_pod!(vm, geom.PbrVertex);
let pgen = shared(vm, id!(PbrGeom), GeometryGen::from_triangle_pbr);
set_script_value!(vm, geom.PbrGeom = pgen);
// Packed game mesh geometry: 6-slot vertex for bandwidth-bound streams.
set_script_value_to_pod!(vm, geom.GameMeshVertex);
let gmgen = shared(vm, id!(GameMeshGeom), GeometryGen::from_triangle_game_mesh);
set_script_value!(vm, geom.GameMeshGeom = gmgen);
// Cube geometry: unit cube in the old geom_pos/geom_normal/geom_uv layout.
set_script_value_to_pod!(vm, geom.CubeVertex);
let cgen = shared(vm, id!(CubeGeom), || {
@ -241,22 +165,6 @@ impl GeometryGen {
}
/// Placeholder single-triangle geometry for vector drawing (overridden at draw time)
pub fn from_triangle_2d_packed() -> GeometryGen {
let mut g = Self::default();
for _ in 0..3 {
g.vertices.extend_from_slice(&crate::vector::pack_vector_record(&[
0.0, 0.0, 0.5, 1.0,
1.0, 1.0, 1.0, 1.0,
1e6, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0,
0.0,
]));
}
g.indices.extend_from_slice(&[0, 1, 2]);
g
}
pub fn from_triangle_2d() -> GeometryGen {
let mut g = Self::default();
// 3 vertices with full VectorVertex stride (23 floats each)
@ -297,18 +205,6 @@ impl GeometryGen {
g
}
/// Placeholder single triangle in the packed GameMeshVertex stride.
pub fn from_triangle_game_mesh() -> GeometryGen {
let mut g = Self::default();
for _ in 0..3 {
// pos, nrm(oct 0,0 = +y), uv, color(white)
g.vertices
.extend_from_slice(&[0.0, 0.0, 0.0, 0.0, 0.0, f32::from_bits(u32::MAX)]);
}
g.indices.extend_from_slice(&[0, 1, 2]);
g
}
/// Placeholder single-triangle geometry for minimal faceted-vertex drawing.
pub fn from_triangle_ico() -> GeometryGen {
let mut g = Self::default();

View file

@ -35,7 +35,6 @@ pub use crate::{
nav::{NavItem, NavOrder, NavRole, NavScrollIndex, NavStop},
overlay::Overlay,
scene_3d::{SceneDrawCallAnchor, SceneScope3D, SceneState3D},
vector::{pack_pair_f16, pack_unorm8x4},
scene_sun::{
SceneSun, ShinyConfig, MAT_CANOPY, MAT_GREEN, MAT_NONE, MAT_ROOF, MAT_ROUTE_GLOW,
MAT_SHADOW, MAT_WALL, MAT_WATER,

View file

@ -31,7 +31,6 @@ impl Overlay {
// mark our overlay_id on cx
cx.overlay_id = Some(self.draw_list.id());
cx.overlay_pass_id = None;
cx.overlay_seq = 0;
// cx.overlay_sweep_lock = Some(self.sweep_lock.clone());
}
@ -39,7 +38,6 @@ impl Overlay {
// mark our overlay_id on cx
cx.overlay_id = Some(self.draw_list.id());
cx.overlay_pass_id = Some(pass_id);
cx.overlay_seq = 0;
// cx.overlay_sweep_lock = Some(self.sweep_lock.clone());
}
@ -82,32 +80,5 @@ impl Overlay {
}
}
}
// Composite in DRAW order, not slot order.
//
// A glass surface keeps whichever overlay slot it first claimed
// (`store_sub_list` takes the first free one and never moves it), so
// slot order is really creation order — a home widget rebuilt after a
// layout change, or a panel opened later, lands wherever there happens
// to be a hole. That is why glass appeared on top of things drawn after
// it. Each sub-list was stamped with its draw position in
// `begin_overlay_inner`; ordering by that stamp makes glass obey the
// same rule as everything else: later drawn, later painted.
let list_id = self.draw_list.id();
let len = cx.draw_lists[list_id].draw_items.len();
let mut keys: Vec<u64> = Vec::with_capacity(len);
for i in 0..len {
let sub = cx.draw_lists[list_id].draw_items[i].sub_list();
let order = sub
.and_then(|sub_id| cx.draw_lists.checked_index(sub_id))
.map(|sub_list| sub_list.overlay_order)
.unwrap_or(0);
keys.push(order);
}
let mut order: Vec<usize> = (0..len).collect();
// Stable, so entries that didn't draw this frame keep their relative
// positions instead of shuffling.
order.sort_by_key(|&i| keys[i]);
cx.draw_lists[list_id].draw_item_reorder = Some(order);
}
}

View file

@ -27,12 +27,6 @@ script_mod! {
cam_c: uniform(0.0)
cam_d: uniform(1.0)
cam_pivot: uniform(vec2(0.0, 0.0))
// Pan/zoom delta applied BEFORE the camera matrix: glyphs are
// emitted in CACHED placement space and ride these uniforms every
// frame, exactly like tile geometry rides map_offset — no CPU
// re-transform between frames, so labels can never trail the map.
cam_scale: uniform(1.0)
cam_shift: uniform(vec2(0.0, 0.0))
// self.upright (instance from the Rust struct): 1.0 = screen-upright
// label (place names, pin/brand text) — its ANCHOR tracks the camera
// delta but its orientation must not; the re-place keeps such labels
@ -49,22 +43,14 @@ script_mod! {
scaled.x * sn + scaled.y * cs
) + origin
if self.upright > 0.5 {
let anchor2 = origin * self.cam_scale + self.cam_shift
let anchor_rel = anchor2 + vec2(0.0, self.lift) - self.cam_pivot
let anchor_rel = origin + vec2(0.0, self.lift) - self.cam_pivot
let cam_anchor = vec2(
anchor_rel.x * self.cam_a + anchor_rel.y * self.cam_b,
anchor_rel.x * self.cam_c + anchor_rel.y * self.cam_d
) + self.cam_pivot - vec2(0.0, self.lift)
var offs = rotated - origin
if self.billboard < 0.5 {
// Street-cap/city names scale with the gesture; text
// inside zoom-constant pins keeps its pixel size.
offs = offs * self.cam_scale
}
rotated = offs + cam_anchor
rotated = rotated - origin + cam_anchor
} else {
let q = rotated * self.cam_scale + self.cam_shift
let cam_rel = q + vec2(0.0, self.lift) - self.cam_pivot
let cam_rel = rotated + vec2(0.0, self.lift) - self.cam_pivot
rotated = vec2(
cam_rel.x * self.cam_a + cam_rel.y * self.cam_b,
cam_rel.x * self.cam_c + cam_rel.y * self.cam_d
@ -134,10 +120,6 @@ pub struct DrawRotatedText {
/// anchor and re-applies the lift, so lifted labels track rotation.
#[live(0.0)]
pub lift: f32,
/// 1.0 = pin-interior text: anchor tracks the pan/zoom delta but glyph
/// offsets and size stay constant screen px (like the pin mesh).
#[live(0.0)]
pub billboard: f32,
}
impl DrawRotatedText {
@ -152,14 +134,6 @@ impl DrawRotatedText {
self.draw_vars
.set_uniform(cx, live_id!(cam_pivot), &[pivot.x, pivot.y]);
}
/// Pan/zoom delta uniforms applied before the camera matrix: cached
/// glyphs render at `p * scale + shift` per frame, GPU-side.
pub fn set_pan_delta(&mut self, cx: &mut Cx, scale: f32, shift: Vec2f) {
self.draw_vars.set_uniform(cx, live_id!(cam_scale), &[scale]);
self.draw_vars
.set_uniform(cx, live_id!(cam_shift), &[shift.x, shift.y]);
}
}
/// A single glyph positioned along a path, ready to draw.
@ -259,7 +233,6 @@ impl DrawRotatedText {
let saved_font_scale = self.draw_super.font_scale;
self.draw_super.font_scale = 1.0;
self.upright = 0.0;
self.billboard = 0.0;
for glyph in glyphs {
self.draw_glyph_at(
cx,
@ -296,7 +269,6 @@ impl DrawRotatedText {
let saved_font_scale = self.draw_super.font_scale;
self.draw_super.font_scale = 1.0;
self.upright = 1.0;
self.billboard = 1.0;
let scaled_anchor =
Point::new(anchor.x * scale + offset.x, anchor.y * scale + offset.y);
for glyph in glyphs {
@ -314,7 +286,6 @@ impl DrawRotatedText {
);
}
self.upright = 0.0;
self.billboard = 0.0;
self.draw_super.font_scale = saved_font_scale;
}
@ -335,7 +306,6 @@ impl DrawRotatedText {
let saved_font_scale = self.draw_super.font_scale;
self.draw_super.font_scale = 1.0;
self.upright = 1.0;
self.billboard = 0.0;
let anchor = Point::new(anchor.x * scale + offset.x, anchor.y * scale + offset.y);
for glyph in glyphs {
self.draw_glyph_at(

View file

@ -44,54 +44,49 @@ script_mod! {
// final anchor so turtle alignment can move it like DrawQuad.
let transformed_local = self.transform_svg_point(pos * self.svg_scale + self.svg_offset);
let transformed = transformed_local + self.rect_pos;
let g_uv = unpack2f16(self.geom.uv)
let g_color = unpack4u8(self.geom.color)
let g_p0s = unpack2f16(self.geom.p0s)
let g_p12 = unpack2f16(self.geom.p12)
let g_p3c = unpack2f16(self.geom.p3c)
self.v_tcoord = g_uv;
self.v_color = g_color;
self.v_tcoord = vec2(self.geom.u, self.geom.v);
self.v_color = vec4(self.geom.color_r, self.geom.color_g, self.geom.color_b, self.geom.color_a);
self.v_stroke_mult = self.geom.stroke_mult;
self.v_stroke_dist = self.geom.stroke_dist;
self.v_shape_id = g_p0s.y;
self.v_param0 = g_p0s.x;
self.v_shape_id = self.geom.shape_id;
self.v_param0 = self.geom.param0;
self.v_param5 = self.geom.param5;
// Transform gradient geometry params by svg_scale/svg_offset and custom hook
let grad_type = g_p0s.x;
let grad_type = self.geom.param0;
if grad_type > 0.5 && grad_type < 1.5 {
// Linear gradient: p1,p2 = start point, p3,p4 = end point
let p0 = self.transform_svg_point(g_p12 * self.svg_scale + self.svg_offset) + self.rect_pos;
let p1 = self.transform_svg_point(vec2(g_p3c.x, self.geom.param4) * self.svg_scale + self.svg_offset) + self.rect_pos;
let p0 = self.transform_svg_point(vec2(self.geom.param1, self.geom.param2) * self.svg_scale + self.svg_offset) + self.rect_pos;
let p1 = self.transform_svg_point(vec2(self.geom.param3, self.geom.param4) * self.svg_scale + self.svg_offset) + self.rect_pos;
self.v_param1 = p0.x;
self.v_param2 = p0.y;
self.v_param3 = p1.x;
self.v_param4 = p1.y;
} else if grad_type > 1.5 {
// Radial gradient: p1,p2 = center, p3,p4 = rx, ry
let center = self.transform_svg_point(g_p12 * self.svg_scale + self.svg_offset) + self.rect_pos;
let center = self.transform_svg_point(vec2(self.geom.param1, self.geom.param2) * self.svg_scale + self.svg_offset) + self.rect_pos;
self.v_param1 = center.x;
self.v_param2 = center.y;
self.v_param3 = g_p3c.x * self.svg_scale.x;
self.v_param3 = self.geom.param3 * self.svg_scale.x;
self.v_param4 = self.geom.param4 * self.svg_scale.y;
} else if g_p0s.y > 0.5 {
} else if self.geom.shape_id > 0.5 {
// Effect shape with bbox in params: transform bbox by svg_scale/svg_offset
let bbox_min = self.transform_svg_point(g_p12 * self.svg_scale + self.svg_offset) + self.rect_pos;
let bbox_max = self.transform_svg_point(vec2(g_p3c.x, self.geom.param4) * self.svg_scale + self.svg_offset) + self.rect_pos;
let bbox_min = self.transform_svg_point(vec2(self.geom.param1, self.geom.param2) * self.svg_scale + self.svg_offset) + self.rect_pos;
let bbox_max = self.transform_svg_point(vec2(self.geom.param3, self.geom.param4) * self.svg_scale + self.svg_offset) + self.rect_pos;
self.v_param1 = bbox_min.x;
self.v_param2 = bbox_min.y;
self.v_param3 = bbox_max.x;
self.v_param4 = bbox_max.y;
} else {
self.v_param1 = g_p12.x;
self.v_param2 = g_p12.y;
self.v_param3 = g_p3c.x;
self.v_param1 = self.geom.param1;
self.v_param2 = self.geom.param2;
self.v_param3 = self.geom.param3;
self.v_param4 = self.geom.param4;
}
let shifted = transformed + self.draw_list.view_shift;
self.v_world = shifted;
// Early clip rejection in final draw space.
let cr = g_p3c.y * max(abs(self.svg_scale.x), abs(self.svg_scale.y));
let cr = self.geom.clip_radius * max(abs(self.svg_scale.x), abs(self.svg_scale.y));
let is_shadow = self.geom.stroke_mult < -0.5;
if cr > 0.0 && !is_shadow {
let clip = vec4(

View file

@ -1292,11 +1292,6 @@ pub struct DrawText {
#[live]
pub temp_y_shift: f32,
/// Centering height (in unscaled lpxs) for RowAlign::Center; TextFlow sets
/// this to the line style's height so mixed-font runs stay baseline-aligned.
#[rust]
pub align_row_height: Option<f32>,
/// Per-row horizontal alignment applied by the text layouter when the
/// text does not fill the full `max_width_in_lpxs`. `x: 0.0` = left,
/// `0.5` = center, `1.0` = right. `y` is currently unused by the
@ -2162,46 +2157,9 @@ impl DrawText {
} = walk.width
{
if let Some(resolved) = max_bound.eval_width(cx) {
let turtle = cx.turtle();
let padding = turtle.padding();
// The bound limits this walk's margin box, so the text
// itself gets the bound minus the walk's own horizontal
// margins and the enclosing turtle's padding.
let mut available =
resolved - padding.left - padding.right - walk.margin.width();
// When the enclosing turtle is itself an unresolved Fit
// with a max bound (a Label sizing itself around this
// text), its final width is clamped to the bound minus
// its outer margins; the text must shrink by the same
// amount, or the clamped clip slices off its tail.
if turtle.width().is_nan()
&& matches!(
turtle.walk().width,
crate::turtle::Size::Fit { max: Some(_), .. }
)
{
available -= turtle.walk().margin.width();
}
// The layouter works in unscaled units; rows are multiplied
// by font_scale on output, so the bound must be divided by
// it here, as max_layout_width_for_walk does.
let available_in_lpxs =
(available.max(0.0) as f32) / self.font_scale.max(0.0001);
// A bound too narrow for even the truncation ellipsis is no
// bound at all: the layouter appends the ellipsis glyph
// unconditionally, so a narrower clip would slice it open.
// Left unbounded, the text overflows honestly, which lets
// an enclosing flow's inline-content clamp hide it and
// draw the ellipsis itself.
let below_ellipsis_width = self.text_overflow == TextOverflow::Ellipsis
&& available_in_lpxs
< self
.layout(cx, 0.0, 0.0, None, false, Align::default(), "")
.size_in_lpxs
.width;
if !below_ellipsis_width {
max_width_in_lpxs = Some(available_in_lpxs);
}
let padding = cx.turtle().padding();
max_width_in_lpxs =
Some((resolved - padding.left - padding.right).max(0.0) as f32);
}
}
}
@ -2257,7 +2215,6 @@ impl DrawText {
cx.cx.debug.area(area, vec4(1.0, 1.0, 1.0, 1.0));
}
let origin_in_lpxs = Point::new(turtle_rect.pos.x as f32, turtle_rect.pos.y as f32);
self.draw_text(cx, origin_in_lpxs, laidout_text);
@ -2312,7 +2269,7 @@ impl DrawText {
let turtle_rect = cx.turtle().inner_rect();
let origin_in_lpxs = Point::new(turtle_rect.pos.x as f32, turtle_pos.y as f32);
let first_row_indent_in_lpxs = turtle_pos.x as f32 - origin_in_lpxs.x;
let row_height = self.resumable_first_row_min_spacing(cx) as f64;
let row_height = cx.turtle().next_row_offset();
let max_width_in_lpxs = if !turtle_rect.size.x.is_nan() {
Some(turtle_rect.size.x as f32)
} else {
@ -2358,7 +2315,6 @@ impl DrawText {
#[cfg(any(target_os = "linux", target_os = "windows"))]
let per_row = false;
if per_row {
// ── PER-ROW path ──
// Draw each visual row as a separate instance batch so its glyphs
@ -2448,15 +2404,12 @@ impl DrawText {
// Emit a per-row FinishedWalk covering this row's glyphs +
// callback rect-areas.
cx.emit_turtle_walk_with_align_height(
cx.emit_turtle_walk(
Rect {
pos: dvec2(row_origin.x as f64, row_top_y),
size: dvec2((row.width_in_lpxs * self.font_scale) as f64, row_h),
},
row_als,
Metrics::default(),
self.align_row_height
.map(|h| (h * self.font_scale) as f64),
);
}
@ -2499,6 +2452,16 @@ impl DrawText {
self.draw_text(cx, origin_in_lpxs, &text);
let turtle = cx.turtle_mut();
turtle.move_to(dvec2(origin_in_lpxs.x as f64, origin_in_lpxs.y as f64));
turtle.allocate_width(used_size_in_lpxs.width as f64);
turtle.allocate_height(used_size_in_lpxs.height as f64);
turtle.move_to(new_turtle_pos);
turtle.set_wrap_spacing(
(last_row.ascender_in_lpxs * last_row.line_spacing_scale
- last_row.ascender_in_lpxs) as f64,
);
if !callback_before_text {
for (row_index, row) in text.rows.iter().enumerate() {
let (sx, ex) = row_span_x_bounds_in_lpxs(
@ -2524,169 +2487,21 @@ impl DrawText {
}
}
let wrap_spacing = (last_row.ascender_in_lpxs * last_row.line_spacing_scale
- last_row.ascender_in_lpxs) as f64;
if text.rows.len() == 1 {
let turtle = cx.turtle_mut();
turtle.move_to(dvec2(origin_in_lpxs.x as f64, origin_in_lpxs.y as f64));
turtle.allocate_width(used_size_in_lpxs.width as f64);
turtle.allocate_height(used_size_in_lpxs.height as f64);
turtle.move_to(new_turtle_pos);
turtle.set_wrap_spacing(wrap_spacing);
cx.emit_turtle_walk_with_align_height(
Rect {
pos: new_turtle_pos,
size: dvec2(
used_size_in_lpxs.width as f64,
used_size_in_lpxs.height as f64,
),
},
align_list_start,
Metrics::default(),
self.align_row_height.map(|h| (h * self.font_scale) as f64),
);
} else {
// The turtle's row bookkeeping must see this run's internal
// row boundaries: RowAlign shifts and row heights are computed
// per turtle row when `finish_row` runs, and without a boundary
// here every walk since the previous one — including content
// that sits on an earlier visual row — is aligned against the
// final row's height. Only the finished-walk and row
// bookkeeping is per-row; the glyphs stay in one instance
// batch, so no per-row shifting of this run's own rows is
// possible and its walks carry ranges accordingly.
let fs = self.font_scale as f64;
let row_top = |row: &crate::text::layouter::LaidoutRow| {
origin_in_lpxs.y as f64
+ (row.origin_in_lpxs.y - row.ascender_in_lpxs) as f64 * fs
};
let row_height = |row: &crate::text::layouter::LaidoutRow| {
(row.ascender_in_lpxs - row.descender_in_lpxs) as f64 * fs
};
let first_row = &text.rows[0];
let turtle = cx.turtle_mut();
turtle.move_to(dvec2(origin_in_lpxs.x as f64, origin_in_lpxs.y as f64));
turtle.allocate_width(used_size_in_lpxs.width as f64);
turtle.allocate_height(row_height(first_row));
// The first row's walk owns every align entry this run emitted
// and is immovable — shifting it would displace the later
// rows' glyphs, which share its instance batch. When the first
// row holds visible text it anchors its row's center line, so
// a taller inline item beside it centers on the text; when it
// holds nothing visible — no glyphs, or only whitespace (a
// continuation that wrapped immediately, often with a single
// leading space fitting the line's slack) — the walk is inert,
// so the row of preceding content keeps its normal alignment
// instead of anchoring to an invisible line.
let first_row_role = if first_row.text.trim().is_empty() {
crate::turtle::RowAlignRole::Fixed
} else {
crate::turtle::RowAlignRole::Anchor
};
cx.emit_turtle_walk_with_role(
Rect {
pos: dvec2(origin_in_lpxs.x as f64, origin_in_lpxs.y as f64),
size: dvec2(
(first_row.width_in_lpxs * self.font_scale) as f64,
row_height(first_row),
),
},
align_list_start,
Metrics::default(),
self.align_row_height.map(|h| (h * self.font_scale) as f64),
first_row_role,
);
for row_index in 1..text.rows.len() {
// A new turtle row starts at origin.y + used_height +
// spacing. Anchoring the spacing to the turtle's real used
// bottom — which includes any inline content on the
// previous visual row that is taller than the text — lands
// the turtle exactly on this laidout row's top, so turtle
// geometry and glyph geometry agree at every internal row
// boundary.
let used_bottom = cx.turtle().origin().y + cx.turtle().used_height();
let spacing = row_top(&text.rows[row_index]) - used_bottom;
cx.turtle_new_line_with_spacing(spacing);
cx.turtle_mut().allocate_height(row_height(&text.rows[row_index]));
}
let turtle = cx.turtle_mut();
turtle.move_to(new_turtle_pos);
turtle.set_wrap_spacing(wrap_spacing);
// The last row's walk carries an empty align range: this run's
// glyphs live in the first row's walk and must not be shifted
// again when the last row finishes. Because those glyphs can
// never move, the walk anchors the row's center line — a
// taller item that joins this row (an inline pill) centers on
// the text instead of dragging the row's center below it.
let empty_range = cx.align_list_len();
cx.emit_turtle_walk_with_role(
Rect {
pos: new_turtle_pos,
size: dvec2(
(last_row.width_in_lpxs * self.font_scale) as f64,
row_height(last_row),
),
},
empty_range,
Metrics::default(),
Some(row_height(last_row)),
crate::turtle::RowAlignRole::Anchor,
);
}
cx.emit_turtle_walk(
Rect {
pos: new_turtle_pos,
size: dvec2(
used_size_in_lpxs.width as f64,
used_size_in_lpxs.height as f64,
),
},
align_list_start,
);
}
(text.rows.len(), text.is_truncated)
}
/// Returns the `first_row_min_line_spacing_below_in_lpxs` a resumable run
/// passes to the layouter for the current turtle. In a centering wrap flow
/// this is the offset to the next turtle row (converted into layout units
/// by dividing out `font_scale`), so a continuation run's second row
/// clears inline content on its first visual row that is taller than the
/// text. Other flows pass zero and keep pure font-metric row spacing.
/// Every layout of the same run (draw, selection capture, wrap probes)
/// must use this same value so they share one layout-cache entry.
pub fn resumable_first_row_min_spacing(&self, cx: &Cx2d) -> f32 {
if matches!(
cx.turtle().layout().flow,
Flow::Right {
wrap: true,
row_align: crate::turtle::RowAlign::Center,
..
}
) {
// When the current row holds inline content taller than the text
// line (pills), the following row is expected to hold the same
// kind of content beside this run's text, and RowAlign::Center
// will seat that content's center on the text's center — placing
// its top (row_height text_height)/2 ABOVE the text's top. The
// floor therefore positions the second row's text one full
// current-row advance below, plus that overhang, so centered
// content on the next row starts exactly one wrap gap below the
// current row's content with zero residual shift. When the text
// is the tallest content, the floor equals `next_row_offset()`
// and pure font-metric spacing wins where it is larger.
let row_height = cx.turtle().row_height();
let text_row_height = self
.align_row_height
.map(|h| (h * self.font_scale) as f64)
.unwrap_or(row_height);
let overhang = ((row_height - text_row_height) * 0.5).max(0.0);
((row_height + cx.turtle().wrap_spacing() + overhang)
/ self.font_scale.max(0.0001) as f64) as f32
} else {
0.0
}
}
#[allow(clippy::too_many_arguments)]
pub fn layout(
&self,

View file

@ -14,7 +14,7 @@ script_mod! {
draw_call: uniform_buffer(draw.DrawCallUniforms)
draw_pass: uniform_buffer(draw.DrawPassUniforms)
draw_list: uniform_buffer(draw.DrawListUniforms)
geom: vertex_buffer(geom.VectorVertexPacked, geom.VectorGeomPacked)
geom: vertex_buffer(geom.VectorVertex, geom.VectorGeom)
gradient_texture: texture_2d(float)
v_tcoord: varying(vec2f)
@ -33,27 +33,22 @@ script_mod! {
vertex: fn() {
let pos = vec2(self.geom.x, self.geom.y);
let g_uv = unpack2f16(self.geom.uv)
let g_color = unpack4u8(self.geom.color)
let g_p0s = unpack2f16(self.geom.p0s)
let g_p12 = unpack2f16(self.geom.p12)
let g_p3c = unpack2f16(self.geom.p3c)
self.v_tcoord = g_uv;
self.v_color = g_color;
self.v_tcoord = vec2(self.geom.u, self.geom.v);
self.v_color = vec4(self.geom.color_r, self.geom.color_g, self.geom.color_b, self.geom.color_a);
self.v_stroke_mult = self.geom.stroke_mult;
self.v_stroke_dist = self.geom.stroke_dist;
self.v_shape_id = g_p0s.y;
self.v_param0 = g_p0s.x;
self.v_param1 = g_p12.x;
self.v_param2 = g_p12.y;
self.v_param3 = g_p3c.x;
self.v_shape_id = self.geom.shape_id;
self.v_param0 = self.geom.param0;
self.v_param1 = self.geom.param1;
self.v_param2 = self.geom.param2;
self.v_param3 = self.geom.param3;
self.v_param4 = self.geom.param4;
self.v_param5 = self.geom.param5;
let shifted = pos + self.draw_list.view_shift;
self.v_world = shifted;
// Early clip rejection in local space.
let cr = unpack2f16(self.geom.p3c).y;
let cr = self.geom.clip_radius;
let is_shadow = self.geom.stroke_mult < -0.5;
if cr > 0.0 && !is_shadow {
let clip = vec4(
@ -721,9 +716,7 @@ impl DrawVector {
}
let geom = self.geometry.get_or_insert_with(|| Geometry::new(cx.cx.cx));
let mut packed = crate::vector::pack_vector_vertices(&self.acc_verts);
let mut indices = self.acc_indices.clone();
geom.update_with_recycled_buffers(cx.cx.cx, &mut indices, &mut packed);
geom.update_with_recycled_buffers(cx.cx.cx, &mut self.acc_indices, &mut self.acc_verts);
self.draw_vars.geometry_id = Some(geom.geometry_id());
cx.new_draw_call(&self.draw_vars);
if self.draw_vars.can_instance() {

View file

@ -402,12 +402,8 @@ script_mod! {
box: fn(x: float, y: float, w: float, h: float, r: float) {
let p = self.pos - vec2(x, y);
let size = vec2(0.5 * w, 0.5 * h);
// The effective visual radius is 2*r. Clamp it to the half-size:
// past that the SDF used to degenerate into a rotated diamond
// (e.g. a 22px disc with r=11), instead of saturating at a circle.
let k = min(2. * r, min(size.x, size.y));
let bp = max(abs(p - size.xy) - (size.xy - vec2(k, k).xy), vec2(0., 0.));
self.dist = (length(bp) - k) / self.scale_factor;
let bp = max(abs(p - size.xy) - (size.xy - vec2(2. * r, 2. * r).xy), vec2(0., 0.));
self.dist = (length(bp) - 2. * r) / self.scale_factor;
self.old_shape = self.shape;
self.shape = min(self.shape, self.dist);
}
@ -417,17 +413,13 @@ script_mod! {
let p_r = self.pos - vec2(x, y);
let p = abs(p_r - size.xy) - size.xy;
// Clamp each (doubled) radius to the half-size so oversized radii
// saturate at a capsule instead of degenerating into a diamond.
let k_top = min(2. * r_top, min(size.x, size.y));
let k_bottom = min(2. * r_bottom, min(size.x, size.y));
let q_top = p + vec2(k_top, k_top).xy;
let q_bottom = p + vec2(k_bottom, k_bottom).xy;
let q_top = p + vec2(2. * r_top, 2. * r_top).xy;
let q_bottom = p + vec2(2. * r_bottom, 2. * r_bottom).xy;
// The min(max(q.x,q.y),0) interior term keeps the field continuous; without it
// the interior dist was -2r, so switching radius at the midpoint left a coverage seam.
let dist_top = min(max(q_top.x, q_top.y), 0.) + length(max(q_top, vec2(0., 0.))) - k_top;
let dist_bottom = min(max(q_bottom.x, q_bottom.y), 0.) + length(max(q_bottom, vec2(0., 0.))) - k_bottom;
let dist_top = min(max(q_top.x, q_top.y), 0.) + length(max(q_top, vec2(0., 0.))) - 2. * r_top;
let dist_bottom = min(max(q_bottom.x, q_bottom.y), 0.) + length(max(q_bottom, vec2(0., 0.))) - 2. * r_bottom;
self.dist = mix(dist_top, dist_bottom, step(0.5 * h, p_r.y)) / self.scale_factor;
@ -440,16 +432,12 @@ script_mod! {
let p_r = self.pos - vec2(x, y);
let p = abs(p_r - size.xy) - size.xy;
// Clamp each (doubled) radius to the half-size so oversized radii
// saturate at a capsule instead of degenerating into a diamond.
let k_left = min(2. * r_left, min(size.x, size.y));
let k_right = min(2. * r_right, min(size.x, size.y));
let bp_left = max(p + vec2(k_left, k_left).xy, vec2(0., 0.));
let bp_right = max(p + vec2(k_right, k_right).xy, vec2(0., 0.));
let bp_left = max(p + vec2(2. * r_left, 2. * r_left).xy, vec2(0., 0.));
let bp_right = max(p + vec2(2. * r_right, 2. * r_right).xy, vec2(0., 0.));
self.dist = mix(
(length(bp_left) - k_left),
(length(bp_right) - k_right),
(length(bp_left) - 2. * r_left),
(length(bp_right) - 2. * r_right),
step(0.5 * w, p_r.x)
) / self.scale_factor;
@ -471,26 +459,20 @@ script_mod! {
let p_r = self.pos - vec2(x, y);
let p = abs(p_r - size.xy) - size.xy;
// Clamp each (doubled) radius to the half-size so oversized radii
// saturate instead of degenerating into a diamond.
let k_lt = min(2. * r_left_top, min(size.x, size.y));
let k_rt = min(2. * r_right_top, min(size.x, size.y));
let k_rb = min(2. * r_right_bottom, min(size.x, size.y));
let k_lb = min(2. * r_left_bottom, min(size.x, size.y));
let bp_lt = max(p + vec2(k_lt, k_lt).xy, vec2(0., 0.));
let bp_rt = max(p + vec2(k_rt, k_rt).xy, vec2(0., 0.));
let bp_rb = max(p + vec2(k_rb, k_rb).xy, vec2(0., 0.));
let bp_lb = max(p + vec2(k_lb, k_lb).xy, vec2(0., 0.));
let bp_lt = max(p + vec2(2. * r_left_top, 2. * r_left_top).xy, vec2(0., 0.));
let bp_rt = max(p + vec2(2. * r_right_top, 2. * r_right_top).xy, vec2(0., 0.));
let bp_rb = max(p + vec2(2. * r_right_bottom, 2. * r_right_bottom).xy, vec2(0., 0.));
let bp_lb = max(p + vec2(2. * r_left_bottom, 2. * r_left_bottom).xy, vec2(0., 0.));
self.dist = mix(
mix(
(length(bp_lt) - k_lt),
(length(bp_lb) - k_lb),
(length(bp_lt) - 2. * r_left_top),
(length(bp_lb) - 2. * r_left_bottom),
step(0.5 * h, p_r.y)
),
mix(
(length(bp_rt) - k_rt),
(length(bp_rb) - k_rb),
(length(bp_rt) - 2. * r_right_top),
(length(bp_rb) - 2. * r_right_bottom),
step(0.5 * h, p_r.y)
),
step(0.5 * w, p_r.x)

View file

@ -400,14 +400,6 @@ impl LayoutContext {
.map_or(false, |max| self.rows.len() >= max)
}
/// Whether the row currently being laid out is the last one `max_rows`
/// permits.
fn current_row_is_last_allowed(&self) -> bool {
self.options
.max_rows
.map_or(false, |max| self.rows.len() + 1 >= max)
}
fn layout(&mut self, len: usize) {
if self.remaining_width_in_lpxs().is_none() {
self.layout_directly(len);
@ -433,21 +425,6 @@ impl LayoutContext {
let next_word = &self.text[self.current_row_end..][..fitter.next_len()];
if next_word.chars().all(|char| char.is_whitespace()) {
self.layout_directly(fitter.pop());
} else if self.options.ellipsis
&& self.current_row_is_last_allowed()
&& !self.current_row_is_continuation()
{
// The last permitted row ends in an ellipsis, so word
// integrity is moot: fill it to the width limit by
// grapheme so the ellipsis truncates at the last glyph
// that fits instead of at the last whole word — a word
// that wraps away from this row would otherwise leave
// it ellipsized far short of the available width.
// Continuation rows are excluded: grapheme layout
// force-places a grapheme wider than an empty row's
// remnant past the width limit, unflagged, whereas
// finishing the row truncates within bounds.
self.layout_by_grapheme(fitter.pop());
} else if self.current_row_is_empty() && !self.current_row_is_continuation() {
self.layout_by_grapheme(fitter.pop());
} else {
@ -538,21 +515,7 @@ impl LayoutContext {
self.current_point_in_lpxs.x = 0.0;
self.current_point_in_lpxs.y += self.rows.last().map_or(row.ascender_in_lpxs, |prev_row| {
let natural_in_lpxs = prev_row.line_spacing_in_lpxs(&row);
if self.rows.len() == 1 {
// The first row can share its visual row with earlier inline
// content that is taller than the text; the caller passes that
// row's real height (plus wrap spacing) so the second row's
// top edge clears it. The floor is a top-to-top distance, so
// it converts to a baseline advance by adding the change in
// ascender between the two rows.
let min_advance_in_lpxs = self.options.first_row_min_line_spacing_below_in_lpxs
+ row.ascender_in_lpxs
- prev_row.ascender_in_lpxs;
natural_in_lpxs.max(min_advance_in_lpxs)
} else {
natural_in_lpxs
}
prev_row.line_spacing_in_lpxs(&row)
});
let max_width_in_lpxs = self.options.max_width_in_lpxs.unwrap_or(row.width_in_lpxs);
let remaining_width_in_lpxs = max_width_in_lpxs - row.width_in_lpxs;
@ -613,24 +576,10 @@ impl LayoutContext {
}
};
let mut text_was_truncated = self.rows.len() > max_rows || !all_text_consumed;
let text_was_truncated = self.rows.len() > max_rows || !all_text_consumed;
self.rows.truncate(max_rows);
// A non-wrapping layout puts every glyph on a single row, so its
// overflow shows up as a row wider than the bound rather than as
// surplus rows. Row counting alone therefore reports "nothing was
// truncated" for the very case the ellipsis exists to handle.
// Restricted to non-wrapping layouts because a wrapped row's width
// includes any first-row indent and may legitimately reach the bound.
if self.options.ellipsis && !self.options.wrap {
if let Some(max_width) = self.options.max_width_in_lpxs {
if self.rows.last().is_some_and(|row| row.width_in_lpxs > max_width) {
text_was_truncated = true;
}
}
}
if !text_was_truncated {
return self.finish_with(false);
}
@ -1041,12 +990,8 @@ impl PartialEq for Style {
#[derive(Clone, Copy, Debug)]
pub struct LayoutOptions {
pub first_row_indent_in_lpxs: f32,
/// Minimum distance in logical pixels from the first row's top edge to the
/// second row's top edge. A continuation run's first row can share its
/// visual row with earlier inline content that is taller than the text;
/// callers pass that row's real height (plus wrap spacing) so the second
/// row clears it. Zero keeps pure font-metric spacing. Only the first row
/// boundary is affected; later rows always use font-metric spacing.
// Note: currently does nothing. Only used by `TextFlow`. Should be removed once `TextFlow` is
// replaced with `TextFlow2`.
pub first_row_min_line_spacing_below_in_lpxs: f32,
pub max_width_in_lpxs: Option<f32>,
pub wrap: bool,

View file

@ -253,27 +253,6 @@ impl Shaper {
}
#[allow(clippy::too_many_arguments)]
/// Logs one warning per unique codepoint that no loaded font can render,
/// so ".notdef" boxes in the UI are explained in the log instead of being
/// silently drawn (a missing arrow/symbol glyph is otherwise very hard to
/// distinguish from a layout bug).
fn warn_missing_glyph_once(text: &str, cluster: usize) {
use std::collections::HashSet;
use std::sync::Mutex;
static WARNED: Mutex<Option<HashSet<char>>> = Mutex::new(None);
let Some(ch) = text.get(cluster..).and_then(|s| s.chars().next()) else {
return;
};
let mut warned = WARNED.lock().unwrap();
if warned.get_or_insert_with(HashSet::new).insert(ch) {
crate::makepad_platform::log!(
"no loaded font has a glyph for '{}' (U+{:04X}); rendering .notdef",
ch,
ch as u32
);
}
}
fn shape_recursive(
&mut self,
text: &str,
@ -365,11 +344,6 @@ impl Shaper {
}
} else {
let glyph_group = glyph_groups[i];
if remaining_fonts.is_empty() {
for glyph in glyph_group.iter().filter(|glyph| glyph.id == 0) {
Self::warn_missing_glyph_once(text, glyph.cluster);
}
}
// If we've exhausted all fallback fonts and still have
// unmapped glyphs (id == 0), use the primary font's .notdef
// so a visible placeholder is rendered instead of nothing.

View file

@ -358,11 +358,6 @@ pub enum Base {
#[pick]
Full,
Unused,
/// The width available on the enclosing line for inline content,
/// accounting for the enclosing widget's own leading geometry and
/// trailing insets; see [`Cx2d::find_line_available_width`]. Widths
/// only: as a height base this resolves to nothing.
Line,
}
/// Specifies how walks should be laid out with respect to each other.
@ -665,20 +660,6 @@ impl Turtle {
self.layout.padding.right = right;
}
/// Sets whether this turtle's `Flow::Right` layout wraps onto a new row,
/// leaving any other flow untouched.
///
/// Turning wrapping off confines the following walks to the current row,
/// which is how a caller that has run out of rows to give keeps an
/// oversized walk from opening one anyway. Such a walk overruns the row's
/// width instead, exactly as unwrappable text does. Save the previous
/// setting via [`Turtle::layout`] and restore it when done.
pub fn set_flow_wrap(&mut self, wrap: bool) {
if let Flow::Right { wrap: flow_wrap, .. } = &mut self.layout.flow {
*flow_wrap = wrap;
}
}
/// Returns the alignment of each walk of this turtle with respect to it's rectangle.
pub fn align(&self) -> Align {
self.layout.align
@ -822,9 +803,6 @@ impl Turtle {
match base {
Base::Full => self.width(),
Base::Unused => self.unused_width(),
// A line bound spans the whole turtle stack, not one turtle;
// it is resolved by `Cx2d::find_line_available_width`.
Base::Line => f64::NAN,
}
}
@ -832,7 +810,6 @@ impl Turtle {
match base {
Base::Full => self.height(),
Base::Unused => self.unused_height(),
Base::Line => f64::NAN,
}
}
@ -1293,37 +1270,6 @@ pub struct FinishedWalk {
outer_size: Vec2d,
metrics: Metrics,
/// How row alignment may treat this walk (see [`RowAlignRole`]).
align_role: RowAlignRole,
/// Height to center by under `RowAlign::Center` instead of `outer_size.y`.
/// Text runs pass their line's height here so mixed-font runs on a row all
/// get the same shift and keep their relative baselines.
align_height: Option<f64>,
}
/// How row alignment may treat a finished walk.
///
/// A multi-row text run draws all of its glyphs in one instance batch, so no
/// individual row of it can ever be repositioned; the run's per-row walks are
/// therefore immovable and declare one of the non-`Shiftable` roles.
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub enum RowAlignRole {
/// Row alignment may shift this walk's align range.
#[default]
Shiftable,
/// Immovable, and under `RowAlign::Center` the row's center line anchors
/// to this walk's own center instead of the tallest walk's, so every
/// shiftable walk on the row — including a taller one, which then shifts
/// UP — centers on it. Declared by a wrapped run's rows that hold visible
/// text.
Anchor,
/// Immovable and inert: neither shifted nor an anchor. Declared by a
/// wrapped run's first-row walk when that row holds no glyphs (the run
/// wrapped immediately), so a row of other content is not anchored to an
/// invisible line.
Fixed,
}
/// The horizontal shift that centers a `Flow::Right` row's actually-drawn content
@ -1398,9 +1344,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
}
pub fn find_base_width(&self, base: Base) -> Option<f64> {
if let Base::Line = base {
return self.find_line_available_width();
}
self.turtles
.iter()
.rev()
@ -1410,9 +1353,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
}
pub fn find_base_height(&self, base: Base) -> Option<f64> {
if let Base::Line = base {
return None;
}
self.turtles
.iter()
.rev()
@ -1421,65 +1361,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
.find(|height| !height.is_nan())
}
/// Returns the width available on the enclosing line for the current
/// turtle's content, for a [`Base::Line`] bound.
///
/// The line is the nearest enclosing turtle with a known width; the
/// unresolved `Fit` turtles between it and the current one (an inline
/// widget's nesting levels) contribute their leading geometry and
/// trailing insets. The line's flow selects between two measurements,
/// each of which is final at the moment it is taken:
///
/// - A wrapping line can relocate the inline widget whole onto a fresh
/// row, so the bound is what a fresh row offers: the line's inner
/// width minus the widget-internal lead-in before this turtle and
/// the trailing insets after it. Content sized to this bound either
/// fits where it is, or fits the row the widget is relocated to.
/// - A non-wrapping line (including one held non-wrapping by an
/// inline-content clamp on the last permitted row) keeps the widget
/// where it is, so the bound is the remnant: the distance from the
/// pen to the line's inner right edge, minus the trailing insets.
///
/// The current turtle's own trailing margin is left for the consumer,
/// which resolves a `Fit` max bound against the walk's margin box. Its
/// leading margin is part of the measured lead-in (the turtle's origin
/// lies past it), so a consumer that subtracts the full margin width
/// counts the leading side twice — a deliberately conservative overlap
/// of a few pixels that keeps a remnant-fitted walk safely inside the
/// line's overrun tolerance.
pub fn find_line_available_width(&self) -> Option<f64> {
let (current, outer_turtles) = self.turtles.split_last()?;
// Measured from the current turtle's content origin, not its pen:
// the bound is evaluated both before the content is laid out and
// again when the turtle closes (the `Fit` max clamp), and the two
// must agree — the origin is the content's start either way.
let start_x = current.origin().x + current.padding().left;
let mut trailing = current.padding().right;
// The outermost unresolved turtle inside the line so far: the
// inline widget's root, whose origin marks where its lead-in
// (icons, padding, spacing before this content) begins.
let mut widget_origin_x = current.origin().x;
let mut widget_margin_left = 0.0;
for turtle in outer_turtles.iter().rev() {
if !turtle.width().is_nan() {
let inner = turtle.inner_rect();
let available = match turtle.layout().flow {
Flow::Right { wrap: false, .. } => {
inner.pos.x + inner.size.x - start_x - trailing
}
Flow::Right { wrap: true, .. } | Flow::Down | Flow::Overlay => {
inner.size.x - widget_margin_left - (start_x - widget_origin_x) - trailing
}
};
return Some(available.max(0.0));
}
trailing += turtle.padding().right + turtle.walk().margin.right;
widget_origin_x = turtle.origin().x;
widget_margin_left = turtle.walk().margin.left;
}
None
}
/// Starts a root turtle.
pub fn begin_root_turtle(&mut self, size: Vec2d, layout: Layout) {
self.align_list
@ -1621,11 +1502,7 @@ impl<'a, 'b> Cx2d<'a, 'b> {
///
/// The current turtle should be finished with the same guard area that was used to start it.
pub fn end_turtle_with_guard(&mut self, guard: Area) -> Rect {
// The final row's bottom forgiveness is deliberately discarded: the
// turtle's used height keeps that row's full physical extent, so
// up-centered walks on a last row stay inside the reported rect and
// its clip bottom.
let _ = self.finish_row(self.align_list.len());
self.finish_row(self.align_list.len());
self.compute_final_size();
let mut turtle = self.turtles.last_mut().unwrap();
@ -1883,13 +1760,8 @@ impl<'a, 'b> Cx2d<'a, 'b> {
turtle = self.turtles.last_mut().unwrap();
if let Some(max) = max {
// take the margin into account when calculating a Fit bound
// with a relative-to-parent max value. The floor keeps a
// bound smaller than the margins (a near-zero line
// remnant) from producing a negative width and an
// inverted clip.
turtle.width = turtle
.width
.min((max - turtle.walk.margin.width()).max(0.0));
// with a relative-to-parent max value.
turtle.width = turtle.width.min(max - turtle.walk.margin.width());
}
}
}
@ -1926,7 +1798,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
clip_max.y = clip_min.y + turtle.height();
}
};
}
/// Computes the maximum available height for the current turtle by walking up
@ -2058,8 +1929,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
deferred_before_count: 0,
outer_size: size + walk.margin.size(),
metrics: walk.metrics,
align_role: RowAlignRole::Shiftable,
align_height: None,
});
let origin = outer_origin + walk.margin.left_top();
@ -2102,7 +1971,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
}
};
let defer_index = self.turtle().deferred_fills.len();
self.turtle_mut().current_row_metrics =
self.turtle().current_row_metrics.max(walk.metrics);
@ -2111,8 +1979,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
deferred_before_count: defer_index,
outer_size,
metrics: walk.metrics,
align_role: RowAlignRole::Shiftable,
align_height: None,
});
let origin = outer_origin + walk.margin.left_top();
@ -2293,31 +2159,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
rect: Rect,
align_list_start: usize,
metrics: Metrics,
) {
self.emit_turtle_walk_with_align_height(rect, align_list_start, metrics, None)
}
/// Like [`emit_turtle_walk_with_metrics`] but also sets the walk's
/// centering height for `RowAlign::Center` (see `FinishedWalk::align_height`).
pub fn emit_turtle_walk_with_align_height(
&mut self,
rect: Rect,
align_list_start: usize,
metrics: Metrics,
align_height: Option<f64>,
) {
self.emit_turtle_walk_with_role(rect, align_list_start, metrics, align_height, RowAlignRole::Shiftable)
}
/// Like [`emit_turtle_walk_with_align_height`] but with an explicit
/// [`RowAlignRole`].
pub fn emit_turtle_walk_with_role(
&mut self,
rect: Rect,
align_list_start: usize,
metrics: Metrics,
align_height: Option<f64>,
align_role: RowAlignRole,
) {
let turtle = self.turtles.last().unwrap();
self.finished_walks.push(FinishedWalk {
@ -2325,8 +2166,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
deferred_before_count: turtle.deferred_fills.len(),
outer_size: rect.size,
metrics,
align_role,
align_height,
});
}
@ -2378,20 +2217,7 @@ impl<'a, 'b> Cx2d<'a, 'b> {
}
pub fn turtle_new_line_internal(&mut self, spacing: f64, align_list_start: usize) {
let row_bottom_forgiveness = self.finish_row(align_list_start);
if row_bottom_forgiveness > 0.0 {
// An anchored row's up-centered walks overhang the row's anchor
// symmetrically, and the top overhang already intrudes into the
// gap above the row; forgiving the same amount below it keeps the
// gaps on both sides of the row equal. The reduction happens only
// on the new-line path — a turtle's final row is finished by
// `end_turtle_with_guard`, which discards the forgiveness — so a
// turtle's reported height always covers its last row's full
// physical extent.
let used_width = self.turtle().used_width();
let reduced_used_height = self.turtle().used_height() - row_bottom_forgiveness;
self.turtle_mut().set_used(used_width, reduced_used_height);
}
self.finish_row(align_list_start);
let new_pos = dvec2(
self.turtle().origin.x + self.turtle().padding().left,
self.turtle().origin.y + self.turtle().used_height() + spacing,
@ -2400,34 +2226,28 @@ impl<'a, 'b> Cx2d<'a, 'b> {
self.turtle_mut().allocate_height(0.0);
}
/// Finishes the current row: applies its row alignment and rolls the row
/// bookkeeping forward.
///
/// Returns the row's bottom forgiveness (see [`Cx2d::finish_row_center`]);
/// rows under `RowAlign::Top` and `RowAlign::Bottom` always return zero.
fn finish_row(&mut self, align_list_start: usize) -> f64 {
fn finish_row(&mut self, align_list_start: usize) {
let row_align = if let Flow::Right { row_align, .. } = self.turtle().flow() {
row_align
} else {
RowAlign::Top
};
let row_bottom_forgiveness = match row_align {
match row_align {
RowAlign::Top => {
// No per-walk shifts needed — items stay at the row top.
0.0
}
RowAlign::Bottom => {
self.finish_row_bottom(align_list_start);
0.0
}
RowAlign::Center => self.finish_row_center(align_list_start),
};
RowAlign::Center => {
self.finish_row_center(align_list_start);
}
}
self.turtle_mut().prev_row_metrics = self.turtle().current_row_metrics;
self.turtle_mut().current_row_metrics = Metrics::default();
self.finished_rows.push(self.finished_walks.len());
row_bottom_forgiveness
}
/// Baseline-aligns every finished walk in the current row so that its
@ -2476,11 +2296,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
let finished_walks_start = self.current_row_walks_start();
let finished_walks_end = self.finished_walks.len();
for finished_walk_index in finished_walks_start..finished_walks_end {
// Immovable walks (a wrapped run's rows — their glyphs live in one
// shared batch) cannot be baseline-shifted either.
if self.finished_walks[finished_walk_index].align_role != RowAlignRole::Shiftable {
continue;
}
let finished_walk_height = self.finished_walks[finished_walk_index].outer_size.y;
let finished_walk_metrics = self.finished_walks[finished_walk_index].metrics;
@ -2495,7 +2310,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
// The total amount by which we have to shift the current finished walk.
let shift = descender_shift + baseline_shift + line_spacing_shift;
let start = self.finished_walks[finished_walk_index].align_list_start;
let end = if finished_walk_index + 1 < self.finished_walks.len() {
self.finished_walks[finished_walk_index + 1].align_list_start
@ -2529,93 +2343,28 @@ impl<'a, 'b> Cx2d<'a, 'b> {
/// Vertically centers every finished walk in the current row on the row's
/// vertical center line.
///
/// Without an anchor walk, the row centers on its tallest walk: shifts are
/// downward only and every walk stays entirely within the row's bounds, so
/// `used_height` is untouched. With an anchor walk (an immovable wrapped-run
/// row), every shiftable walk centers on the anchor's center line instead,
/// so a walk taller than the anchor shifts UP and overhangs the anchor's
/// box symmetrically — by equal amounts above and below it.
/// The row height is the max height of any walk on the row, so the tallest
/// walk (e.g. an inline pill widget) has a zero shift and stays put. Shorter
/// walks (e.g. surrounding text) are shifted down by half the difference,
/// so their vertical centers land on the same horizontal line as the tallest
/// walk's center. For symmetrically-padded pills this makes the pill's
/// internal text visually align with the surrounding text.
///
/// An up-shift is clamped so that no walk's top rises above the turtle's
/// own rectangle top: that edge is also the turtle's clip top, and content
/// shifted above it renders with its top edge cut off. The clamp can only
/// restrict an up-shift; it never turns one into a downward shift.
///
/// Returns the row's bottom forgiveness: how far the row's allocated
/// bottom extent hangs below the bottom of its anchor-centered content,
/// capped at twice the largest up-shift actually applied. The new-line
/// path subtracts this from the advance to the next row, so a centered
/// walk's symmetric overhang intrudes equally into the row gaps above and
/// below it instead of pushing the next row further down. Anchorless rows
/// return zero, which leaves the advance untouched.
fn finish_row_center(&mut self, align_list_start: usize) -> f64 {
/// This alignment does not grow the row's `used_height` — every walk stays
/// entirely within the row's bounds because each shift is at most
/// `(row_height - walk_height) / 2` and each walk has height
/// `<= row_height`.
fn finish_row_center(&mut self, align_list_start: usize) {
let current_row_height = self.turtle().row_height();
let finished_walks_start = self.current_row_walks_start();
let finished_walks_end = self.finished_walks.len();
// An anchor walk stands for content that cannot be shifted, so the
// row's center line is anchored to its center rather than the tallest
// walk's. Shiftable walks then move toward that line in either
// direction: a taller item (an inline pill beside a wrapped run's
// text row) moves UP to center on the text. Without an anchor, the
// row centers on its tallest walk and shifts are downward only.
let mut anchor_center: Option<f64> = None;
for finished_walk_index in finished_walks_start..finished_walks_end {
let finished_walk = &self.finished_walks[finished_walk_index];
if finished_walk.align_role == RowAlignRole::Anchor {
let center = finished_walk
.align_height
.unwrap_or(finished_walk.outer_size.y)
* 0.5;
anchor_center = Some(anchor_center.map_or(center, |c: f64| c.max(center)));
}
}
let finished_walk_height = self.finished_walks[finished_walk_index].outer_size.y;
let shift = (current_row_height - finished_walk_height) * 0.5;
// The largest post-shift "effective bottom" of any walk on this row,
// relative to the row top: the walk's own box displaced by the shift
// that was actually applied to it. An up-shifted walk's bottom rises
// by exactly its shift, so the row's visual extent ends that much
// above its allocation, and only that surplus may be forgiven.
let mut max_effective_bottom: f64 = 0.0;
// The largest upward shift actually applied on this row; it bounds
// the returned forgiveness so that allocations no walk accounts for
// (pre-allocated text row boxes, vertical margins) are never forgiven.
let mut max_up_overhang: f64 = 0.0;
for finished_walk_index in finished_walks_start..finished_walks_end {
let finished_walk = &self.finished_walks[finished_walk_index];
if finished_walk.align_role != RowAlignRole::Shiftable {
max_effective_bottom = max_effective_bottom.max(finished_walk.outer_size.y);
continue;
}
let finished_walk_height = finished_walk
.align_height
.unwrap_or(finished_walk.outer_size.y);
let shift = match anchor_center {
Some(center) => {
// The row top is the turtle's position while the row
// finishes; the walk's top after shifting is the row top
// plus the shift. A negative bound keeps the walk's top at
// or below the turtle's own rectangle top (its clip top);
// the `min(0.0)` keeps the bound from ever forcing a
// downward shift.
let min_shift =
(self.turtle().origin().y - self.turtle().pos().y).min(0.0);
(center - finished_walk_height * 0.5).max(min_shift)
}
None => (current_row_height - finished_walk_height) * 0.5,
};
let applied = !((anchor_center.is_none() && shift <= 0.0) || shift == 0.0);
let applied_shift = if applied { shift } else { 0.0 };
max_up_overhang = max_up_overhang.max((-applied_shift).max(0.0));
max_effective_bottom =
max_effective_bottom.max(finished_walk.outer_size.y + applied_shift);
if !applied {
if shift <= 0.0 {
continue;
}
@ -2627,13 +2376,6 @@ impl<'a, 'b> Cx2d<'a, 'b> {
};
self.move_align_list(start, end, 0.0, shift, false);
}
if anchor_center.is_none() {
return 0.0;
}
let row_bottom_forgiveness = (current_row_height - max_effective_bottom.max(0.0))
.clamp(0.0, max_up_overhang);
row_bottom_forgiveness
}
/// Shifts the rendered content in the align list range `[start, end)` by

View file

@ -2,89 +2,6 @@ use makepad_svg::path::{LineCap, LineJoin, VectorPath};
use makepad_svg::tessellate::{compute_clip_radii, Tessellator, VVertex};
pub const VECTOR_FLOATS_PER_VERTEX: usize = 19;
/// Packed GPU layout: see `pack_vector_record` / VectorVertexPacked.
pub const VECTOR_PACKED_FLOATS_PER_VERTEX: usize = 12;
#[inline]
fn f16_bits(value: f32) -> u32 {
// IEEE 754 binary16 encode (round-to-nearest-even, clamps to inf).
let bits = value.to_bits();
let sign = (bits >> 16) & 0x8000;
let exp = ((bits >> 23) & 0xff) as i32;
let frac = bits & 0x007f_ffff;
if exp == 0xff {
return sign | 0x7c00 | if frac != 0 { 0x200 } else { 0 };
}
let e = exp - 127 + 15;
if e >= 0x1f {
return sign | 0x7c00;
}
if e <= 0 {
if e < -10 {
return sign;
}
let frac = frac | 0x0080_0000;
let shift = (14 - e) as u32;
let half = frac >> shift;
let rem = frac & ((1 << shift) - 1);
let round = (rem > (1 << (shift - 1)))
|| (rem == (1 << (shift - 1)) && (half & 1) != 0);
return sign | (half + round as u32);
}
let half = ((e as u32) << 10) | (frac >> 13);
let rem = frac & 0x1fff;
let round = (rem > 0x1000) || (rem == 0x1000 && (half & 1) != 0);
sign | (half + round as u32)
}
/// Two floats into one f32 slot as an f16 pair; unpacked in-shader with
/// `unpack2f16`. Public so other packed vertex layouts reuse this rounding
/// rather than growing a second, subtly different implementation.
#[inline]
pub fn pack_pair_f16(a: f32, b: f32) -> f32 {
f32::from_bits(f16_bits(a) | (f16_bits(b) << 16))
}
/// Four 0..1 channels into one f32 slot as unorm8x4; unpacked in-shader
/// with `unpack4u8`.
#[inline]
pub fn pack_unorm8x4(r: f32, g: f32, b: f32, a: f32) -> f32 {
let q = |x: f32| (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
f32::from_bits(q(r) | (q(g) << 8) | (q(b) << 16) | (q(a) << 24))
}
/// One 19-float logical record -> the 12-slot packed layout.
#[inline]
pub fn pack_vector_record(record: &[f32]) -> [f32; VECTOR_PACKED_FLOATS_PER_VERTEX] {
[
record[0],
record[1],
pack_pair_f16(record[2], record[3]),
pack_unorm8x4(record[4], record[5], record[6], record[7]),
record[8],
// stroke_dist stays f32: multi-km merged roads exceed f16 range
// (inf -> NaN varyings) and dash phase needs the precision.
record[9],
pack_pair_f16(record[11], record[10]),
pack_pair_f16(record[12], record[13]),
// clip_radius clamped into f16 range: huge radii mean "never
// clipped" either way.
pack_pair_f16(record[14], record[17].min(60000.0)),
record[15],
record[16],
record[18],
]
}
/// Pack a whole 19-stride vertex buffer for GPU upload.
pub fn pack_vector_vertices(vertices: &[f32]) -> Vec<f32> {
let count = vertices.len() / VECTOR_FLOATS_PER_VERTEX;
let mut out = Vec::with_capacity(count * VECTOR_PACKED_FLOATS_PER_VERTEX);
for record in vertices.chunks_exact(VECTOR_FLOATS_PER_VERTEX) {
out.extend_from_slice(&pack_vector_record(record));
}
out
}
pub const VECTOR_ZBIAS_STEP: f32 = 0.000001;
/// Selects DrawVector's signed-coordinate analytic fill fringe. Ordinary
/// fills use `1e6`; a distinct sentinel lets the same vertex format carry a
@ -257,21 +174,22 @@ pub fn append_expanded_stroke_geometry(
let ramp = (total_dist * 0.35).min(96.0).max(1e-3);
let base = (acc_verts.len() / VECTOR_FLOATS_PER_VERTEX) as u32;
let start = acc_verts.len();
let floats = verts.len() * VECTOR_FLOATS_PER_VERTEX;
// One resize + slot writes into the zeroed tail: the per-vertex
// extend_from_slice of a stack array still re-checked capacity and
// copied through a temporary 19 floats at a time — measurable on the
// face/fringe path that now routes every morphable surface here.
acc_verts.resize(start + floats, 0.0);
let shape_id = params.shape_id + EXPAND_STROKE_SHAPE_OFFSET;
let decked = deck_m > 0.0 || deck_override.is_some();
for (vi, ((v, anchor), record)) in verts
.iter()
.zip(anchors)
.zip(acc_verts[start..].chunks_exact_mut(VECTOR_FLOATS_PER_VERTEX))
.enumerate()
{
for (vi, (v, anchor)) in verts.iter().zip(anchors).enumerate() {
acc_verts.push(anchor[0]);
acc_verts.push(anchor[1]);
acc_verts.push(v.u);
acc_verts.push(v.v);
acc_verts.push(params.color[0]);
acc_verts.push(params.color[1]);
acc_verts.push(params.color[2]);
acc_verts.push(params.color[3]);
acc_verts.push(params.stroke_mult);
acc_verts.push(v.stroke_dist);
acc_verts.push(params.shape_id + EXPAND_STROKE_SHAPE_OFFSET);
acc_verts.push(params.params[0]);
acc_verts.push(v.x - anchor[0]);
acc_verts.push(v.y - anchor[1]);
acc_verts.push(expand_class);
let deck_v = if let Some(decks) = deck_override {
decks.get(vi).copied().unwrap_or(0.0)
} else if deck_m > 0.0 {
@ -281,36 +199,22 @@ pub fn append_expanded_stroke_geometry(
} else {
params.params[4]
};
acc_verts.push(deck_v);
// A lifted deck is semantically ABOVE whatever it crosses: bump its
// tilt micro-depth with the lift, or high-rank strokes underneath
// (rail over secondary) still depth-win near the crossing.
let param5 = if decked {
acc_verts.push(if deck_m > 0.0 || deck_override.is_some() {
params.params[5] + 0.30 * (deck_v / 2.0).min(1.0)
} else {
params.params[5]
};
record[0] = anchor[0];
record[1] = anchor[1];
record[2] = v.u;
record[3] = v.v;
record[4] = params.color[0];
record[5] = params.color[1];
record[6] = params.color[2];
record[7] = params.color[3];
record[8] = params.stroke_mult;
record[9] = v.stroke_dist;
record[10] = shape_id;
record[11] = params.params[0];
record[12] = v.x - anchor[0];
record[13] = v.y - anchor[1];
record[14] = expand_class;
record[15] = deck_v;
record[16] = param5;
record[17] = v.clip_radius;
record[18] = params.zbias;
});
acc_verts.push(v.clip_radius);
acc_verts.push(params.zbias);
}
acc_indices.extend(indices.iter().map(|&idx| base + idx));
for &idx in indices {
acc_indices.push(base + idx);
}
}
pub fn append_tessellated_geometry(
@ -340,39 +244,37 @@ pub fn append_tessellated_geometry_decked(
}
let base = (acc_verts.len() / VECTOR_FLOATS_PER_VERTEX) as u32;
acc_verts.reserve(verts.len() * VECTOR_FLOATS_PER_VERTEX);
for (vi, v) in verts.iter().enumerate() {
let deck_v = match deck_override {
Some(decks) => decks.get(vi).copied().unwrap_or(0.0),
None => params.params[4],
};
let param5 = if deck_v > 0.0 {
acc_verts.push(v.x);
acc_verts.push(v.y);
acc_verts.push(v.u);
acc_verts.push(v.v);
acc_verts.push(params.color[0]);
acc_verts.push(params.color[1]);
acc_verts.push(params.color[2]);
acc_verts.push(params.color[3]);
acc_verts.push(params.stroke_mult);
acc_verts.push(v.stroke_dist);
acc_verts.push(params.shape_id);
acc_verts.push(params.params[0]);
acc_verts.push(params.params[1]);
acc_verts.push(params.params[2]);
acc_verts.push(params.params[3]);
acc_verts.push(deck_v);
acc_verts.push(if deck_v > 0.0 {
params.params[5] + 0.30 * (deck_v / 2.0).min(1.0)
} else {
params.params[5]
};
acc_verts.extend_from_slice(&[
v.x,
v.y,
v.u,
v.v,
params.color[0],
params.color[1],
params.color[2],
params.color[3],
params.stroke_mult,
v.stroke_dist,
params.shape_id,
params.params[0],
params.params[1],
params.params[2],
params.params[3],
deck_v,
param5,
v.clip_radius,
params.zbias,
]);
});
acc_verts.push(v.clip_radius);
acc_verts.push(params.zbias);
}
acc_indices.extend(indices.iter().map(|&idx| base + idx));
for &idx in indices {
acc_indices.push(base + idx);
}
}

View file

@ -6,15 +6,8 @@ app_main!(App);
script_mod! {
use mod.prelude.widgets.*
let state = {
counter: 0
}
mod.state = state
startup() do #(App::script_component(vm)){
ui: Root{
on_startup:||{ // right now render isnt called automatically yet
ui.main_view.render()
}
main_window := Window{
window.inner_size: vec2(420, 220)
body +: {
@ -24,11 +17,9 @@ script_mod! {
flow: Down
spacing: 12
align: Center
on_render: ||{
counter_label := Label{
text: "Count: " + state.counter
draw_text.text_style.font_size: 24
}
counter_label := Label{
text: "Count: 0"
draw_text.text_style.font_size: 24
}
}
increment_button := Button{
@ -44,15 +35,15 @@ script_mod! {
pub struct App {
#[live]
ui: WidgetRef,
#[rust]
counter: i32,
}
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
if self.ui.button(cx, ids!(increment_button)).clicked(actions) {
script_eval!(cx,{
mod.state.counter += 1
ui.main_view.render()
});
self.counter += 1;
self.ui.label(cx, ids!(counter_label)).set_text(cx, &format!("Count: {}", self.counter));
}
}
}

View file

@ -11,7 +11,3 @@ makepad-widgets = { path = "../../widgets", features = ["maps", "pdf", "voice"]
makepad-code-editor = { path = "../../code_editor" }
makepad-ai = { path = "../../libs/makepad_ai" }
makepad-converse = { path = "../../libs/converse" }
makepad-game-sim = { path = "../../libs/game/sim" }
makepad-game-render = { path = "../../libs/game/render" }
makepad-game-blocks = { path = "../../libs/game/blocks" }
makepad-game-script = { path = "../../libs/game/script" }

View file

@ -1,72 +0,0 @@
// Speedway — a complete 4-car race built only from blocks.
// The engine drives the cars, tracks the laps and sorts the standings; this
// file is layout and rules. Compare with sandbox3d.splash, where every actor
// hand-rolled its own physics and AI.
let LAPS = 3
let TRACK = [
vec3(0, 0, -60), vec3(34, 0, -52), vec3(52, 0, -30), vec3(56, 0, 0),
vec3(52, 0, 30), vec3(34, 0, 52), vec3(0, 0, 60), vec3(-34, 0, 52),
vec3(-52, 0, 30), vec3(-56, 0, 0), vec3(-52, 0, -30), vec3(-34, 0, -52),
]
let COLORS = [#xe8594f, #x4f8fe8, #x4fe87a, #xe8d24f]
let NAMES = ["You", "Rosa", "Kim", "Bex"]
game.sky({})
game.terrain({size: 260, cells: 81, smooth: true, seed: 4, freq: 0.006, amp: 3,
plaza: {r: 120, ramp: 20, h: 0}, color: #x6b8f4a})
// Track surface: a slab under every corner, plus barriers on the outside.
for p in TRACK {
game.box({pos: vec3(p.x, 0.1, p.z), size: vec3(22, 0.2, 22), color: #x3b3f47, collide: false})
let out = p.normalized()
game.box({pos: vec3(out.x * 15 + p.x, 1.2, out.z * 15 + p.z),
size: vec3(6, 2.4, 6), color: #xd8d8d8, tag: "wall"})
}
// Start grid + gates. One spawnpoint and one checkpoint per corner.
for i in 0..TRACK.len() {
game.checkpoint({pos: vec3(TRACK[i].x, 3, TRACK[i].z), size: vec3(11, 6, 11)})
}
for i in 0..4 {
game.spawnpoint({pos: vec3(-6 + i * 4, 1.5, -60 + (i % 2) * 6), yaw: 0})
}
// Four cars: seat 0 is the player, the rest follow the racing line.
let cars = []
for i in 0..4 {
let car = game.car({color: COLORS[i], player: i == 0, top_speed: 26 - i * 0.5})
game.place(car, i)
game.label(car, NAMES[i])
if i > 0 {
game.autodrive(car, {points: TRACK, pace: 0.86 + i * 0.03})
}
cars.push(car)
}
game.camera({chase: cars[0], height: 3.2, boom: 13, pitch: -0.22, lag: 0.25})
game.race({laps: LAPS})
let done = false
game.on_tick(|dt, input| {
// Standings board, leader first.
let board = ""
let order = game.standings()
for i in 0..order.len() {
let s = order[i]
board = board + (i + 1) + ". " + game.tag(s.entity) + " lap " + min(s.lap + 1, LAPS) + "/" + LAPS + "\n"
}
game.text("board", board, {anchor: "top_left"})
game.bar("speed", game.speed(cars[0]) / 26, {anchor: "bottom"})
if !done && game.finished(cars[0]) {
done = true
game.text("center", "P" + game.rank(cars[0]) + " — press R to race again", {})
game.jingle("C5 E5 G5 C6", 140)
}
if input.reset_pressed {
done = false
game.text("center", "", {})
for i in 0..4 { game.place(cars[i], i) }
game.race({laps: LAPS})
}
})

View file

@ -1,77 +0,0 @@
// Wanderhome — the scenery counterpart to racing.splash.
//
// racing.splash is the model answer for GAMEPLAY: the engine drives the cars
// and counts the laps, so the file is layout and rules. This one is the model
// answer for a PLACE: the engine lays the streets, picks the artwork and makes
// it solid, so the file is intent.
//
// Every prop here is a real model from the stock library. Nothing is a
// coloured box, and no model id is written by hand — they all come back from
// game.find_model, which returns DIFFERENT models rather than one repeated.
let SEED = 11
let ROADS = "kenney/city-kit-roads"
let HOUSES = "kenney/city-kit-suburban"
game.sky({})
game.sun({time_of_day: 8.0})
// ---- the town: streets with buildings fronting them --------------------
// Junction types are never named here — a crossing produces a crossroad and a
// tee produces a T, purely from how the grid connects.
let town = game.town({
roads_kit: ROADS,
buildings_kit: HOUSES,
extent: 18,
block: 6,
density: 0.7,
seed: SEED,
})
game.log("town laid")
// ---- a wood around it --------------------------------------------------
// Four DIFFERENT conifers, not one conifer four times: `count` is what stops
// a wood looking stamped.
let trees = game.find_model("pine tree", {count: 4, seed: SEED})
let rocks = game.find_model("rock stone", {count: 3, spread: "variants", seed: SEED})
for i in 0..40 {
let a = i * 0.157
let r = 46.0 + (i % 7) * 2.5
game.model(trees[i % 4], {
pos: vec3(cos(a) * r, 0.0, sin(a) * r),
yaw: a * 2.0,
})
}
for i in 0..9 {
let a = i * 0.7
game.model(rocks[i % 3], {
pos: vec3(cos(a) * 38.0, 0.0, sin(a) * 38.0),
yaw: a,
})
}
// ---- somewhere to go ---------------------------------------------------
// The same generator gives a crypt, a cavern or a space station depending
// only on the kit; every room is reachable by construction.
let crypt = game.dungeon({
kit: "kenney/modular-dungeon-kit",
extent: 20,
min_room: 5,
seed: SEED,
})
game.log("crypt laid")
// ---- someone to be -----------------------------------------------------
let hero = game.character({
pos: vec3(0, 2, 0),
size: vec3(0.6, 1.7, 0.6),
color: #x4f8fe8,
speed: 6.0,
})
game.camera({third_person: hero, height: 1.6, boom: 9, pitch: -0.3})
game.text("hint", "Arrows to walk — find the crypt", {anchor: "top"})
game.on_tick(|dt, input| {
game.drive(hero, {move_x: input.move_x, move_z: input.move_z, jump: input.jump})
})

File diff suppressed because it is too large Load diff

View file

@ -1427,7 +1427,6 @@ impl AppMain for App {
vm.bx.captured_errors = Some(Vec::new());
crate::makepad_widgets::script_mod(vm);
crate::makepad_code_editor::script_mod(vm);
makepad_game_render::script_mod(vm);
crate::game_view::script_mod(vm);
let value = self::script_mod(vm);
let errors = vm.take_errors();

View file

@ -0,0 +1 @@
{"version":1,"agent_id":[1],"title":"Chat 1","backend_id":"openai_localhost","active":true,"status":"ready","pending":false,"updated_at":1785223053.212714,"messages":[],"history":[]}

View file

@ -108,7 +108,7 @@ script_mod! {
min_zoom: 3.0
mbtiles_path: "local/maps/europe-shortbread.mbtiles"
detail_mbtiles_path: "local/maps/europe-osm-detail.mbtiles"
bridge_dz_mbtiles_path: "local/maps/nl-bridge-dz.mbtiles"
bridge_dz_mbtiles_path: "local/maps/ams-bridge-dz.mbtiles"
buildings_3d: true
// shiny.md: baked AO/shadows/water/foliage ride
// the widget theme defaults; the showcase app

View file

@ -4,600 +4,11 @@ script_mod! {
use mod.prelude.widgets.*
use mod.widgets.*
// Mirrors a chat client's room-list message preview: bold sender, text,
// and a mention pill whose rounded background overdraws its layout rect
// through negative padding, all inside a RowAlign.Center wrapping flow.
let ReproPreview = View{
height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{
width: Fill, height: Fit
max_lines: 2
text_overflow: Ellipsis
font_size: 9.3
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
align: Align{ y: 0.5 }
text_style_normal +: { font_size: 9.3, line_spacing: 1.32 }
text_style_bold +: { font_size: 9.3, line_spacing: 1.32 }
pill := View {
width: Fit, height: Fit,
RoundedView {
width: Fit, height: Fit,
flow: Right,
align: Align{ y: 0.5 }
spacing: 1,
padding: Inset{ left: 4.5, right: 3.0, bottom: -3.5, top: -3.5 }
margin: Inset{ top: 1, right: 1 }
show_bg: true,
draw_bg +: { color: #000, border_radius: 4.5 }
RoundedView {
width: 13, height: 13,
show_bg: true,
draw_bg +: { color: #1fc7a8, border_radius: 6.5 }
}
Label {
flow: Right,
draw_text +: {
color: #f,
text_style +: { font_size: 8.5, line_spacing: 1.0 }
}
text: "Sam Carter",
}
}
}
body: "<b>Riley Hayes</b>: a message with one pill <pill></pill>"
}
}
// Mirrors a chat timeline message: pills and short text runs mixed in a
// RowAlign.Center wrapping flow with no line clamp. The trailing "hello"
// follows a pill, so at narrow widths it wraps as a continuation whose
// first row is empty — the case where a row starts with regular text.
let TimelineRepro = View{
height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{
width: Fill, height: Fit
font_size: 9.3
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
align: Align{ y: 0.5 }
text_style_normal +: { font_size: 9.3, line_spacing: 1.32 }
text_style_bold +: { font_size: 9.3, line_spacing: 1.32 }
pill := View {
width: Fit, height: Fit,
RoundedView {
width: Fit, height: Fit,
flow: Right,
align: Align{ y: 0.5 }
spacing: 1,
padding: Inset{ left: 4.5, right: 3.0, bottom: -3.5, top: -3.5 }
margin: Inset{ top: 1, right: 1 }
show_bg: true,
draw_bg +: { color: #000, border_radius: 4.5 }
RoundedView {
width: 13, height: 13,
show_bg: true,
draw_bg +: { color: #1fc7a8, border_radius: 6.5 }
}
Label {
flow: Right,
draw_text +: {
color: #f,
text_style +: { font_size: 8.5, line_spacing: 1.0 }
}
text: "Jordan Lee",
}
}
}
body: "<pill></pill> @ <pill></pill> <pill></pill> <pill></pill> hello <pill></pill> <pill></pill>"
}
}
// Same shape at chat-timeline metrics: font 11 with 1.3 line spacing
// and the default pill geometry (avatar 16, padding -3/-3, no top margin).
// At these metrics the text advance and the pill's outer height differ,
// unlike the 9.3pt preview metrics where they coincide.
let TimelineRepro11 = View{
height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{
width: Fill, height: Fit
font_size: 11
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
align: Align{ y: 0.5 }
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
text_style_bold +: { font_size: 11, line_spacing: 1.3 }
pill := View {
width: Fit, height: Fit,
RoundedView {
width: Fit, height: Fit,
flow: Right,
align: Align{ y: 0.5 }
spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true,
draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView {
width: 16, height: 16,
show_bg: true,
draw_bg +: { color: #1fc7a8, border_radius: 8.0 }
}
Label {
flow: Right,
draw_text +: {
color: #f,
text_style +: { font_size: 11, line_spacing: 1.0 }
}
text: "Jordan Lee",
}
}
}
// A pill whose title falls back to the CJK font, whose line
// metrics differ from the Latin font's.
cjk := View {
width: Fit, height: Fit,
RoundedView {
width: Fit, height: Fit,
flow: Right,
align: Align{ y: 0.5 }
spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true,
draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView {
width: 16, height: 16,
show_bg: true,
draw_bg +: { color: #cccccc, border_radius: 8.0 }
}
Label {
flow: Right,
draw_text +: {
color: #f,
text_style +: { font_size: 11, line_spacing: 1.0 }
}
text: "王小明",
}
}
}
red := View {
width: Fit, height: Fit,
RoundedView {
width: Fit, height: Fit,
flow: Right,
align: Align{ y: 0.5 }
spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true,
draw_bg +: { color: #e4335a, border_radius: 6.0 }
RoundedView {
width: 16, height: 16,
show_bg: true,
draw_bg +: { color: #1fc7a8, border_radius: 8.0 }
}
Label {
flow: Right,
draw_text +: {
color: #f,
text_style +: { font_size: 11, line_spacing: 1.0 }
}
text: "Riley Hayes",
}
}
}
body: "<cjk></cjk> @ <pill></pill> <pill></pill> <red></red> hello <pill></pill> <pill></pill>"
}
}
// D1 repro: zero content padding (a chat timeline's message body) and a
// FIRST row holds both a pill and the anchored first row of a wrapped text
// run. Centering the pill on the anchor lifts it above the widget's clip
// top unless the up-shift is clamped, which renders the pill's rounded top
// as a flat cut edge.
let TimelineReproD1 = View{
height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{
width: Fill, height: Fit
font_size: 11
padding: 0.0
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
align: Align{ y: 0.5 }
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
text_style_bold +: { font_size: 11, line_spacing: 1.3 }
cjk := View {
width: Fit, height: Fit,
RoundedView {
width: Fit, height: Fit,
flow: Right,
align: Align{ y: 0.5 }
spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true,
draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView {
width: 16, height: 16,
show_bg: true,
draw_bg +: { color: #cccccc, border_radius: 8.0 }
}
Label {
flow: Right,
draw_text +: {
color: #f,
text_style +: { font_size: 11, line_spacing: 1.0 }
}
text: "王小明",
}
}
}
body: "<cjk></cjk> hello there this is a longer message that wraps"
}
}
mod.widgets.DemoHtml = UIZooTabLayout_B{
desc +: {
Markdown{body: "# Html\n\nThe Html widget renders HTML content."}
}
demos +: {
H4{text: "Fit-max bounded pill labels at widths across the cap"}
P{text: "A pill-like label bounded to 60% of the enclosing width, at widths walking across the cap. Truncation must always show a trailing ellipsis, never a bare cut."}
View{
width: Fill, height: Fit, flow: Down, spacing: 6
View{ width: 300, height: Fit, show_bg: true, draw_bg +: {color: #333}
View{ width: Fit, height: Fit,
View{ width: Fit, height: Fit,
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
Label {
width: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.6}},
max_lines: 1, text_overflow: Ellipsis,
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
text: "@somebody-with-a-long-name:example.org" }
}
}
}
}
View{ width: 220, height: Fit, show_bg: true, draw_bg +: {color: #333}
View{ width: Fit, height: Fit,
View{ width: Fit, height: Fit,
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
Label {
width: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.6}},
max_lines: 1, text_overflow: Ellipsis,
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
text: "@somebody-with-a-long-name:example.org" }
}
}
}
}
View{ width: 160, height: Fit, show_bg: true, draw_bg +: {color: #333}
View{ width: Fit, height: Fit,
View{ width: Fit, height: Fit,
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
Label {
width: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.6}},
max_lines: 1, text_overflow: Ellipsis,
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
text: "@quokka:example.org" }
}
}
}
}
View{ width: 160, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill, height: Fit, font_size: 11
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
pill := View { width: Fit, height: Fit,
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
Label {
width: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.6}},
max_lines: 1, text_overflow: Ellipsis,
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
text: "@somebody-with-a-long-name:example.org" }
}
}
body: "hi <pill></pill> ok"
}
}
}
Hr{}
H4{text: "Line-bounded pill labels (Base.Line)"}
P{text: "Pill labels bounded by the line width actually available to the pill. A long name ellipsizes at the line edge; a mid-line pill relocates whole to the next row; a pill held on the last clamped row squeezes into the remnant and stays visible."}
View{
width: Fill, height: Fit, flow: Down, spacing: 6
// A long name in a narrow container: ellipsis lands near the
// container's right edge, not at a fixed fraction of it.
View{ width: 160, height: Fit, show_bg: true, draw_bg +: {color: #333}
View{ width: Fit, height: Fit,
View{ width: Fit, height: Fit,
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
Label {
width: Fit{max: FitBound.Rel{base: Base.Line, factor: 1.0}},
max_lines: 1, text_overflow: Ellipsis,
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
text: "@somebody-with-a-long-name:example.org" }
}
}
}
}
// A long-name pill mid-line in a wrapping flow: the pill
// relocates whole to its own row and ellipsizes at the row
// edge; the text before and after stays intact.
View{ width: 200, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill, height: Fit, font_size: 11
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
pill := View { width: Fit, height: Fit,
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
Label {
width: Fit{max: FitBound.Rel{base: Base.Line, factor: 1.0}},
max_lines: 1, text_overflow: Ellipsis,
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
text: "@somebody-with-a-long-name:example.org" }
}
}
body: "hi <pill></pill> ok"
}
}
// A long-name pill landing on the LAST permitted row of a
// clamped flow: the line clamp holds it in place, so the
// title squeezes into the row remnant and the pill remains
// visible with its own ellipsis, not hidden behind the
// flow's.
View{ width: 210, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill, height: Fit, font_size: 11
max_lines: 2
text_overflow: Ellipsis
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
pill := View { width: Fit, height: Fit,
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
Label {
width: Fit{max: FitBound.Rel{base: Base.Line, factor: 1.0}},
max_lines: 1, text_overflow: Ellipsis,
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
text: "@somebody-with-a-long-name:example.org" }
}
}
body: "one two three four five six seven <pill></pill>"
}
}
// A short name in a wide container fits fully inline: the
// line bound must not truncate a name the line can hold.
View{ width: 400, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill, height: Fit, font_size: 11
flow: Flow.Right{wrap: true, row_align: RowAlign.Center}
text_style_normal +: { font_size: 11, line_spacing: 1.3 }
pill := View { width: Fit, height: Fit,
RoundedView { width: Fit, height: Fit, flow: Right, align: Align{ y: 0.5 }, spacing: 1,
padding: Inset{ left: 6, right: 4, bottom: -3, top: -3 }
margin: Inset{ right: 1 }
show_bg: true, draw_bg +: { color: #000, border_radius: 6.0 }
RoundedView { width: 16, height: 16, show_bg: true, draw_bg +: { color: #1fc7a8, border_radius: 8.0 } }
Label {
width: Fit{max: FitBound.Rel{base: Base.Line, factor: 1.0}},
max_lines: 1, text_overflow: Ellipsis,
draw_text +: { color: #f, text_style +: { font_size: 11, line_spacing: 1.0 } }
text: "@quokka:example.org" }
}
}
body: "hi <pill></pill> ok"
}
}
}
Hr{}
H4{text: "REPRO: timeline message, pills + text, no line clamp"}
P{text: "Rows starting with regular text after a pill-heavy row must keep the text and the pills on one center line, pill tops must never be cut, and inter-line spacing must be uniform."}
View{
width: Fill, height: Fit, flow: Down, spacing: 6
TimelineRepro11 { width: 420 }
TimelineRepro11 { width: 310 }
TimelineRepro11 { width: 240 }
TimelineRepro { width: 260 }
TimelineRepro { width: 200 }
TimelineReproD1 { width: 240 }
}
Hr{}
H4{text: "REPRO: sender + wrapped text + trailing pill, chat-preview style"}
P{text: "Bold sender, small text with 1.32 line spacing, RowAlign.Center, and a pill whose background overdraws its layout rect via negative padding. Five widths walk the pill from inline, to wrapped, to sharing a row with wrapped text, to overrunning the last line."}
View{
width: Fill, height: Fit, flow: Down, spacing: 6
ReproPreview { width: 280 }
ReproPreview { width: 230 }
ReproPreview { width: 190 }
ReproPreview { width: 170 }
ReproPreview { width: 150 }
ReproPreview { width: 130 }
}
Hr{}
H4{text: "Html with ellipsis, inline code, and a narrow width (2 lines)"}
P{text: "An inline code span too wide for the remaining room wraps to its own line. That wrap must consume one of the two allowed lines, so the text below must never exceed two rows at any window width."}
View{
width: Fill, height: Fit, flow: Down, spacing: 4
View{ width: 230, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
body: "Sam Carter: and <code>&lt;details&gt;</code> / <code>&lt;summary&gt;</code> is fully working (see: https://example.com/doc/inline-code-demo for the full writeup)" } }
View{ width: 260, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
body: "Sam Carter: and <code>&lt;details&gt;</code> / <code>&lt;summary&gt;</code> is fully working (see: https://example.com/doc/inline-code-demo for the full writeup)" } }
View{ width: 320, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
body: "Sam Carter: and <code>&lt;details&gt;</code> / <code>&lt;summary&gt;</code> is fully working (see: https://example.com/doc/inline-code-demo for the full writeup)" } }
View{ width: 410, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
body: "Sam Carter: and <code>&lt;details&gt;</code> / <code>&lt;summary&gt;</code> is fully working (see: https://example.com/doc/inline-code-demo for the full writeup)" } }
}
H4{text: "Html with an atomic inline widget at max_lines 2"}
P{text: "An inline widget too wide for the room left on its row is relocated whole onto a new row. That row counts against max_lines like any other, and once the budget is spent no further widget may draw."}
View{
width: Fill, height: Fit, flow: Down, spacing: 4
View{ width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
pill := RoundedView{
width: Fit, height: Fit, flow: Right
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
margin: Inset{left: 2, right: 2}
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
}
body: "hey <pill></pill> and <pill></pill> plus a good deal more text that has to be clamped" }
}
View{ width: 300, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
pill := RoundedView{
width: Fit, height: Fit, flow: Right
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
margin: Inset{left: 2, right: 2}
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
}
body: "hey <pill></pill> and <pill></pill> plus a good deal more text that has to be clamped" }
}
}
H4{text: "Text that fills both lines, then a mention pill"}
P{text: "The worst realistic case: the text before the pill ends on the last allowed line without being cut, so nothing has truncated yet, and the pill then does not fit in what is left of that line."}
View{
width: Fill, height: Fit, flow: Down, spacing: 4
View{ width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
pill := RoundedView{
width: Fit, height: Fit, flow: Right
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
margin: Inset{left: 2, right: 2}
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
}
body: "Thanks for the review, that all makes sense, so I am assigning it to <pill></pill>" }
}
View{ width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
pill := RoundedView{
width: Fit, height: Fit, flow: Right
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
margin: Inset{left: 2, right: 2}
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
}
body: "Thanks for the review and all of your detailed comments, that all makes sense to me, so I am going to assign it over to <pill></pill> later today" }
}
}
H4{text: "A mention pill inside a <summary>"}
P{text: "The summary line opens with a fold button, so the same overrun applies there: the pill charges its row like any other inline widget."}
View{
width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
pill := RoundedView{
width: Fit, height: Fit, flow: Right
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
margin: Inset{left: 2, right: 2}
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
}
body: "<details><summary>a pill inside a summary <pill></pill></summary>hidden body content</details>" }
}
H4{text: "Consecutive inline widgets with no text between them"}
P{text: "Nothing but widgets, so no text run follows to notice the rows they open; each widget charges its own row instead. On the last allowed row wrapping is switched off, so the widget that no longer fits is held there and clipped rather than opening a third row, and the ones after it are dropped."}
View{
width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
pill := RoundedView{
width: Fit, height: Fit, flow: Right
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
margin: Inset{left: 2, right: 2}
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
Label{ flow: Right, draw_text +: { color: #ffffff }, text: "@quill:example.org" }
}
body: "<pill></pill><pill></pill><pill></pill><pill></pill><pill></pill><pill></pill>" }
}
H4{text: "Inline widget wider than the whole line"}
P{text: "Left: an unbounded pill overflows the container, because relocating it to a fresh row still does not make it fit. Right: bounding its label to a fraction of the enclosing width keeps the pill inside the line and ellipsizes the name."}
View{
width: Fill, height: Fit, flow: Down, spacing: 4
View{ width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
pill := RoundedView{
width: Fit, height: Fit, flow: Right
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
margin: Inset{left: 2, right: 2}
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
Label{ flow: Right, draw_text +: { color: #ffffff }
text: "@a-really-long-display-name-that-cannot-fit:example.org" }
}
body: "hey <pill></pill> and some trailing words" }
}
View{ width: 250, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 2 text_overflow: Ellipsis
pill := RoundedView{
width: Fit, height: Fit, flow: Right
padding: Inset{left: 6, right: 6, top: 1, bottom: 1}
margin: Inset{left: 2, right: 2}
show_bg: true, draw_bg +: { color: #4488cc, border_radius: 6.0 }
Label{ flow: Right, draw_text +: { color: #ffffff }
width: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.6}}
max_lines: 1, text_overflow: Ellipsis
text: "@a-really-long-display-name-that-cannot-fit:example.org" }
}
body: "hey <pill></pill> and some trailing words" }
}
}
H4{text: "Html list with max_lines 3"}
P{text: "A list item's bullet and its first line of text share one visual line, so three allowed lines must show three items."}
View{
width: 320, height: Fit, show_bg: true, draw_bg +: {color: #333}
Html{ width: Fill height: Fit max_lines: 3 text_overflow: Ellipsis
body: "<ul><li>alpha</li><li>beta</li><li>gamma</li><li>delta</li></ul>" }
}
H4{text: "Non-wrapping Label: max_lines 1 + Ellipsis"}
P{text: "A single-line, non-wrapping Label overflows sideways rather than onto extra rows, so its ellipsis must still appear when max_lines is also set."}
View{
width: Fill, height: Fit, flow: Down, spacing: 4
View{ width: 230, height: Fit, show_bg: true, draw_bg +: {color: #333}
Label{ width: Fill, height: Fit, flow: Flow.Right{wrap: false}, padding: 0
max_lines: 1, text_overflow: Ellipsis
text: "A label whose text is far too long to fit on one line" } }
View{ width: 230, height: Fit, show_bg: true, draw_bg +: {color: #333}
Label{ width: Fill, height: Fit, flow: Flow.Right{wrap: false}, padding: 0
text_overflow: Ellipsis
text: "Same label with ellipsis but no max_lines set" } }
}
Hr{}
Html{
width: Fill height: Fit
body: "<H1>H1 Headline</H1><H2>H2 Headline</H2><H3>H3 Headline</H3><H4>H4 Headline</H4><H5>H5 Headline</H5><H6>H6 Headline</H6>This is <b>bold</b>&nbsp;and <i>italic text</i>.<sep><b><i>Bold italic</i></b>, <u>underlined</u>, and <s>strike through</s> text. <p>This is a paragraph</p> <code>A code block</code>. <br/> And this is a <a href='https://www.google.com/'>link</a><br/><ul><li>lorem</li><li>ipsum</li><li>dolor</li></ul><ol><li>lorem</li><li>ipsum</li><li>dolor</li></ol><br/> <blockquote>Blockquote</blockquote> <pre>pre</pre><sub>sub</sub><del>del</del>"
@ -623,19 +34,6 @@ script_mod! {
body: "The <b>quick brown fox</b> jumps over the <i>lazy dog</i>. Pack my box with <b><i>five dozen</i></b> liquor jugs. How <u>vexingly quick</u> daft zebras jump! The five boxing wizards jump quickly. Sphinx of black quartz, judge my vow. Two driven jocks help fax my big quiz."
}
Hr{}
H4{text: "Html with ellipsis, inline code, and a narrow width (2 lines)"}
P{text: "An inline code span too wide for the remaining room wraps to its own line. That wrap must consume one of the two allowed lines, so the text below must never exceed two rows at any window width."}
View{
width: 320, height: Fit
Html{
width: Fill height: Fit
max_lines: 2
text_overflow: Ellipsis
body: "Sam Carter: and <code>&lt;details&gt;</code> / <code>&lt;summary&gt;</code> is fully working (see: https://example.com/doc/inline-code-demo for the full writeup)"
}
}
Hr{}
H4{text: "Html with ellipsis and emoji (1 line)"}
P{text: "Html with multi-byte emoji mixed into styled text."}

View file

@ -5,7 +5,3 @@ edition = "2021"
[dependencies]
makepad-widgets = { path = "../../widgets", version = "2.0.0" }
robius-file-picker = "0.3"
[target.'cfg(target_os = "android")'.dependencies]
robius-android-env = { version = "0.2", features = ["makepad"] }

View file

@ -22,9 +22,6 @@ pub use makepad_widgets;
use makepad_widgets::*;
mod picker;
use picker::{pick_local_video, PickedMediaAction};
app_main!(App);
// A public HLS (m3u8) adaptive-bitrate stream. The Video widget's backend plays HLS natively
@ -137,13 +134,7 @@ script_mod! {
width: 64
height: Fit
text: "Pause"
draw_bg.color: #x00000099
}
open_button := Button{
width: 56
height: Fit
text: "Open"
show_bg: true
draw_bg.color: #x00000099
}
@ -224,6 +215,7 @@ script_mod! {
width: 52
height: Fit
text: "1.0x"
show_bg: true
draw_bg.color: #x00000099
}
@ -231,6 +223,7 @@ script_mod! {
width: 56
height: Fit
text: "Full"
show_bg: true
draw_bg.color: #x00000099
}
}
@ -308,8 +301,6 @@ pub struct VideoPlayer {
tick_started: bool,
#[rust(false)]
media_started: bool,
#[rust]
pending_source: Option<String>,
}
impl Widget for VideoPlayer {
@ -333,7 +324,6 @@ impl Widget for VideoPlayer {
// "no gesture -> hide" rule takes over (armed from show_controls_bar / gesture paths).
}
if self.tick_timer.is_event(event).is_some() {
self.try_open_pending(cx);
self.refresh_progress(cx);
}
if self.hud_timer.is_event(event).is_some() {
@ -633,39 +623,6 @@ impl VideoPlayer {
self.view(cx, ids!(hud)).set_visible(cx, false);
self.redraw(cx);
}
/// Open a new path, URL, or `content://` URI (e.g. from the system picker).
fn request_open_media(&mut self, cx: &mut Cx, src: &str) {
let src = src.trim();
if src.is_empty() {
return;
}
let video = self.video(cx, ids!(video));
if video.is_unprepared() {
play_media(cx, &video, src);
self.paused = false;
self.button(cx, ids!(play_button)).set_text(cx, "Pause");
self.show_controls_bar(cx);
return;
}
self.pending_source = Some(src.to_string());
video.stop_and_cleanup_resources(cx);
self.show_controls_bar(cx);
}
fn try_open_pending(&mut self, cx: &mut Cx) {
let Some(src) = self.pending_source.clone() else {
return;
};
let video = self.video(cx, ids!(video));
if !video.is_unprepared() {
return;
}
self.pending_source = None;
play_media(cx, &video, &src);
self.paused = false;
self.button(cx, ids!(play_button)).set_text(cx, "Pause");
}
}
/// Points a [`Video`] widget at a media source given as a plain string, auto-detecting:
@ -677,16 +634,19 @@ impl VideoPlayer {
/// This is the "plugin" entry point: callers just pass a path or URL and the right
/// [`VideoDataSource`] is chosen and applied.
fn play_media(cx: &mut Cx, video: &VideoRef, src: &str) {
let Some((path, is_network)) = parse_media_ref(src) else {
return;
};
let lower_path = path.split(['?', '#']).next().unwrap_or(&path).to_ascii_lowercase();
let lower_path = src.split(['?', '#']).next().unwrap_or(src).to_ascii_lowercase();
let is_manifest = lower_path.ends_with(".m3u8") || lower_path.ends_with(".mpd");
let is_network = src.starts_with("http://")
|| src.starts_with("https://")
|| src.starts_with("file://");
// Both HLS/DASH manifests and progressive files go through Network when given a URL; a local
// path uses Filesystem. (Local .m3u8 is unusual and not handled specially here.)
let source = if is_network {
VideoDataSource::Network { url: path }
VideoDataSource::Network { url: src.to_string() }
} else {
VideoDataSource::Filesystem { path }
VideoDataSource::Filesystem { path: src.to_string() }
};
let kind = if is_manifest { "HLS/DASH manifest" } else { "progressive file" };
@ -697,30 +657,6 @@ fn play_media(cx: &mut Cx, video: &VideoRef, src: &str) {
video.begin_playback(cx);
}
/// Parse a filesystem path, `file://` / `content://` URI, or `http(s)://` URL.
fn parse_media_ref(src: &str) -> Option<(String, bool)> {
let src = src.trim();
if src.is_empty() {
return None;
}
let is_network = src.starts_with("http://") || src.starts_with("https://");
if let Some(rest) = src.strip_prefix("file://") {
let rest = rest
.strip_prefix('/')
.filter(|r| r.as_bytes().get(1) == Some(&b':'))
.unwrap_or(rest);
return Some((rest.to_string(), false));
}
if src.starts_with("content://") {
return Some((src.to_string(), false));
}
if is_network {
Some((src.to_string(), true))
} else {
Some((src.to_string(), false))
}
}
/// Computes the target scrub position for a horizontal seek drag.
///
/// Bilibili-style sensitivity: a full-width drag scrubs at most ~120s (or the whole clip, if
@ -764,20 +700,6 @@ pub struct App {
impl MatchEvent for App {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
for action in actions {
if let Some(picked) = action.downcast_ref::<PickedMediaAction>() {
if let Some(err) = picked.error.as_ref() {
error!("video_player: open file failed: {err}");
continue;
}
let Some(path) = picked.path_or_uri.as_ref() else {
continue;
};
if let Some(mut vp) = self.ui.widget(cx, ids!(player)).borrow_mut::<VideoPlayer>() {
vp.request_open_media(cx, path);
}
}
}
if self.ui.button(cx, ids!(play_button)).clicked(actions) {
if let Some(mut vp) = self.ui.widget(cx, ids!(player)).borrow_mut::<VideoPlayer>() {
vp.toggle_play_pause(cx);
@ -790,12 +712,6 @@ impl MatchEvent for App {
vp.show_controls_bar(cx);
}
}
if self.ui.button(cx, ids!(open_button)).clicked(actions) {
pick_local_video();
if let Some(mut vp) = self.ui.widget(cx, ids!(player)).borrow_mut::<VideoPlayer>() {
vp.show_controls_bar(cx);
}
}
if self.ui.button(cx, ids!(fullscreen_button)).clicked(actions) {
self.is_fullscreen = !self.is_fullscreen;
let window = self.ui.window(cx, ids!(main_window));
@ -820,18 +736,6 @@ impl AppMain for App {
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
if let Event::Drop(drop) = event {
for item in drop.items.iter() {
if let DragItem::FilePath { path, .. } = item {
if let Some(mut vp) =
self.ui.widget(cx, ids!(player)).borrow_mut::<VideoPlayer>()
{
vp.request_open_media(cx, path);
}
break;
}
}
}
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
}
@ -895,20 +799,4 @@ mod tests {
// Zero duration is a no-op.
assert_eq!(scrub_target_ms(5_000, w, w, 0), 0);
}
#[test]
fn parse_media_ref_handles_content_and_file_urls() {
assert_eq!(
parse_media_ref("content://media/external/video/media/42"),
Some(("content://media/external/video/media/42".into(), false))
);
assert_eq!(
parse_media_ref("file:///C:/Videos/clip.mp4"),
Some(("C:/Videos/clip.mp4".into(), false))
);
assert_eq!(
parse_media_ref("https://example.com/x.m3u8"),
Some(("https://example.com/x.m3u8".into(), true))
);
}
}

View file

@ -1,77 +0,0 @@
//! Native file picker via `robius-file-picker` (desktop + Android).
use makepad_widgets::*;
use robius_file_picker::FileDialog;
/// Posted from the picker callback onto the UI action queue.
#[derive(Clone, Debug, Default)]
pub struct PickedMediaAction {
/// `None` means cancelled or picker error (see `error`).
pub path_or_uri: Option<String>,
pub error: Option<String>,
}
fn video_file_filters() -> Vec<(String, Vec<String>)> {
vec![
(
"Video".into(),
vec![
"mp4".into(),
"mkv".into(),
"webm".into(),
"avi".into(),
"mov".into(),
"flv".into(),
"ts".into(),
"m4v".into(),
"wmv".into(),
"mpg".into(),
"mpeg".into(),
"m3u8".into(),
],
),
("All Files".into(), vec!["*".into()]),
]
}
/// Open the native video/file picker. Result arrives as [`PickedMediaAction`].
pub fn pick_local_video() {
let mut dialog = FileDialog::new().set_title("Open Video");
dialog = dialog.set_filters(video_file_filters());
let result = dialog.pick_video(|result| {
let action = match result {
Ok(Some(file)) => {
let path_or_uri = file
.path()
.map(|p| p.to_string_lossy().into_owned())
.or_else(|| file.uri().map(|u| u.to_string()));
if let Some(ref s) = path_or_uri {
log!("video_player: picked {}", s);
}
PickedMediaAction {
path_or_uri,
error: None,
}
}
Ok(None) => PickedMediaAction {
path_or_uri: None,
error: None,
},
Err(e) => {
error!("video_player: pick error: {e}");
PickedMediaAction {
path_or_uri: None,
error: Some(e.to_string()),
}
}
};
Cx::post_action(action);
});
if let Err(e) = result {
error!("video_player: failed to show file dialog: {e}");
Cx::post_action(PickedMediaAction {
path_or_uri: None,
error: Some(e.to_string()),
});
}
}

View file

@ -1,10 +0,0 @@
# append ip:port lines — nodes join the running spiral within 30s
10.0.0.169:8384
10.0.0.217:8384
10.0.0.123:8384
10.0.0.100:8384
10.0.0.203:8384
10.0.0.235:8384
10.0.0.166:8384
10.0.0.162:8384
10.0.0.160:8384

130
glass.md Normal file
View file

@ -0,0 +1,130 @@
# Apple Glass UI Shader Plan
## Goal
Add first-class glass UI surfaces that can tint, refract, highlight, and blur the scene behind them without each widget inventing its own offscreen path. The first implementation lives in `Window`: each window owns a `GaussStack`, and overlay widgets request access to that stack through a generic API. Longer term, the same request/capture model can become a backend-level hookable renderflow.
## Current State
- `widgets/src/glass_panel.rs` already defines a `GlassPanel` style with tint, border, specular, noise, and a `use_scene_blur` knob, but it does not sample a real blurred scene texture yet.
- UI rendering is pass-based. `platform/src/os/cx_shared.rs::compute_pass_repaint_order` builds `passes_todo`, and each backend renders that ordered list to a window or texture. It now logs the pass count when the count changes.
- Views can create child texture passes through `ViewOptimize::Texture`, but that is a widget-local cache, not a frame-level background blur stack.
- `Window` can split a frame into an offscreen scene capture plus root-pass overlays when an overlay widget requests glass.
- The immediate problem for real glass is that a shader cannot reliably sample the current framebuffer underneath itself while drawing in the same render pass. We need renderflow-owned intermediate textures.
## Window-Owned Architecture
The first slice keeps the renderflow local to `Window` so we can validate behavior before changing every backend:
1. `Window` owns a `GaussStack`.
2. A generic `request_window_gauss(cx)` API records that the current window has an overlay needing the gauss mipchain.
3. The next redraw captures normal window contents into the stack's high-resolution scene pass.
4. Overlay drawlists are still collected by the window overlay, but are assigned to the root window pass while the scene is captured.
5. `Window::end()` builds the mip chain, draws the sharp scene back into the root pass, then appends overlays.
6. Glass widgets bind the scene texture plus mip textures if the stack is active; otherwise they draw a fallback material.
This keeps `Window` free of specific widget types. It only asks whether the window has a gauss request, and it exposes textures through a generic snapshot.
## Future Renderflow Hook
Initial hook points:
- `before_pass(draw_pass_id)`: allocate or update resources before a UI pass is rendered.
- `after_pass(draw_pass_id)`: capture pass output or schedule dependent passes.
- `before_present(window_pass_id)`: composite final effects before presenting the window.
- `resize_or_dpi_changed(window_pass_id, size, dpi)`: invalidate per-window blur resources.
The default renderflow hook would do nothing, so existing apps keep the same behavior. The current `Window` implementation is the prototype for the hook contract.
## Glass Blur Stack
The `GaussStack` owns per-window blur resources:
- `scene_texture`: the resolved color output before glass overlays.
- `mip_textures`: a chain of downsampled textures for larger blur radii.
- `scene_pass`: the high-resolution drawpass that captures normal content.
- `mip_passes`: drawpasses that build the pyramid.
- `uniforms`: blur radius, saturation, tint, refraction, noise seed, and scale.
The first implementation uses a conservative full-window mip stack:
1. Render normal content into an offscreen scene texture.
2. Downsample scene texture into a mip pyramid.
3. Draw the unblurred scene texture back into the root window pass.
4. Draw overlay widgets that sample the scene texture and mip textures in screen coordinates.
5. Present the composed final pass.
Once stable, optimize by scissoring or tiling around glass rectangles.
## Widget Integration
Glass widgets should use the request/snapshot API:
- Register demand by calling `request_window_gauss(cx)` while drawing inside an overlay drawlist.
- Expose script properties for `blur_radius`, `saturation`, `tint_color`, `tint_alpha`, `border_alpha`, `specular_strength`, `noise_strength`, and `refraction_strength`.
- Bind window-provided scene/mip textures into the glass draw shader.
- Keep a fallback path when no blur stack exists: current tint/noise/highlight shader behavior.
The widget should not allocate render targets directly. It only declares intent: "this overlay draw needs backdrop glass with these parameters."
## Shader Work
Implement shaders in layers:
1. `DrawGlassPanel` / `GaussRoundedView`: final panel shader that samples blurred backdrop texture, optional sharp scene texture, and procedural noise.
2. `DrawBlurDownsample`: box or tent downsample.
3. Mip-level interpolation and a small multi-tap gather for smoother large kernels.
4. Optional `DrawGlassMask`: writes rounded-rect mask for region-limited blur.
The first pass should favor stable visuals over perfect physical accuracy:
- Background blur sampled in screen/window coordinates.
- Tint and saturation correction after blur.
- Rounded SDF clipping in the glass panel shader.
- Border/highlight generated in the panel shader.
- Noise dither kept subtle and time-stable unless animation is explicitly requested.
## Backend Path
Implementation order:
1. Validate the window-owned `GaussStack` on the existing backend paths.
2. Clean up the request/snapshot API so multiple glass widgets can share one stack.
3. Add backend-neutral renderflow structs and hook APIs in `platform/src` if the window-local model proves too limiting.
4. Convert the Metal repaint loop to execute renderflow ops.
5. Keep other backends on the default no-hook flow until the API is stable.
## Invalidation And Performance
Blur resources must be rebuilt when:
- Window size changes.
- DPI changes.
- Glass parameters require a different stack resolution or radius class.
- The source pass repaints.
- The overlay request state changes from no glass to glass, or glass to no glass.
Performance controls:
- Downsample before blur.
- Bucket blur radii into a small number of stack variants.
- Skip blur work when no visible glass regions exist.
- Reuse textures across frames.
- Add counters for renderflow op count, blur texture size, and pass count.
## Milestones
1. Log current pass count from the shared pass planner.
2. Add `Window`-owned `GaussStack` with scene and mip drawpasses.
3. Add generic overlay request/snapshot API with no widget-type leakage into `Window`.
4. Add a `GaussRoundedView` proof widget and splash popup demo.
5. Add screenshots and pass-count checks through Studio remote runs.
6. Add smoother multi-tap upsample/selection for large blur kernels.
7. Optimize region-limited blur after the full-window path is correct.
## Open Questions
- Should glass split a window into "content before glass" and "glass overlay" draw lists automatically, or should glass widgets force a renderflow capture at their depth?
- Do we want one blur stack per window or one per source pass?
- How much refraction should be supported in the first shader without requiring normal maps or distance fields from surrounding UI?
- Should renderflow hooks be internal-only at first, or exposed to app/widget authors once stabilized?

346
gps.md Normal file
View file

@ -0,0 +1,346 @@
# GPS layer: search, current position, routing
## Status 2026-07-27 — basic navigator SHIPPED (M0 + M1 + M3 + sim-M2/M4)
Built and verified end-to-end (search "centraal" → route → simulated drive →
"You have arrived" banner):
- `libs/map_nav` — geo, `region.search` (builder+query, prefix/category/proximity),
`region.graph` (CSR, per-mode speeds, oneway, turn restrictions, snap grid, A*),
maneuver generation, `NavSession` (map-matching, off-route → reroute). 26 unit tests.
- `tools/map_tiles nav-build <pbf> <basename> [--bbox]` — one scan → both artifacts
(Noord-Holland: 7.8s, 89MB graph / 107MB search, 1.39M directed edges, 1.8M docs
incl. full addresses). `nav-probe` for CLI search/route checks.
NH pbf at `local/maps/noord-holland-latest.osm.pbf`, artifacts `local/maps/noord-holland.*`.
- MapView M0: `MapViewAction` (ViewportChanged/Tapped/LongPressed/MarkerClicked),
camera API + `fly_to` (zoom-out-in arc), `overlay.rs` (route casing+fill with
traveled dim, pin markers, position puck w/ accuracy + heading), `mbtiles_path`
DSL property (per-app tile archive override). Map now respects the `handled`
flag so floating panels win hits (EventOrder::Up).
- `examples/map` is the navigator app: debounced worker-thread search, result list,
long-press / "Set position here" → puck, route bar (Drive/Bike/Walk), Start →
simulated turn-by-turn with banner + follow cam + recenter.
Not yet: real GPS (CoreLocation), heading-up camera, voice, CH scaling, v2 pbf
index extras (opening hours, admin hierarchy), typo tolerance. Plan below is the
original roadmap.
Plan for the interaction layer on top of the local map stack: a search engine over the
map data (places, shops, streets, addresses), a live "current position" puck, and
turn-by-turn routing — a proper offline GPS app. This work sits between the renderer
(being improved in `widgets/src/map/`) and the OSM import pipeline (`tools/map_tiles/`),
and deliberately touches both only through small, agreed contracts so all three tracks
can proceed in parallel.
## Current state (what we build on)
- **Renderer**: `MapView` (`widgets/src/map/view.rs`) renders Shortbread vector tiles
from a local mbtiles file. Camera = `center_norm` (normalized web mercator `Vec2d`)
+ fractional `zoom`. No rotation. Pan/scroll-zoom gestures work. There is **no public
camera API, no widget actions, no tap reporting, no fly-to animation** — the widget is
render-only today. `examples/map` is a bare demo with an empty `handle_actions`.
- **Data**: `noord-holland-shortbread-1.0.mbtiles`, z014. The z14 layers carry real
search fodder: `pois` (name, amenity, shop, cuisine, tourism…), `addresses`
(housenumber/housename — **no street name**), `street_labels` (street names),
`place_labels` (cities/towns + population), `public_transport` (stations),
`buildings`, `sites`, `land`. The `streets` layer has `kind`, `oneway`,
`oneway_reverse`, `bridge`, `tunnel`, `link` — enough for a stopgap routing graph,
but no turn restrictions and no stable node ids.
- **Import pipeline**: `tools/map_tiles` already parses raw `.osm.pbf` (`osmpbf` dep)
and builds a full-tag "detail archive" that retains OSM ids + complete tag maps.
This is the natural source of truth for search v2 and the routing graph.
- **Reusable primitives**:
- Zoom-constant screen-space symbols: `ICON_SHAPE_ID` path in the map vertex shader
+ `append_icon_mesh` (`tile.rs`) — exactly what markers and the position puck need.
- `DrawVector` immediate-mode path API (`move_to`/`line_to`/`stroke`) — route overlay.
- `DrawRotatedText` + the label collision machinery — overlay labels.
- Background work pattern: `TagThreadPool` + `ToUIReceiver` (used for tile decode) —
reuse for search queries, index build, and route computation.
- `MbtilesReader` (`libs/mbtile_reader`): sync, cheap to open, direct tile lookup.
- Overlay injection point: end of `MapView::draw_walk`, after `place_and_draw_labels`.
## Architecture
One new pure-logic crate plus additions to the widget and the import tool:
```
libs/map_nav/ NEW — no UI deps, fully unit-testable
src/search/ index format, builder, query engine, ranking
src/graph/ routing graph format, builder helpers, A* query
src/nav/ NavSession state machine (map-matching, instructions, reroute)
src/geo.rs shared: fixed-point coords, mercator helpers, haversine
tools/map_tiles/ import pipeline (other agent) gains two subcommands:
search-index pbf (or tiles) -> region.search
route-graph pbf -> region.graph
widgets/src/map/
view.rs camera API, actions, fly-to, overlay draw hook
overlay.rs NEW — markers, route polyline, position puck
(search UI + nav HUD live in the example app first, promoted to widgets later)
examples/map/ grows into the actual GPS app (search box, nav HUD)
```
Rationale: `libs/map_nav` mirrors what `libs/mbtile_reader` is for tiles — a dependency
shared by the import tool (which *builds* artifacts) and the app/widget (which *queries*
them). File formats live in one place; the import agent and we only need to agree on
the artifact contracts (below).
All heavy work (index build, search queries, route computation, reroutes) runs on the
existing thread-pool pattern and posts results back over `ToUIReceiver`. The UI thread
never blocks on any of this.
Coordinate conventions: `f64` lon/lat at every public API boundary; normalized web
mercator internally (matches `center_norm`); `u32` fixed-point (norm-mercator × 2³²)
in file formats.
## Milestone 0 — MapView interaction API (prerequisite for everything)
The widget must become programmable before search or nav can exist. Small, mechanical,
should land first and unblocks all later milestones. Coordinate with the renderer agent
since it touches `view.rs`, but it's additive.
1. **Actions**: add `MapViewAction` and emit via `cx.widget_action`:
- `ViewportChanged { center_lon, center_lat, zoom }` (end of gesture + end of fly-to)
- `Tapped { lon, lat, abs }` (finger up without drag)
- `LongPressed { lon, lat, abs }` (for "set position here" / "route to here")
2. **Camera API** on `MapView`/`MapViewRef`:
- `set_center(lon, lat)`, `set_zoom(z)`, `center() -> (f64, f64)`, `zoom() -> f64`
- `screen_to_lonlat(abs) -> (f64, f64)` and inverse (the math already exists
privately in `geometry.rs`)
- write `center_lon/center_lat/zoom` back after gestures (today they're stale
input-only seeds)
3. **`fly_to(lon, lat, zoom)`**: animated camera. Copy the existing timer pattern
(`zoom_settle_timer` / tile-fade). Ease center in mercator space + zoom with a
zoom-out-then-in arc when the target is far (keeps tiles loadable mid-flight and
reads like every mapping app). ~150 lines including easing.
4. **Marker layer** (`overlay.rs`): `set_markers(Vec<MapMarker { lon, lat, icon, color, id }>)`,
drawn with the zoom-constant symbol path; hit-test markers *before* the map's finger
grab and emit `MarkerClicked { id }`. Add a pin SVG to `ICON_SVGS`.
5. **Overlay draw hook**: draw order = tiles → labels → route polyline → markers →
position puck (puck always on top).
Deliverable: `examples/map` can tap the map, log lon/lat, drop a pin, and `fly_to` it.
## Milestone 1 — Search
### Two data stages (don't block on the import agent)
- **v1 — index built from the tiles we already ship.** Scan all z14 tiles
(Noord-Holland: 5,780 tiles ≤ ~400 KB — a seconds-scale, one-time background job on
first launch, cached to disk). Extract named/categorized features from `pois`,
`place_labels`, `street_labels`, `public_transport`, `buildings`, `sites`, `land`.
The MVT decoder already produces per-feature tag maps at tile-build time; v1 adds an
`MvtSink` that collects (name, tags, centroid) instead of tessellating. Known v1
limits: no full addresses (Shortbread `addresses` lacks street names), features can
appear in multiple tiles (dedupe by name_key + quantized location), no house-number
interpolation.
- **v2 — index emitted by the import pipeline from the pbf.** Full `addr:street` +
`addr:housenumber` + `addr:city` + postcode geocoding, OSM ids, opening hours /
website / phone in result details, admin hierarchy for display names
("Albert Heijn — Zaandam"). Same file format, better builder. This is the contract
to agree with the import agent; v1 exists so the search UI, ranking, and UX are
finished before v2 data arrives.
### Index format (`region.search`, owned by `libs/map_nav`)
Design goals: mmap/stream-friendly (match the disk-streamable ethos of the tile store),
zero-copy query, no runtime deps. Layout:
- **Doc table**: fixed-size records — coord (2×u32 fixed-point), category (u16 enum:
city, town, street, supermarket, restaurant, cafe, station, …), static rank (u8:
population for places, category weight for POIs), offsets into a string pool
(display name, secondary line).
- **Token index**: all names normalized (reuse/extend `normalize_label_key` in
`label.rs`: lowercase, unaccent, strip punctuation) and tokenized. Sorted token
table → binary search gives exact *and prefix* ranges for free. Each token has a
postings list of doc ids, pre-sorted by static rank so top-k short-circuits.
- **Category synonyms**: small static table in code mapping query words to categories
("supermarkt"/"supermarket"/"groceries" → shop=supermarket; "pizza" →
amenity=restaurant|fast_food + cuisine=pizza), so category queries return nearby
instances rather than name matches. NL + EN synonyms first.
- Typo tolerance (trigram fallback) is a later add — the format reserves a section id
for it; v1 is prefix-only, which covers the autocomplete-style UX.
### Query pipeline (background thread, debounced ~150 ms)
1. Normalize + tokenize; last token treated as a prefix.
2. Intersect postings (rarest token first); or category expansion when a token hits
the synonym table.
3. Score: match quality (exact > prefix, full-name > partial) + static rank +
proximity boost (distance to current position if set, else viewport center) —
`log2(distance)` penalty so "pizza" means "pizza near me".
4. Top 10 → `SearchResults` action to the UI thread.
### Search UI (`examples/map` first)
Floating panel over the map: `TextInput` + results list (name, category icon,
secondary line, distance). Enter/click → `fly_to` + drop marker; category queries can
pin all top-N results. Escape/map-tap dismisses. Promote to a reusable
`MapSearchPanel` widget once the UX settles.
Deliverable: type "centraal", get Amsterdam Centraal ranked above street matches,
fly there. Type "supermarkt", get the nearest ten with pins.
## Milestone 2 — Current position
### Position provider
`PositionSample { lon, lat, accuracy_m, heading_deg: Option, speed_mps: Option, time }`
behind a `PositionSource` abstraction with three implementations, in build order:
1. **Manual** — long-press → "set my position here". Trivial, immediately useful.
2. **Simulated** — plays back a polyline (later: a computed route) at a given speed
with synthetic heading + configurable GPS noise. This is the workhorse: it's how
nav (M4) gets developed and tested on a desk, and it feeds the same code path a
real receiver would.
3. **Platform** — real location services. Makepad's platform layer has no geolocation
API yet, so this is a new `Cx` service: `cx.start_location_updates()`
`Event::LocationUpdate`. macOS (CoreLocation via the existing objc bindings) first
since that's the dev machine; iOS shares CoreLocation; Android (FusedLocation via
JNI), web (`navigator.geolocation`) after. Permission plumbing per platform.
Schedule late — everything above it works against Manual/Simulated.
### Rendering + camera behavior
- **Puck**: accuracy circle drawn in *map* space (scales with zoom), blue dot +
white ring + heading wedge drawn zoom-constant (`ICON_SHAPE_ID` path). Interpolate
between 1 Hz fixes at frame rate (short lerp, mild dead-reckoning by speed/heading)
so the puck glides instead of teleporting.
- **Follow mode**: camera tracks the puck. Any user pan/zoom breaks follow; a
recenter button (standard GPS UX) re-engages it. Follow state lives in the app,
driven via the M0 camera API — the widget stays dumb.
Deliverable: simulated drive around Amsterdam with a gliding puck, follow mode, and
working recenter button.
## Milestone 3 — Routing
### Graph build (import pipeline, contract with the import agent)
Go straight to the **pbf-derived graph**`osmpbf` is already a dependency of
`tools/map_tiles`, and the tile-derived alternative (stitching clipped z14 `streets`
geometry) has no turn restrictions, lossy connectivity at tile borders, and
crossing-vs-junction ambiguity. Not worth building twice. (If the interaction track
ever gets far ahead of the import track, a tile-stitched graph behind the same file
format is the documented fallback — `bridge`/`tunnel`/`layer` disambiguate most
crossings — but it's plan B, not the path.)
Builder (`route-graph` subcommand, logic in `libs/map_nav::graph::build`):
1. Pass 1 over ways: keep `highway=*` filtered by access profile; count node usage.
2. Vertices = nodes used ≥2× + way endpoints. Edges = way segments between vertices,
carrying: length (m), speed class (from `highway` kind, `maxspeed` when present),
oneway, access flags per profile (car / bike / foot — Netherlands, so bike is not
optional), and the full segment geometry for drawing.
3. Turn restrictions from relations (`no_left_turn`, `only_straight_on`, …) stored as
(via-node, from-edge, to-edge) ban/only lists.
4. Serialize `region.graph`: CSR adjacency, u32 vertex ids, fixed-point coords, packed
edge attrs, geometry pool, plus a uniform **grid spatial index over edges** for
nearest-edge snapping.
Scale check: Noord-Holland is on the order of a million edges — plain in-memory graph,
loads in well under a second.
### Query engine (`libs/map_nav::graph::query`)
- Snap start/goal: grid lookup → project point onto candidate edges → virtual split.
- **Bidirectional A*** with a per-profile cost function (time-based; distance mode as
a flag). Province-scale queries land in tens of ms on a worker thread — no
preprocessing needed at this size. When we go country/Europe scale, add Contraction
Hierarchies *in the builder* (query side barely changes); the file format reserves a
section for shortcut edges now so the format doesn't break later.
- Turn restrictions handled by edge-based expansion at restricted via-nodes only
(cheap, standard trick — avoids paying edge-based-graph cost everywhere).
- Output `Route { polyline, length_m, duration_s, legs: Vec<Maneuver> }`.
### Route rendering
Route polyline via `DrawVector` in the overlay hook: casing + fill in a distinct
color, simplified by zoom (`geometry.rs` already has simplification helpers), the
already-traveled portion dimmed once nav is active. Start/end markers from M0.
Deliverable: long-press → "route here" from current position; route line + distance
and time appear.
## Milestone 4 — Turn-by-turn navigation
### Instruction generation (`libs/map_nav::nav`)
Walk the route's vertices: where bearing delta + road-class change warrant it, emit
`Maneuver { kind, at: coord, street_name, distance_from_start }` with kinds
Depart / Continue / TurnSlightLeft…SharpRight / Roundabout { exit_n } / Uturn /
Arrive. Roundabout exit counting from the graph topology. Street names come from way
tags (already in the graph edges).
### NavSession state machine
Owned by the app, fed `PositionSample`s, emits actions:
- **Map-matching**: project position onto route polyline near last known progress,
gated by heading agreement; monotonic progress (never snaps backward on noise).
- **Progress**: distance to next maneuver, remaining distance/time, ETA (recomputed
from live speed with per-class fallback speeds).
- **Off-route**: > ~30 m from route (accuracy-scaled) for > ~5 s → `Rerouting` state,
fire a background route query from current position, swap route on arrival. The
simulated source with noise (M2) is the test harness for exactly this.
- Maneuver announcements at distance thresholds (e.g. 500 m / 100 m / now) → actions
the UI turns into banner changes. Optional later: voice via `libs/tts` (kokoro) —
the announcement action is designed so a TTS consumer just subscribes.
### Nav UI (`examples/map`)
- Top banner: maneuver arrow icon + "300 m — turn left onto Prinsengracht".
- Bottom bar: ETA, remaining km/min, end-navigation button.
- Camera: follow mode centered ahead of the puck (position ~1/3 from bottom), zoom
scaled by speed. **North-up in v1.** Heading-up requires camera rotation, which the
renderer doesn't have — the transform is affine so it's feasible, but it's the
renderer agent's call; flagged as a coordinated stretch goal, not a v1 dependency.
Deliverable: simulated drive follows a computed route with live banner instructions,
and going off-route visibly triggers a reroute.
## Milestone 5 — Real-world hardening (post-MVP, ordered by value)
1. macOS CoreLocation backend (first real-GPS platform), then iOS/Android/web.
2. Search v2 index from pbf (full addresses, postcodes, admin hierarchy, details).
3. Camera rotation + heading-up nav (with renderer agent).
4. Voice guidance via `libs/tts`.
5. Typo tolerance (trigram section), multi-stop routes, avoid-motorway/ferry flags.
6. Europe scale: CH preprocessing, sharded search index, both keyed by region files.
## Artifact contracts (the import-agent interface, agree early)
| Artifact | Producer | Consumer | Contents |
|---|---|---|---|
| `region.mbtiles` | import pipeline | renderer | exists today (Shortbread tiles) |
| `region.search` | `map_tiles search-index` (v1: app self-builds from tiles) | search engine | doc table + token index + string pool, format owned by `libs/map_nav` |
| `region.graph` | `map_tiles route-graph` | router | CSR graph + turn restrictions + edge geometry + snap grid, format owned by `libs/map_nav` |
Formats versioned with a magic + version header; builders and readers live in the same
crate so they can't drift.
## Testing
- **Unit** (`libs/map_nav`, no UI): tokenizer/normalizer, ranking ("dam" must rank
Dam square over side streets), postings intersection; graph snap edge cases (near
junctions, dual carriageways); A* against hand-verified routes (a handful of
Noord-Holland pairs sanity-checked against an online router, asserted within a
tolerance); turn-restriction respect; maneuver generation on synthetic geometries
(roundabout exit counts).
- **NavSession sim tests**: drive a noisy simulated position along a route, assert
instruction sequence, ETA monotonicity, and that a forced detour triggers exactly
one reroute.
- **Headless render tests** (existing `MAKEPAD=headless` PNG harness +
`examples/*/tests` pattern): marker/route/puck overlay snapshots, search panel
open-with-results snapshot.
## Suggested execution order
M0 is small and unblocks everything — do it first. After M0, search (M1) and position
(M2) are independent and can proceed in parallel; both are pure-add. M3 starts with
the `region.graph` contract conversation with the import agent, then builder + A*
(testable entirely without UI), then the overlay. M4 builds strictly on M2+M3. The
riskiest unknowns are deliberately early: the MapView API touch (renderer-agent
coordination) is M0, and the graph contract conversation opens at the start of M3.

233
handoff.md Normal file
View file

@ -0,0 +1,233 @@
# handoff — the Makepad AI Game Maker (godot-like livecoding agent)
State as of 2026-07-10. Everything below is in the working tree, **nothing committed**.
Written as the handoff for `examples/gamemaker`: a voice-driven game maker for kids
where Claude Code live-edits a single splash script and the game hot-reloads in-process
— the replacement for the Godot-backed `examples/godot` (which still exists, untouched,
as the reference implementation).
## 1. What it is
```
kid holds F1, talks (Whisper) ──► Claude Code (via makepad_ai::ClaudeCodeAgent)
▲ │ edits ~/games/<name>/game.splash
│ TTS speaks replies ▼
Game Maker app ── chat pane │ GameView pane: splash isolate + fixed-60Hz world,
│ re-evals on every edit (aichat-style streaming),
│ broken edits never replace the running world
```
- One process. No engine restarts, no focus stealing. A clean edit is visible mid-turn
("the world grows while the AI talks"); a broken edit keeps the last-good world
running and returns line-numbered errors to the agent.
- The whole game is ONE file, `game.splash`, written in a curated ~45-verb `game.*`
DSL (deliberately smaller than GDScript — easier to prompt, verify, and keep stable).
- The agent self-verifies through `tools/ag`: screenshots, deterministic input-tape
playtests with numeric probes, error/log round-trip.
## 2. Engine architecture (examples/gamemaker)
**Host** (`src/main.rs`, cloned from the godot example): chat/voice/TTS/session shell,
per-game state in `<game>/.gamemaker/` (chat log, Claude session id, model), project
dropdown over `~/games/*/game.splash` (`GAMEMAKER_HOME` overrides), harness re-stamped
into each game on switch (`tools/`, `CLAUDE.md` — app-owned, kid files never touched).
System prompt teaches kid-talk rules + the DSL + the always-check-errors workflow.
Permission policy: `Edit/Write(./**)` + `Bash(./tools/ag:*)` only.
**GameView** (`src/game_view.rs`, ~2k lines — splitting into modules was repeatedly
deferred, do it before it grows again):
- **Script hosting**: dedicated splash isolate (`cx.alloc_splash_vm_with_network`),
incremental re-eval via `eval_with_append_source` parser checkpoints (the same
machinery aichat uses to stream `runsplash` blocks), instruction limits (2M per
eval / 500k per tick), pointer-stable ScriptMod keying. Reload triggers: 250ms mtime
watch + immediately on the agent's Edit/Write ToolRequest completing.
- **Last-good semantics**: eval snapshots the world (entities, parts, labels, terrain,
sky, HUD), rebuilds from scratch, rolls back wholesale on failure. Errors go to
`.agent/game.log` + `.agent/last_error.txt` (cleared-to-empty on success BY DESIGN —
"empty = your edit is live") via a platform addition: `ScriptVm::take_errors` +
`captured_errors` sink (streaming evals otherwise silence errors).
- **Dispatch design**: one native `game` handle over `Rc<RefCell<GameWorld>>`; every
`game.verb(...)` is a match arm in `game_dispatch` — synchronous mutation, no async
widget trampoline, deterministic ordering. Adding a verb = one arm + one doc row.
- **Physics**: deliberately tiny — gravity + axis-separated AABB sweeps, kinematic
carry, sensors, mover ground detection, terrain height-lookup collision with 0.55
step-up. **The physics body NEVER rotates — only the visual model yaws.** This
matches Godot exactly (their CharacterBody3D does the same); it is the reason a
mini-engine reaches parity. box3d swap remains a marked seam (see §5).
- **Renderer**: offscreen 3D pass composited into the pane. Shaders (script-DSL,
JIT-compiled on headless): `DrawGameCube` (instanced lit boxes + emission + fog),
`DrawGameAlpha` (translucent pass: sensors, water, blob shadows), `DrawGameSky`
(gradient dome), `DrawGameTerrain` (triangulated heightfield, per-vertex color).
Near plane **1.0** (Godot's CAM_NEAR) so lens-overlapping creatures clip open
instead of filling the screen. Billboard labels project into a 2D overlay with
automatic 4-copy dark outlines; HUD slots + crosshair are plain overlay draws.
- **Input**: ActionMap — keyboard (WASD/arrows, Space, F=shoot, G=grab) + gamepad
(`cx.game_input_states`: stick analog w/ deadzone, dpad, A=jump/X=shoot/B=grab,
edge-detected) merged at read time via `PadState` (never into the key-held set, so
devices can't cancel each other). Input snapshot per tick: held/pressed booleans,
raw `axis_x/axis_z`, **camera-relative `move_x/move_z`** (the canonical walk input;
the axes rotated by effective camera yaw — derived from the camera basis, and the
fix for "controls don't match the camera": the first documented recipe rotated by
yaw). Tapes zero the pad and pin yaw+pitch → byte-identical replays.
- **Audio** (`src/synth.rs`): polyphonic synth (osc + percussive envelope, 24-voice
cap) mixed into the app's audio callback under the TTS voice; mute button silences
both. Named bank (18: jump shoot zap grab angry calm rescue shove board coin hurt
win lose squeak roar bark moo clank whip) + `game.beep{}` + `game.jingle("C5 E5")`.
The Godot corpus AI hand-built a synth in GDScript when it had no audio API —
that's why this is an engine service.
- **Agent harness** (`tools/ag` + file RPC through `.agent/`): `peek` (4 live frames
via `Cx::capture_next_frame_to_file` — a platform addition riding the studio
screenshot pipeline — + entity state), `test N tape` (restart, frame-indexed tape
through the ActionMap, captures + `probe.txt`, `sheet.py` contact sheet), `errors`,
`logs`. Tape format is the Godot harness's JSON unchanged. Errors are also
PUSHED into the agent chat (GameViewAction → auto fix wake-up on post-turn broken
evals and idle runtime errors; 2-wake-up guard, reset by a kid message;
GAMEMAKER_NO_AGENT=1 disables the agent for token-safe headless tests). Engine
registration errors surface in the status bar, never in the kid chat.
**DSL surface** — the authoritative, always-in-sync docs:
- **repo-root `splashgame.md`** — THE agent-facing API contract, loaded into the
system prompt at runtime (fs read + include_str fallback), exactly the pattern
aichat uses for `splash.md`. Adding a verb = dispatch arm + a row here.
- `resources/template/CLAUDE.md` — per-game workflow only (ag ritual, house style,
gotchas); points at the system prompt for the API.
- `resources/aigame-dsl.md` — developer guide (execution model, extension points).
Spawning: `box/mover/spawn/terrain/part/label`. Driving: `on_tick(dt, input)`, `walk/
jump/on_floor/pos/vel/set_pos/set_vel/face/yaw`, `find/tag/distance`, timers `after`,
`on_touch`. Look: `set_color/glow/scale/move_part/sky/camera` (orbit / `side:` /
`third_person:` with occlusion pull-in ignoring `"scenery"`). Systems: `attach`
(seat + `mode:"ride"` w/ spin)/`detach`, `speed_mult`, `beam`, `ground_y/ground_peak`,
`rand/rand_range` (seeded per eval — tape-deterministic, better than the corpus's
`randomize()`), HUD `text(slot,…)/crosshair`, labels w/ ids + `label_text`, sounds.
Terrain noise is shaped engine-side (`freq/offset/amp/step/min/max/plaza`, ≤384
cells, `bands:` height-color thresholds — snow bands are what make hills read as
mountains) so big worlds cost no script instruction budget.
## 3. How parity was reached (method matters as much as the result)
1. **Corpus first**: the AI-generated Godot game (`~/games/my-game`, grew to ~44
creatures) was inventoried exhaustively (`aigame_port_inventory.md`), and every
engine feature exists because the corpus used it — nothing speculative. Confirmed
non-needs (no tweens/particles/navmesh/shaders in 2600+ generated lines) were
deliberately NOT built.
2. **Port as acceptance test**: the game was ported to
`resources/fixtures/sandbox3d.splash` (932 lines, 90 entities — ~2100 lines of
GDScript; the compression is the argument the DSL is at the right altitude).
Each port round produced findings (`aigame_port_findings.md`); findings became
engine verbs; the fixture re-ported until clean. Gap analysis lives in
`aigame_parity_gap.md`; the original plan in `aigame.md`.
3. **Verification discipline**: headless eval cleanliness + numeric probes against
Godot ground truth (plaza floor exactly 7.9, walk exactly 6.0, jump apex JUMP²/2G,
ride-debuff exactly 3.0) + determinism (two tape runs byte-identical). Pixels on
GPU were checked by rik; pure-headless pixels are blocked on §5.5.
The fixture runs the CURRENT game: Giant DogDay guardian (intercept-charge, beam
bonk-arc), 3 headcrabs (leap→latch→speed debuff→jump to shake off), the Prototype
weeping-angel (LOS dot vs camera), Baba Chops (glow-ramp fire eyes, ram), Nightmare
Huggy (arm-reach via move_part), CatNap sleep-curl (scale), Kissy bodyguard, heal
quest, 10 farm animals (per-kind sfx pitch), trucks + passengers, grapple hand on G
(terrain yank / creature haul, beam cable), 257×257 smooth terrain at Godot's exact
constants with banded snow mountains, sky/fog/blob shadows, crosshair/hint/flash HUD.
Installed for rik as `~/games/dogday-world`.
## 4. Infrastructure built along the way (reusable beyond gamemaker)
- **Headless render/test pipeline**: `MAKEPAD=headless` builds render frames to PNG
via CPU raster + JIT-compiled Rust shaders (`--draws=N`,
`MAKEPAD_HEADLESS_OUT_DIR`); warm cache at `examples/splash/target`. gamemaker has
its own `build.rs` mirroring the platform's env→cfg wiring.
- **Three JIT shader-compiler bugs fixed** (platform/script + headless preamble):
scalar casts `u32(x)``as` casts; `Mat4f*Mat4f` missing in the runtime preamble;
heterogeneous constructors splat-padding because `ShaderType::Id` args weren't
scope-resolved. Regression stages 4k/4l in `platform/script/test/src/main.rs`.
- **`ScriptVm::take_errors` + `captured_errors` sink** (platform/script/src/vm.rs) —
the error round-trip primitive; should be upstreamed into the `Splash` widget too
(aichat runsplash blocks still render blank on error).
- **`Cx::capture_next_frame_to_file`** (platform/src/os/cx_shared.rs + metal path) —
in-app window capture without the studio bridge.
- **xr on box3d**: the whole `makepad-xr` crate ported off Rapier3D (raycast vehicle
reimplemented in `xr/src/scene/raycast_vehicle.rs`; 4 pre-existing test failures
match the Rapier baseline exactly). box3d `body_set_mass_data` stale
world-inverse-inertia bug found + fixed with a regression test.
- The **godot example harness** got the no-focus-steal treatment first (open -g,
unfocusable offscreen capture window, last-good relaunch policy) — kept as-is.
## 5. Remaining work for FULL parity (ranked)
1. **Caves / overhangs** — the only dropped world feature. Heightfields can't carve.
Options: engine `game.tunnel(a, b, r)` laying rock-slab roofs (matches the Godot
`_carve_caves` approach — it lays roof slabs too, it does NOT boolean-carve), or
accept boxes-as-caves authored by the AI.
2. **`game.raycast(from, dir, {mask})`** — the grapple currently probes terrain
height only; it can't catch trees/boxes/creatures mid-flight, and ledge-probe AI
(the corpus's no-navmesh pathing trick) can't be written. One verb unlocks both.
3. **Real shadow maps** — blob shadows ground creatures but the 21:54 Godot capture
has directional shadows. A single-cascade sun map over the play area is enough.
4. **Camera-overlap creature fade** — near-clip 1.0 fixed the giant-polygon fill;
inside a crowd you can still sit within a body. Fade entities whose AABB
intersects a small camera sphere (Godot mitigates via its spring-arm feel).
5. **Headless offscreen-pass compositing** (task #9) — box3d example renders nothing
headless; gamemaker's pane is blank in pure-headless frames (GPU capture path is
fine). Blocks CI-grade visual verification of `ag test` sheets. Look at
`platform/src/os/headless/event_loop.rs` pass scheduling + overlay draw lists +
NextFrame-only apps never drawing.
6. **box3d under the verbs** — the mini-AABB physics is the deliberate seam
(`TODO(aigame)` in game_view.rs). Swap when games need slopes-with-momentum,
stacking, ragdolls, vehicles-with-suspension. box3d is deterministic and already
in-tree; keep the tape guarantees.
7. **The 2D side-scroller** (`main.tscn`) was never ported — `side:` camera + boxes
cover it in principle; port it as a second fixture to harden 2D ergonomics
(AnimatableBody movers + Camera2D limits analogues).
8. **Engine polish debt**: terrain band colors interact with auto-shade (bands win —
fine, but no slope shading within a band); `game.terrain` columns mode still
spawns per-cell entities (instanced draw + height collision would retire it);
part transforms have no rot lerp shortest-path handling; gamepad stick vertical
sign untested on hardware (one-line flip if inverted).
9. **Perf headroom**: 90 entities × parts ≈ fine; the tick budget (500k instructions)
fits the fixture's ~15 actors of AI — a 100-creature brawl will need either budget
raise or engine-side steering helpers (`walk_towards`, `flee`) which the corpus's
shared `_drive()` suggests anyway.
10. **Future (from aigame.md phase 3)**: host the scene layer on `XrNode` so the same
game.splash runs on Quest (xr is on box3d now, and has hands/multiplayer);
upstream the error round-trip to `Splash`; batch corpus regression harness
(splash_preview-style: run recorded kid asks through the real CLI against the
DSL guide, eval + tape-smoke each result).
## 6. Run / verify cheatsheet
```bash
# the app (from the makepad repo root; Whisper model resolves from CWD)
cargo run -p makepad-example-gamemaker --release
# rik's install: ~/games/dogday-world (set as .last), full fixture
# headless smoke of any game dir
MAKEPAD=headless CARGO_TARGET_DIR=examples/splash/target \
MAKEPAD_HEADLESS_OUT_DIR=/tmp/frames GAMEMAKER_HOME=<games-root> \
cargo run -p makepad-example-gamemaker --release -- --draws=6
# then: <game>/.agent/game.log ("eval #N: ok, E entities"), last_error.txt empty
# AND count startup script errors: ... 2>&1 | grep -cE "^\[E\]" must be 0
# (shader/widget REGISTRATION errors print as [E] lines but do not fail the run —
# a missed [E] once shipped a build with the terrain shader dead)
# agent-side playtest, in a game dir (app running)
./tools/ag errors | logs | peek | test 200 tools/tapes/<tape>.json
# sheet: .agent/sheet.png probes: .agent/probe.txt (byte-identical across runs)
# engine tests touched by this work
cargo run -p makepad-script-test --release # shader codegen stages 4k/4l
cargo test -p makepad-box3d --release # incl. mass-data regression
cargo test -p makepad-xr --release # 127 pass / 4 pre-existing
```
## 7. Document index
| doc | what |
|---|---|
| `aigame.md` | the original migration plan (Godot → splash), phases + rationale |
| `aigame_port_inventory.md` | exhaustive Godot API usage of the generated corpus |
| `aigame_port_findings.md` | port findings v1+v2: API cleanups, fidelity ledgers, open gaps |
| `aigame_parity_gap.md` | the final gap matrix (current game vs engine) + fork specs |
| `examples/gamemaker/resources/aigame-dsl.md` | developer guide to the DSL + engine internals |
| `splashgame.md` (repo root) | THE agent-facing API contract, loaded into the system prompt (keep in sync!) |
| `examples/gamemaker/resources/template/CLAUDE.md` | per-game workflow stamped into each project |
| this file | orientation + what's left |

228
isolate.md Normal file
View file

@ -0,0 +1,228 @@
# Splash UI-Thread Isolate Changes
## Target
Run each inline `Splash` widget in its own script VM while still creating and mutating native widgets on the UI thread.
The MVP is not a worker-thread sandbox. It is UI-thread VM isolation with sampled wall-clock guards so Splash scripts cannot monopolize the UI loop indefinitely.
## VM Identity
Use one VM id type everywhere:
```rust
#[derive(Clone, Copy, Eq, PartialEq, Hash)]
struct SplashVmId(u64);
const MAIN_SPLASH_VM_ID: SplashVmId = SplashVmId(0);
```
Rules:
- `SplashVmId(0)` is the existing app VM.
- New Splash isolates allocate ids from `1` upward.
- Widget/script routing carries `SplashVmId`, not a `ScriptVmOwner` enum.
- Unknown app widgets default to `MAIN_SPLASH_VM_ID`.
## Cx Storage
Add isolated VM storage under `CxScriptData`:
```rust
struct IsolatedScriptVms {
next_id: u64,
vms: HashMap<SplashVmId, IsolatedSplashVm>,
widget_owners: HashMap<WidgetUid, SplashVmId>,
}
struct IsolatedSplashVm {
std: ScriptStd,
vm: Option<Box<ScriptVmBase>>,
initialized: bool,
generation: u64,
wake_pending: bool,
consecutive_over_budget: u32,
}
```
Add helpers:
- `Cx::with_script_vm_id(vm_id, f)`: routes id `0` to the app VM, non-zero ids to isolated VMs.
- `Cx::with_splash_vm(id, f)`: non-zero isolate helper.
- subtree owner registration/unregistration for widgets produced by a Splash view.
## Splash Eval
Change `Splash::eval_body` from app-VM eval to isolate eval:
```text
Splash::set_text
-> allocate or reuse SplashVmId
-> initialize restricted Splash VM once
-> eval_with_append_source under wall-clock budget
-> View::script_from_value in the same VM
-> register produced widget subtree with that SplashVmId
-> replace the native View
-> redraw
```
The restricted Splash VM registers the widget/runtime modules needed for normal widget DSL, but does not inherit host app script globals. Filesystem, process spawning, and arbitrary network remain disabled by default.
## Widget Message Routing
Make widget async routing VM-id-aware.
Update widget-to-script requests:
```rust
struct WidgetToScriptCallRequest {
vm_id: SplashVmId,
target_uid: WidgetUid,
// existing callback/function/arg fields
}
```
Dispatch:
- `vm_id == MAIN_SPLASH_VM_ID`: use existing app `cx.with_vm`.
- non-zero `vm_id`: use `cx.with_splash_vm`.
Update script-to-widget async calls the same way:
- pending returns keyed by `(SplashVmId, ScriptThreadId)`;
- `ui` handle injection is per VM;
- `View.render()` callbacks and returned objects apply in the same VM that requested them.
Move `TextInput` callback execution onto the same VM-id-aware queue instead of directly calling `cx.with_vm`.
## UI Loop Pumping
Use the existing UI service points:
- after `Cx::call_event_handler`, pump app tasks, then pump runnable Splash isolates;
- on `Event::Signal`, pump isolates with `wake_pending` or queued work;
- on `TimerEvent`, route script timers by `SplashVmId`;
- on network responses, only route to Splash isolates if network is explicitly enabled.
Scheduling policy:
- round-robin non-zero Splash ids;
- per-isolate soft wall-clock slice;
- aggregate per-frame Splash wall-clock cap;
- one wake request per isolate while work is pending, using `wake_pending` to avoid signal storms.
## Tasks And Async
Each isolate owns its own `ScriptStd`, including `ScriptTasks`.
Add a budgeted task pump:
```rust
fn handle_script_tasks_budgeted(
host: &mut dyn Any,
std: &mut ScriptStd,
script_vm: &mut Option<Box<ScriptVmBase>>,
budget: &mut ScriptRunBudget,
) -> ScriptPumpOutcome;
```
Stop pumping when:
- no work progresses;
- the isolate time slice is exhausted;
- the frame-wide Splash time budget is exhausted;
- the isolate hits hard overrun policy.
Do not discard unfinished work on a soft time yield. Leave pending resumes, task queues, and pump-hook work in that isolate's `ScriptStd`.
`widget_async` task hooks must be installed per `ScriptStd` or per `SplashVmId`, not behind one global app-only boolean.
## Timers
Make script timers VM-id-aware:
```rust
struct CxScriptTimer {
vm_id: SplashVmId,
id: LiveId,
repeat: bool,
timer: Timer,
callback: ScriptFnRef,
}
```
`std.start_timeout`, `std.start_interval`, and `std.stop_timer` operate on the current VM id. `stop_timer` only removes timers owned by that VM id.
Timer callbacks run through `Cx::with_script_vm_id` under the timer callback wall-clock budget. On repeated interval overruns, stop the timer and report an isolate diagnostic.
## Sampled Wall-Clock Guard
Add a wall-clock budget checked inside `ScriptVm::run_core`, but only sample time every N instructions:
```rust
pub struct ScriptRunBudget {
pub soft_deadline: Instant,
pub hard_deadline: Instant,
pub sample_interval_instructions: u32,
pub instructions_until_sample: u32,
pub reason: ScriptBudgetReason,
}
```
Deadline meaning:
- `soft_deadline`: cooperative yield point. The current script thread stays resumable, the isolate is queued for a later signal/frame, and this is not treated as an error.
- `hard_deadline`: runaway failure point. The current script execution is bailed, pending work for that isolate generation is cleared, and a diagnostic is reported.
Interpreter loop behavior:
```text
each opcode/direct value
-> instructions_until_sample -= 1
-> if zero:
instructions_until_sample = sample_interval_instructions
now = monotonic_time()
if now >= hard_deadline: hard time-budget failure
if now >= soft_deadline: soft time-budget yield
```
Do not reuse `ScriptTrapOn::Pause` for budget yield. Add a distinct outcome/trap, for example `TimeBudgetYield`, so async pauses still mean "waiting for external work".
Soft time yield:
- preserve thread state and instruction pointer;
- queue the isolate for a later signal/frame;
- return control to the UI loop without treating it as an error.
Hard time failure:
- bail the current thread;
- clear pending timers/tasks/callbacks for that isolate generation;
- keep the previous valid Splash view if possible;
- show/log a diagnostic.
Native Rust calls cannot be preempted mid-call. For Splash VMs, sample immediately before and after native calls so execution stops after an overlong native call returns.
## Implementation Order
1. Add `SplashVmId`, id `0` main routing, and isolated VM registry storage.
2. Add `Cx::with_script_vm_id` and isolate initialization.
3. Add sampled wall-clock budget support to VM entry points and `run_core`.
4. Move `Splash::eval_body` to isolated VM eval and subtree owner registration.
5. Make widget async requests, returns, `ui`, `View.render()`, and `TextInput` VM-id-aware.
6. Make task hooks and task pumping per VM id and budgeted.
7. Make script timers VM-id-aware and budgeted.
8. Add isolate pumping to event, signal, timer, and optional network paths.
9. Add stale subtree unregistering and isolate generation cleanup.
## Tests
- Two Splash widgets keep independent state and callbacks.
- Splash callback updates only its own subtree.
- `TextInput.on_return` runs in the owning Splash VM.
- `View.render()` callback and result apply in the owning Splash VM.
- Infinite loop yields or is killed by wall-clock policy while the app UI remains responsive.
- Requeueing task is limited by per-isolate pump budget.
- Interval callback cannot run in app VM and is stopped after repeated overruns.
- `stop_timer` from one VM cannot cancel another VM's timer.
- Replacing Splash body invalidates stale callbacks from the previous generation.

259
layers.md Normal file
View file

@ -0,0 +1,259 @@
# Overlay data layers — work log & status
Status 2026-07-28. This documents the overlay-layer track: the `libs/geodata`
crate, the nine built layer databases, the live rain-radar sync, and the LLM
query surface. Companions: `datasources.md` (source survey + licenses, the
research this implements), `gps.md` (interaction layer), `libs/geodata/README.md`
(the crate's own contract-level docs). The map renderer is a separate track —
nothing in here touches `widgets/src/map`.
## TL;DR
All planned NL layers are **built, integrity-checked, and query-verified** in
`local/overlays/nl-<layer>.mbtiles` (one file per layer, never merged into the
base Europe archive). Every vector layer is simultaneously:
1. **renderable** — standard gzipped MVT 2.1 tiles the renderer's existing
decoder already parses, and
2. **LLM-queryable** — a grid-indexed `features` sidecar table + `query::LayerDb`
(point / radius / bbox → structured JSON) for "reason with the map" tool calls.
Raster layers use terrarium RGB (elevation) or gray8 class-index encoding with
a `geodata_classmap` metadata table (class → label + suggested color), so the
shader colormaps and the query side can *name* values.
| layer | content | zooms | size | build | verified by |
|---|---|---|---|---|---|
| `nature` | Natura 2000 + Ramsar wetland polygons | 612 | 6.2 MB | 0.6 s | polygons + rings in sidecar |
| `chargers` | 66,583 EV charging locations (operator, kW) | 814 | 26 MB | 2.9 s | Krasnapolsky charger @128 m from Dam |
| `demographics` | CBS 500 m + 100 m grid stats (442k cells) | 813 | 275 MB | 9.4 s | population at point |
| `wijkbuurt` | gemeente/wijk/buurt polygons + kerncijfers | 613 | 91 MB | 4.8 s | Dam → Amsterdam → Burgwallen-NZ → Nieuwe Kerk e.o. |
| `transit` | all NL stops + 1,457 non-bus route shapes | 714 | 14 MB | 6.1 s | streamed from 234 MB GTFS |
| `buildings-age` | **all 11,407,303 BAG buildings**, bouwjaar+status | 1314 | 1.6 GB | 91 s | Royal Palace → bouwjaar 1655 |
| `terrain` | GLO-30 elevation, terrarium, NL bbox | 612 | 330 MB | 57 s | 18/20 cells; 2 sea-only 404s by design |
| `noise` | RIVM 10 m Lden → 5 dB classes | 613 | 34 MB | 23 min | A10 tile: 6575 dB spine over 5055 dB city |
| `flood` | JRC 1-in-100y river flood depth classes | 611 | 2.7 MB | 3.6 min | class histogram plausible |
Plus the live source: **rain radar** (`RadarSync`) — pulled a real KNMI +2h
nowcast minutes after publication; poll-gated, cache-pruning, app-embeddable.
CLI quick reference (`cargo run -p makepad-geodata --bin geodata` or the
release binary):
```
geodata list | fetch <layer|all> | build <layer|all> | status
geodata query <layer> <lon> <lat> [--radius m] [--limit n]
geodata radar-sync [forecast|reflectivity]
```
## Architecture
`libs/geodata` is a **library first** (the map app will embed it for radar
sync, periodic source refresh, and the query surface), with a thin `geodata`
CLI. Workspace member; depends only on `makepad-mbtile-reader`,
`makepad-map-nav`, `flate2`, `serde_json`.
Data flow per layer:
```
bulk source file ──fetch.rs──▶ local/overlays/cache/ (+ .meta.json)
(GPKG / zip / json.gz / GeoTIFF, verified URLs, polite)
──layers/<id>.rs──▶ parse + reproject to WGS84
──tiler.rs / spool.rs──▶ clipped MVT features per (z,x,y)
└──▶ sidecar.rs features records
──MbtilesWriter──▶ local/overlays/nl-<id>.mbtiles
├─ tiles (gzip MVT | PNG)
├─ features (query sidecar)
└─ metadata (license, classmap, TileJSON)
```
Key decisions and why:
- **One .mbtiles per layer.** Independently rebuildable/shippable/deletable;
the base-map archive (owned by the import-pipeline track) is never touched.
- **Bulk downloads only.** No API paging/spidering — search-shaped APIs
(Wikipedia, Overpass, NDW live traffic, GTFS-RT) will be integrated in the
map app, queried by viewport context at runtime. Radar is the one live
source here and polls its official file API at most once per data refresh.
- **Reuse over reimplementation** (per the "don't duplicate the map engine's
math" rule): mercator projection comes from `makepad_map_nav::geo`; SQLite
read *and* write go through `makepad-mbtile-reader` — GeoPackages are just
SQLite files, so the reader ingests PDOK/CBS/BAG data directly. Genuinely
new code only where nothing existed: RD New ↔ WGS84 polynomials, WKB
parsing, MVT encoding, a GeoTIFF subset reader, a PNG codec, clipping/tiling.
## Politeness (the "don't get banned" layer)
Enforced inside `fetch.rs`/`radar.rs` so no layer module can violate it:
- descriptive User-Agent with contact address; one transfer at a time; 1 s
pause after every network hit
- a cached file is not even *revalidated* before its `recheck_days` age
(BAG 45 d, CBS 90 d, chargers 2 d, DEM ~forever); afterwards
If-Modified-Since revalidation makes an unchanged file cost one 304, zero bytes
- interrupted downloads resume (`.part` + `-C -`); optional `--limit-rate`
for small government origin servers
- radar: the poll gate is armed *before* the request (a failing API cannot be
hammered — this was found and fixed when the shared KNMI anonymous key
429'd), plus request pacing and a single 429 backoff-retry
- every cached file carries a `.meta.json` (url, license, fetch time)
## Database contract (what the renderer AI and app consume)
Written by `MbtilesWriter`: 64 KB pages, deterministic block-major rowids
(direct-seek tile lookup), passes `PRAGMA integrity_check` — verified on all
nine outputs. Standard SQLite tooling reads everything.
- `tiles` — gzip MVT 2.1, extent 4096 (vector; `format=pbf`) or PNG 256 px
(raster; `format=png`). The renderer's gzip sniff + MVT decode work as-is;
MVT winding follows the spec (exterior CW in y-down tile coords).
- `metadata` — mbtiles standard keys, `attribution` + `license` (for the
attribution screen), TileJSON-style `json.vector_layers` (field names and
types per MVT layer — the renderer can style data-driven without guessing),
`geodata_encoding` + `geodata_classmap` for rasters, `geodata_built_unix`.
- `features` — the query sidecar (vector layers): `cell, layer, name,
min_lon, min_lat, max_lon, max_lat, attrs (JSON), ring (JSON|null)` with
rowid = `(z12 grid cell << 24) | seq`. Bbox queries become pruned b-tree
range scans — no SQL engine, no extra index. Polygon layers opt into
storing a simplified exterior ring (≤96 pts, ~10 m tolerance) for exact
point-in-polygon.
## Per-layer notes
**nature** — PDOK Natura 2000 + wetlands GPKGs (CC0, ~15 MB). MVT layers
`natura2000` (naam_n2k, sitecodes, status…) and `wetlands`; every feature also
tagged `kind`. Rings stored.
**chargers** — NDW national OCPI aggregate (`charging_point_locations_ocpi.json.gz`,
open data, refreshed ~daily; recheck 2 d makes this the natural app-side
periodic re-sync candidate). Attrs: name, operator, city, evses, connectors,
max_kw (from `max_electric_power` or volts×amps fallback).
**demographics** — CBS Vierkantstatistieken 500 m (z8z11) + 100 m (z12z13)
grids; 139/134 columns of per-cell stats carried through with CBS suppression
sentinels (negatives) dropped. Square cells mean bbox containment is exact —
no rings needed.
**wijkbuurt** — CBS Wijk- en Buurtkaart 2025: `gemeenten` (z68), `wijken`
(z910), `buurten` (z1113) with kerncijfers. Rings stored → exact
administrative lookup for any coordinate.
**transit** — OVapi static GTFS (CC0, no key). CSVs are streamed out of the
234 MB zip (`unzip -p`, never extracted). `stops` points (stations from z8,
stops from z10) + `routes` lines for rail/tram/metro/ferry (bus shapes
excluded deliberately: they follow roads already on the map and triple the
size). Live vehicle positions are explicitly app-side (GTFS-RT), not here.
**buildings-age** — PDOK `bag-light.gpkg` (CC0, 7.8 GB, monthly). The one
layer that can't fit the in-memory tiler: `SpoolTiler` clips features in one
pass and spools compact records to per-(zoom, 256×256-block) disk files — NL
is only 5 blocks at z13/z14 — then loads one block at a time in writer rowid
order. 11.4M polygons → 20,229 tiles + an 11.4M-row sidecar in 91 s, peak
memory one block. MVT layer `bag`: bouwjaar, status.
**terrain** — Copernicus GLO-30 DSM COGs from anonymous AWS S3, 20 one-degree
cells for NL; all-ocean cells 404 upstream and are treated as sea level (2 of
20 did — as expected). Terrarium encoding is the shared contract: renderer
hillshade/3D terrain later, and `map_nav` samples the same file to bake
per-edge climb/descent for EV routing (the elevation thread from
`datasources.md`).
**noise** — RIVM "Geluid in Nederland" Lden all-sources 10 m GeoTIFF (CC0),
EPSG:28992 — sampled through the WGS84→RD inverse polynomial. dB values are
binned into 5 dB classes (1: <45 … 8: ≥75) at encode time; the classmap
metadata carries labels + colors. 23-minute build (≈800M bilinear samples of
a deflate-tiled national raster through the pure-Rust TIFF reader) — fine for
a yearly-refresh batch job.
**flood** — JRC Europe river flood hazard RP100 depth GeoTIFF (CC BY, WGS84
~90 m), binned to depth classes (<0.5 m … ≥4 m). Small and cheap. Follow-ups
noted: JRC's `spurious_depth_areas` mask, and PDOK's official ROR zone
polygons (CC0) as a vector companion.
**radar (live)** — `radar.rs`: KNMI Data Platform datasets `radar_forecast`
(+2 h nowcast, whole animation in one ~0.5 MB HDF5 file every 5 min; the
map-app default) and `radar_reflectivity_composites` (5-min frames, keeps
~1 h). `RadarSync::sync()` is safe to call arbitrarily often — network at
most once per `min_poll_secs` (240 s default), downloads only missing files,
prunes old ones, `state()` never touches the network. API key: config →
`KNMI_API_KEY` env → the documented shared anonymous key (dev-only; its
quota is shared — register a free personal key for real use).
## The query surface (LLM goal)
```rust
let mut db = query::LayerDb::open("local/overlays/nl-wijkbuurt.mbtiles")?;
db.query_point(4.8926, 52.3731, 10)?; // exact point-in-polygon
db.query_radius(4.8926, 52.3731, 400., 5)?; // nearest-first + distances
db.query_bbox(min_lon, min_lat, max_lon, max_lat, 100)?;
```
Live-verified answers (via `geodata query`):
- Dam Square → gemeente **Amsterdam** (pop 934,526) → wijk
**Burgwallen-Nieuwe Zijde** (4,345) → buurt **Nieuwe Kerk e.o.** (830,
9,827 /km²) — exact containment via stored rings.
- Chargers near Dam: **Krasnapolsky at 128 m** (Eneco, 22 kW), with distances.
- Royal Palace → **bouwjaar 1655**, "Pand in gebruik" — out of 11.4M buildings.
- Population grid cell at Dam: 1,735 inhabitants (vk500).
The intended wiring: the map app exposes these as LLM tool calls (and as
tap-to-inspect UI). Every layer answers from the same three query shapes, and
attrs come back as JSON with the source's own column names.
## Infrastructure added to shared crates (all additive)
`libs/mbtile_reader`:
- `open_sqlite` / `schema_entries` / `for_each_row` — generic SQLite table
reading; what lets GeoPackages be ingested with zero new dependencies.
- `for_each_row_in_range` — pruned b-tree descent for rowid ranges; the
engine under the sidecar's spatial queries.
- Writer: `begin_extra_table` / `write_extra_row` (+ `WriterValue`, float and
NULL record support) — arbitrary extra tables alongside `tiles`/`metadata`.
- All pre-existing writer/reader tests still pass; new outputs pass
`PRAGMA integrity_check`.
`libs/geodata` internals worth knowing:
- `geo.rs` — RD New ↔ WGS84 both directions (Schreutelkamp/Strang van Hees
polynomials), round-trip tested < 1 m at Amsterdam/Rotterdam/Maastricht/
Groningen; `tile_order_key` replicating the writer's rowid order.
- `tiff.rs` — GeoTIFF subset: tiled + striped, deflate/LZW/uncompressed,
predictors 1/2/3 (incl. the floating-point predictor), int/float samples,
ModelPixelScale/Tiepoint georeferencing, GDAL nodata, block cache. Proven
against Copernicus COGs, the JRC Europe raster, and RIVM's RD raster.
- `png.rs` — encoder + decoder for gray8/RGB8 (hand-rolled CRC32, round-trip
tested). `raster.rs` — sampler → 256 px tile pyramid.
- `mvt.rs` — spec-correct MVT 2.1 encoder with key/value dedup.
- `spool.rs` — the country-scale streaming tiler described above.
Test suite: 7 unit tests in geodata (projections, round-trips, ordering, PNG,
ISO-8601), 9 in mbtile_reader. Byte-level MVT/gzip verification and SQLite
integrity checks run against every produced artifact.
## Open follow-ups (in rough priority order)
1. **Radar HDF5 → raster decode** so frames render (and a small
`RadarFrame::to_grid()` for querying "rain at my location in 30 min");
OPERA/MeteoGate (CC-BY COGs) after that for Europe-wide radar.
2. **Raster point-query helper** in `query.rs` (PNG-decode a tile, return
elevation / class + classmap label) — completes the LLM surface for
terrain/noise/flood ("how high / how loud / what flood depth here").
3. **EV grade baking**: map_nav's `nav-build` samples `nl-terrain.mbtiles`
per edge (the datasources.md EV thread).
4. Flood vector companion (PDOK ROR, CC0) + JRC spurious-areas mask.
5. Scale-out to Europe per country as sources allow (the machinery is
region-agnostic; NL-specific parts are just the RD transform and source
URLs).
6. Minor: neighbor-tile point buffering, Douglas-Peucker simplification,
BigTIFF, replacing shelled-out `unzip`.
## Verification ledger
- All 9 mbtiles: `PRAGMA integrity_check` = ok (validates the hand-rolled
SQLite writer end-to-end, incl. extra tables and >1 GB files).
- MVT: tile payloads gzip-wrapped (renderer sniff), protobuf structure
byte-checked, winding per spec.
- Geo math: RD origin exact; RD↔WGS84 round trip < 1 m ×4 cities; Amsterdam
sanity; ISO-8601 epoch math vs hand-computed constants.
- Content spot-checks: Royal Palace 1655; Dam admin hierarchy; charger
distances; A10 noise spine; terrain cell coverage 18/20 with sea handling.
- Radar: real nowcast downloaded; poll gate verified (second call → zero
network); 429 backoff exercised against the live saturated anonymous tier.

View file

@ -13,6 +13,4 @@ repository = "https://github.com/makepad/makepad/"
metadata.makepad-auto-version = "Xn0xvOdZChZbltM8tMBk0zLdOBc="
[dependencies]
## Note: we must not use local 'path' dependencies on `makepad-jni-sys`
## in order to guarantee that only one instance of each crate exists in the app binary.
makepad-jni-sys = {version = "0.4.0"}
makepad-jni-sys = {path = "../jni-sys"}

View file

@ -61,7 +61,7 @@ pub unsafe extern "C" fn JNI_OnLoad(
}
#[no_mangle]
extern "C" fn jni_on_load(vm: *mut std::ffi::c_void) {
pub extern "C" fn jni_on_load(vm: *mut std::ffi::c_void) {
unsafe {
VM = vm as _;
}

View file

@ -349,13 +349,6 @@ pub const kCMTimeInvalid: CMTime = CMTime {
epoch: 0,
};
pub const kCMTimePositiveInfinity: CMTime = CMTime {
value: 0,
timescale: 0,
flags: kCMTimeFlags_Valid | kCMTimeFlags_PositiveInfinity,
epoch: 0,
};
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct CMSampleTimingInfo {
@ -1019,10 +1012,10 @@ pub struct MTLClearColor {
#[allow(non_camel_case_types)]
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
pub enum MTLPixelFormat {
//RGBA8Unorm = 70,
R8Unorm = 10,
RG8Unorm = 30,
R32Float = 55,
RGBA8Unorm = 70,
BGRA8Unorm = 80,
RGBA16Float = 115,
RGBA32Float = 125,

View file

@ -6,20 +6,13 @@ description = "Conversational voice pipeline for Makepad apps: streamed agent re
license = "MIT OR Apache-2.0"
[features]
# Speech synthesis (Kokoro). Default ON so existing consumers are unchanged,
# but separable: the model plus its embedded pronunciation lexicon is ~10 MB of
# binary and ~327 MB resident, which a build that can never speak should not
# carry. With it off, SpeechOutput still exists and every non-audio path
# behaves identically — the worker just drains its queue in silence.
default = ["tts"]
tts = ["dep:makepad-tts"]
# The local filtering LLM (QwenFilter) on makepad-llama; heavy, so opt-in.
local-llm = ["dep:makepad-llama"]
[dependencies]
makepad-widgets = { path = "../../widgets" }
makepad-ai = { path = "../makepad_ai" }
makepad-tts = { path = "../tts", optional = true }
makepad-tts = { path = "../tts" }
makepad-llama = { path = "../llama", optional = true }
[[bin]]

View file

@ -9,10 +9,8 @@
//! being generated. Lifted out of the gamemaker example so any app can bolt a
//! voice onto an agent.
#[cfg(feature = "tts")]
use makepad_tts::Speaker;
use makepad_widgets::makepad_draw::audio::AudioBuffer;
#[cfg(feature = "tts")]
use makepad_widgets::log;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{mpsc, Arc, Mutex};
@ -92,45 +90,17 @@ impl SpeechOutput {
let worker_generation = generation.clone();
let voice = voice.to_string();
std::thread::spawn(move || {
// Built without `tts`: the synthesis stack (Kokoro plus its
// embedded pronunciation lexicon) is ~10 MB of binary, so a build
// that can never speak does not link it. Drain the queue so senders
// never block — every other path (text, muting, generation
// cancellation) behaves exactly as it does with speech on.
#[cfg(not(feature = "tts"))]
{
let _ = (&voice, &worker_playback);
while let Ok((generation, _text)) = requests.recv() {
let _ = generation.min(worker_generation.load(Ordering::Relaxed));
}
return;
}
// Off the main thread on purpose: synthesis blocks until the whole
// utterance is rendered.
//
// The speaker is built on the FIRST request, not here: the Kokoro
// model is ~327 MB resident, and an app that never speaks (text
// tier, muted, or a session where nobody triggers a reply) should
// not pay for it. Construction is still off the main thread, so
// the load cost lands on the worker either way.
#[cfg(feature = "tts")]
let mut speaker: Option<Speaker> = None;
#[cfg(feature = "tts")]
let mut speaker = Speaker::from_makepad_env_with_voice(&voice);
log!("tts: backend {:?}", speaker.kind());
// Discarded warm-up: Kokoro's first synthesis initializes the Metal
// context on this thread; better now than on the first reply.
let _ = speaker.synthesize("Hi.");
while let Ok((generation, text)) = requests.recv() {
if generation != worker_generation.load(Ordering::Relaxed) {
continue;
}
let speaker = match speaker {
Some(ref mut speaker) => speaker,
ref mut none => {
let mut fresh = Speaker::from_makepad_env_with_voice(&voice);
log!("tts: backend {:?}", fresh.kind());
// Discarded warm-up: Kokoro's first synthesis
// initializes the Metal context on this thread.
let _ = fresh.synthesize("Hi.");
none.insert(fresh)
}
};
match speaker.synthesize(&text) {
Ok(audio) if !audio.is_empty() => {
// Re-check: synthesis is slow enough that a cancel can
@ -301,57 +271,3 @@ pub fn spoken_text(markdown: &str) -> String {
}
spoken.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
/// Constructing the output must NOT load the synthesis model: Kokoro is
/// ~327 MB resident, and an app that never speaks should not pay for it.
/// Model load happens on the first enqueued utterance instead.
///
/// Proxy for "did not load": construction returns promptly. A real load
/// reads hundreds of MB off disk and warms a Metal context, which cannot
/// happen in this budget on any machine we build on.
#[test]
fn constructing_speech_output_does_not_load_the_model() {
let start = std::time::Instant::now();
let speech = SpeechOutput::new("bm_fable.mkvoice");
let elapsed = start.elapsed();
assert!(
speech.playback().lock().unwrap().samples.is_empty(),
"nothing should be synthesized before anything is said"
);
assert!(
elapsed < std::time::Duration::from_millis(250),
"construction took {elapsed:?} — the model is being loaded eagerly again"
);
}
/// The lazy path must still speak. Ignored by default: it loads the real
/// ~327 MB model and takes seconds.
///
/// Model paths resolve relative to the CWD, which under `cargo test` is
/// the crate dir, not the repo root — so point them at the real files or
/// this silently falls back to a different backend and proves nothing:
///
/// ```text
/// MAKEPAD_TTS_MODEL=$REPO/kokoro-v1_0.mktts \
/// MAKEPAD_TTS_VOICE=$REPO/bm_fable.mkvoice \
/// cargo test -p makepad-converse --release lazily_loaded -- --ignored
/// ```
#[test]
#[ignore = "loads the real Kokoro model (~327 MB); needs MAKEPAD_TTS_* paths"]
fn lazily_loaded_speaker_still_produces_audio() {
let mut speech = SpeechOutput::new("bm_fable.mkvoice");
speech.enqueue("Testing the lazy speech path.");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
while std::time::Instant::now() < deadline {
if !speech.playback().lock().unwrap().samples.is_empty() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
panic!("no audio produced within the timeout — the lazy path is broken");
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,15 +0,0 @@
[package]
name = "makepad-game-assets"
version = "0.1.0"
edition = "2021"
description = "AI-queryable index over Makepad Arcade's stock CC0 model library (see game.md)"
license = "MIT OR Apache-2.0"
[dependencies]
# Test-only: the real skin loader, so "this character is rigged with N joints
# and M clips" is proven by the code that will actually load it rather than by
# this crate's own lightweight probe agreeing with itself. Kept a dev-dependency
# so the shipped index stays dependency-free.
[dev-dependencies]
makepad-game-render = { path = "../render" }

View file

@ -1,28 +0,0 @@
//! What rigged characters the library holds, and what the AI is told about them.
fn main() {
use makepad_game_assets::agent;
let idx = makepad_game_assets::AssetIndex::build(std::path::Path::new(
"apps/arcade/resources",
));
for c in idx.casts() {
println!(
"rig {:>2} joints — {} members, up to {} clips",
c.joints, c.members.len(), c.max_clips
);
println!(" states: {}", c.shared_states.join(", "));
println!(" members: {}", c.members.join(", "));
if c.richest.len() < c.members.len() {
println!(" richest ({} clips): {}", c.max_clips, c.richest.join(", "));
}
println!();
}
let json = agent::casts_to_json(&agent::execute_cast(&idx, None), 6);
println!("find_cast JSON ({} bytes):\n{}", json.len(), json);
println!();
for q in ["a character that can attack", "a skeleton enemy", "someone who can run and jump"] {
let hits = idx.find(q);
let top: Vec<&str> = hits.iter().filter(|h| h.entry.rigged).take(3)
.map(|h| h.entry.id.as_str()).collect();
println!("{q:<34} -> {}", top.join(", "));
}
}

View file

@ -1,17 +0,0 @@
use makepad_game_assets::{agent, AssetIndex};
fn main() {
let idx = AssetIndex::build(std::path::Path::new("apps/arcade/resources"));
println!("index: {} entries ({} models, {} sounds, {} music)\n",
idx.len(),
idx.count_of(makepad_game_assets::AssetKind::Model),
idx.count_of(makepad_game_assets::AssetKind::Sound),
idx.count_of(makepad_game_assets::AssetKind::Music));
println!("--- prompt summary ({} chars) ---\n{}\n", agent::library_summary(&idx).len(), agent::library_summary(&idx));
for q in ["i want a lorry", "a big scary monster", "something to hide behind",
"sound when you crash into a wall", "happy win music", "a digger like at the roadworks"] {
let r = agent::execute(&idx, &agent::FindParams::new(q));
let top: Vec<String> = r.iter().take(3).map(|x| format!("{} ({})", x.id, x.name)).collect();
println!("{:38} -> {}", format!("\"{q}\""), if top.is_empty() { "(no hits)".into() } else { top.join("\n ") });
}
println!("\n--- compact JSON handed to the model ---\n{}", agent::results_to_json(&agent::execute(&idx, &agent::FindParams::new("red truck"))));
}

View file

@ -1,15 +0,0 @@
fn main() {
let root = std::path::PathBuf::from("apps/arcade/resources");
let idx = makepad_game_assets::AssetIndex::build(&root);
let kits = idx.kits();
println!("{} kits, {} entries total", kits.len(), idx.len());
println!("{:<24} {:>6} {:>8} roles", "kit", "tiles", "size");
for k in &kits {
let roles: Vec<String> = k.roles.iter().map(|(r, n)| format!("{r}:{n}")).collect();
let unclassified = k.tiles - k.roles.iter().map(|(_, n)| n).sum::<u32>();
println!("{:<24} {:>6} {:>8} {} (unclassified {})",
k.pack, k.tiles,
k.tile_size.map(|s| format!("{s:.2}")).unwrap_or("-".into()),
roles.join(" "), unclassified);
}
}

View file

@ -1,38 +0,0 @@
//! Ad-hoc query probe: prints the top hits and their kind for a set of
//! queries, so a ranking change can be judged against the queries it might
//! regress rather than only the ones it fixes.
use makepad_game_assets::AssetIndex;
fn main() {
let idx = AssetIndex::build(std::path::Path::new("apps/arcade/resources"));
let queries: Vec<String> = std::env::args().skip(1).collect();
let queries: Vec<&str> = if queries.is_empty() {
vec![
"spaceship",
"glass smashing",
"metal clang",
"laser gun",
"explosion",
"footsteps on wood",
"coins",
"somewhere for my guy to stand",
"spaceship engine",
"a boat",
"police car",
]
} else {
queries.iter().map(|s| s.as_str()).collect()
};
for q in queries {
println!("\"{q}\"");
for h in idx.find(q).iter().take(4) {
println!(
" {:6} {:5} {}",
h.score,
format!("{:?}", h.entry.kind),
h.entry.id
);
}
}
}

View file

@ -1,28 +0,0 @@
use makepad_game_assets::{agent, AssetIndex, AssetKind};
use std::time::Instant;
fn main() {
let t = Instant::now();
let idx = AssetIndex::build(std::path::Path::new("apps/arcade/resources"));
let build_ms = t.elapsed().as_millis();
let kw: usize = idx.entries().iter().map(|e| e.keywords.len()).sum();
let bytes: usize = idx.entries().iter().map(|e|
e.id.len()+e.name.len()+e.path.as_os_str().len()+e.pack.len()
+ e.keywords.iter().map(|k| k.len()+24).sum::<usize>()
+ e.categories.iter().map(|c| c.len()+24).sum::<usize>() + 160).sum();
println!("build: {build_ms} ms | entries {} ({} models, {} sounds, {} music)",
idx.len(), idx.count_of(AssetKind::Model), idx.count_of(AssetKind::Sound), idx.count_of(AssetKind::Music));
println!("keywords total {kw} (avg {:.1}/entry) | approx heap {:.1} MB", kw as f32/idx.len() as f32, bytes as f32/1048576.0);
let s = agent::library_summary(&idx);
println!("summary {} chars:\n{s}\n", s.len());
let t2 = Instant::now();
for q in ["truck","tree","something to drive"] { let _ = idx.find(q); }
println!("3 queries: {} us", t2.elapsed().as_micros());
for q in std::env::args().skip(1) {
let (q, kind) = match q.split_once('#') { Some((a,b))=>(a.to_string(),Some(b.to_string())), None=>(q,None) };
let mut p = agent::FindParams::new(&q);
if let Some(k)=&kind { p = p.with_kind_str(k); }
let r = agent::execute(&idx, &p);
let top: Vec<String> = r.iter().take(3).map(|x| x.id.clone()).collect();
println!("{:34} -> {}", q, if top.is_empty(){"(none)".into()}else{top.join(" | ")});
}
}

View file

@ -1,579 +0,0 @@
//! Agent-facing surface: a backend-agnostic tool the model calls to SEARCH the
//! library, plus a tiny prompt blurb telling it the library exists.
//!
//! The agent never receives the catalogue. Hundreds of entries do not fit in a
//! prompt, every result token is paid for, and the library grows with every
//! pack — so the contract is "call `find_model`, never guess an id".
//!
//! Everything here is plain data so any backend (Claude Code, direct API,
//! OpenAI-compatible) can expose it; this module deliberately does not depend
//! on `makepad_ai`.
use crate::{AssetIndex, AssetKind, Filters, Palette, Source, Spread, VarietyParams};
/// A tool parameter, in the subset of JSON Schema every provider understands.
pub struct ToolParam {
pub name: &'static str,
pub ty: &'static str,
pub description: &'static str,
pub required: bool,
}
/// A provider-neutral tool description. Render it into whatever shape the
/// backend wants.
pub struct ToolDescriptor {
pub name: &'static str,
pub description: &'static str,
pub params: &'static [ToolParam],
}
pub const FIND_MODEL: ToolDescriptor = ToolDescriptor {
name: "find_model",
description: "Search the stock asset library — 3D models, sound effects and music — by \
description, and get back ids you can use. Use plain language: what the thing \
is, or what it is FOR (\"red truck\", \"something to hide behind\", \"trees \
for a forest\", \"sound when you crash\", \"happy win music\"). Always call \
this before using an asset id; never invent one. \
RESULTS ARE DISTINCT MODELS, NOT RANKED DUPLICATES: if you need several of \
something houses in a village, trees in a wood, rocks on a hill ask for \
several here and PLACE A DIFFERENT ONE EACH TIME. Placing result #1 five \
times is the single most common way to make a scene look cheap.",
params: &[
ToolParam {
name: "query",
ty: "string",
description: "What you are looking for, in plain language.",
required: true,
},
ToolParam {
name: "kind",
ty: "string",
description: "Optional: \"model\", \"sound\" or \"music\".",
required: false,
},
ToolParam {
name: "category",
ty: "string",
description: "Optional category filter, e.g. \"vehicle\", \"nature/tree\", \
\"character/enemy\", \"sound/impact\".",
required: false,
},
ToolParam {
name: "rigged_only",
ty: "boolean",
description: "Models only: require a skeleton (can be animated).",
required: false,
},
ToolParam {
name: "max_results",
ty: "integer",
description: "How many DISTINCT models to return (default 5, max 20). Ask for as \
many as you intend to place 6 houses, 8 trees and use them all.",
required: false,
},
ToolParam {
name: "spread",
ty: "string",
description: "How different the results should be. \"mixed\" (default) spreads \
across kinds before repeating a shape right for a forest or a \
street. \"kinds\" returns one of each kind only, for maximum \
variety. \"variants\" returns members of one family, e.g. the same \
house in several designs, for a row that should look related.",
required: false,
},
ToolParam {
name: "seed",
ty: "integer",
description: "Optional: changes which models are picked while keeping the same \
picks on every re-run. Vary it to reroll a scene's look.",
required: false,
},
],
};
/// Kenney authors each pack as a matched set, so drawing a whole scene from one
/// pack is what makes it look authored. A palette hands the model that set in
/// one call, instead of it running five unrelated searches and mixing five art
/// styles into one village.
pub const FIND_PALETTE: ToolDescriptor = ToolDescriptor {
name: "find_palette",
description: "Get a COHERENT SET of models that visually belong together — all from one \
art pack grouped by what each is for. Use this when building a whole \
scene (\"a village\", \"a city street\", \"a spooky graveyard\", \"a space \
station\") instead of searching separately for houses, then trees, then \
fences: those searches can land in different art styles and the result \
looks like a junk drawer. Returns the pack name and its groups with several \
ids each, so you can place a DIFFERENT model from a group every time.",
params: &[
ToolParam {
name: "query",
ty: "string",
description: "The kind of place you are building, e.g. \"village\", \"race track\", \
\"dungeon\", \"suburb\", \"pirate island\".",
required: true,
},
ToolParam {
name: "seed",
ty: "integer",
description: "Optional: reroll which members of each group come first.",
required: false,
},
],
};
/// Parameters for one `find_model` call. The app fills these from whatever the
/// backend handed back; all optional except `query`.
#[derive(Default, Debug)]
pub struct FindParams {
pub query: String,
pub kind: Option<AssetKind>,
pub category: Option<String>,
pub rigged_only: bool,
pub max_results: Option<usize>,
/// How different the results should be from each other. `None` keeps the
/// old plain-ranked behaviour for callers that genuinely want a ranking
/// (`best`, `resolve_or_explain`); the agent path always sets it.
pub spread: Option<Spread>,
pub seed: u64,
}
impl FindParams {
pub fn new(query: &str) -> Self {
FindParams { query: query.to_string(), ..Default::default() }
}
/// Parse the `spread` argument. An unknown value means the default rather
/// than an error — a stray word should not fail the call.
pub fn with_spread_str(mut self, spread: &str) -> Self {
self.spread = Some(match spread.to_lowercase().as_str() {
"kinds" | "kind" | "distinct" => Spread::Kinds,
"variants" | "variant" | "family" => Spread::Variants,
_ => Spread::Mixed,
});
self
}
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = seed;
self
}
/// Parse the `kind` argument a model would send. Unknown values mean "no
/// filter" rather than an error — a stray word should not fail the call.
pub fn with_kind_str(mut self, kind: &str) -> Self {
self.kind = match kind.to_lowercase().as_str() {
"model" => Some(AssetKind::Model),
"sound" | "sfx" => Some(AssetKind::Sound),
"music" => Some(AssetKind::Music),
_ => None,
};
self
}
}
/// One compact result row. Deliberately narrow: id, name, category, rigged and
/// size are all the model needs to choose. Paths and index internals are not
/// the agent's business and would cost tokens for nothing.
pub struct FindResult {
pub id: String,
pub name: String,
pub kind: AssetKind,
pub category: String,
pub rigged: bool,
pub size: Option<[f32; 3]>,
/// Audio only: a looping sound is held, not fired.
pub loops: bool,
/// False when the repo cannot decode this asset yet (Kenney audio is Ogg
/// Vorbis and there is no vorbis decoder in the tree).
pub playable: bool,
}
/// Run a `find_model` call.
///
/// Results are DISTINCT models by default. The old behaviour — a plain ranked
/// list whose top entries are often near-identical siblings — is what made a
/// caller place the same house five times, so the variety pass is the default
/// and a plain ranking has to be asked for explicitly (`spread: None`).
pub fn execute(index: &AssetIndex, params: &FindParams) -> Vec<FindResult> {
let limit = params.max_results.unwrap_or(5).clamp(1, 20);
let filters = Filters {
rigged_only: params.rigged_only,
category: params.category.clone(),
source: None,
max_extent: None,
kind: params.kind,
};
let entries: Vec<&crate::AssetEntry> = match params.spread {
Some(spread) => index.find_many(
&params.query,
&VarietyParams {
count: limit,
spread,
seed: params.seed,
filters: filters.clone(),
},
),
None => index
.find_filtered(&params.query, &filters)
.into_iter()
.take(limit)
.map(|h| h.entry)
.collect(),
};
entries
.into_iter()
.map(|e| FindResult {
id: e.id.clone(),
name: e.name.clone(),
kind: e.kind,
category: e.categories.first().cloned().unwrap_or_default(),
rigged: e.rigged,
size: e.size,
loops: e.loops,
playable: e.decodable,
})
.collect()
}
/// Run a `find_palette` call.
pub fn execute_palette(index: &AssetIndex, query: &str, seed: u64) -> Option<Palette> {
index.palette(query, seed)
}
/// Render a palette compactly. Groups are capped because a pack can hold
/// hundreds of models and the model only needs enough of each to avoid
/// repeating itself — every token here is paid for on every turn.
pub fn palette_to_json(palette: &Palette, per_group: usize) -> String {
let mut s = format!("{{\"pack\":\"{}\",\"groups\":{{", palette.pack);
let mut first = true;
for (group, ids) in palette.groups.iter().take(12) {
if ids.is_empty() {
continue;
}
if !first {
s.push(',');
}
first = false;
s.push_str(&format!("\"{group}\":["));
for (i, id) in ids.iter().take(per_group).enumerate() {
if i > 0 {
s.push(',');
}
// Ids share the pack prefix, so send only the tail and state the
// prefix once — a village palette is ~40 ids and the repetition
// would be most of the payload.
let tail = id.rsplit('/').next().unwrap_or(id);
s.push_str(&format!("\"{tail}\""));
}
s.push(']');
if ids.len() > per_group {
// Tell the model more exist, so it knows it can ask for a bigger
// slice rather than assuming this is the whole group.
s.push_str("");
}
}
s.push_str("}}");
s
}
/// Render results as compact JSON for handing back to a model. One line per
/// result, no pretty-printing — this is paid-for context.
pub fn results_to_json(results: &[FindResult]) -> String {
let mut s = String::from("[");
for (i, r) in results.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&format!(
"{{\"id\":\"{}\",\"name\":\"{}\",\"kind\":\"{}\",\"category\":\"{}\"",
r.id,
r.name,
r.kind.as_str(),
r.category
));
// Only emit flags that are true or that the model must act on — every
// token here is paid for.
if r.kind == AssetKind::Model && r.rigged {
s.push_str(",\"rigged\":true");
}
if r.loops {
s.push_str(",\"loops\":true");
}
if !r.playable {
s.push_str(",\"playable\":false");
}
if let Some(sz) = r.size {
s.push_str(&format!(
",\"size\":[{:.2},{:.2},{:.2}]",
sz[0], sz[1], sz[2]
));
}
s.push('}');
}
s.push(']');
s
}
/// A short blurb for the system prompt. Must stay tiny however large the
/// library grows, so it summarises by top-level category with counts and names
/// only a handful of examples — never the catalogue.
pub fn library_summary(index: &AssetIndex) -> String {
if index.is_empty() {
return "No stock assets are installed (run apps/arcade/download_assets.sh). \
Build scenes from primitive shapes and the built-in synth."
.to_string();
}
// Roll categories up to their top level so the list stays a sentence.
let mut tops: Vec<(String, usize)> = Vec::new();
for (cat, n) in index.category_counts() {
let top = cat.split('/').next().unwrap_or(&cat).to_string();
match tops.iter_mut().find(|(t, _)| *t == top) {
Some((_, c)) => *c += n,
None => tops.push((top, n)),
}
}
tops.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let cats: Vec<String> = tops.iter().map(|(t, n)| format!("{t} ({n})")).collect();
// A few concrete examples make the id shape obvious without listing much.
let examples: Vec<&str> = index
.entries()
.iter()
.filter(|e| !e.categories.is_empty())
.step_by((index.len() / 3).max(1))
.take(3)
.map(|e| e.id.as_str())
.collect();
let models = index.count_of(AssetKind::Model);
let sounds = index.count_of(AssetKind::Sound);
let music = index.count_of(AssetKind::Music);
format!(
"A stock CC0 asset library is available: {models} models, {sounds} sounds, {music} music \
jingles {}. Call find_model(query, max_results) to search in plain language never \
guess an asset id. Ids look like {}. Results are DISTINCT models: ask for as many as \
you will place and use a different one each time, because repeating one model is what \
makes a scene look cheap. For a whole scene call find_palette(query) instead it \
returns a matched set from one art pack, so the parts look like one game.",
cats.join(", "),
examples.join(", ")
)
}
/// What the local librarian tier produces: a resolved model plus a structured
/// spawn action, so a small model can answer "put a truck in the game" without
/// writing any script.
#[derive(Debug, PartialEq)]
pub struct SpawnAction {
pub model_id: String,
pub name: String,
/// Confidence in 0.0..=1.0 that this is what was asked for. The app
/// decides the threshold: act locally when high, escalate when low.
pub confidence: f32,
}
/// Resolve a plain-language request to a spawn action, or `None` when the
/// library has nothing plausible — in which case the caller should escalate to
/// the cloud agent rather than spawn something wrong.
pub fn local_spawn(index: &AssetIndex, request: &str) -> Option<SpawnAction> {
let (entry, confidence) = index.best(request)?;
Some(SpawnAction {
model_id: entry.id.clone(),
name: entry.name.clone(),
confidence,
})
}
/// Attribution lines for every source present in the library, for a published
/// game package to carry.
pub fn credits(index: &AssetIndex) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for src in [Source::Kenney, Source::KayKit] {
if index.entries().iter().any(|e| e.source == src) && !out.contains(&src.credit()) {
out.push(src.credit());
}
}
out
}
// ---------------------------------------------------------------- kit lookup
/// A kit is a coherent, visually-matching SET of tiles. A level assembled from
/// one tile of each of five kits looks like a junk drawer; a level built from
/// one kit looks designed. So the AI needs to discover that a set exists —
/// and its grid pitch — before it can lay anything out.
pub const FIND_KIT: ToolDescriptor = ToolDescriptor {
name: "find_kit",
description: "List modular building kits — sets of tiles designed to snap together on a \
grid (roads, dungeons, caves, buildings, race tracks, platformer blocks). \
Returns each kit's id, its tile size (the grid pitch to place tiles on) and \
which roles it provides (straight, corner, junction, end, ramp, wall, floor, \
door, ...). Build a level from ONE kit so the pieces match visually, then \
call find_model with that kit's name to get specific tile ids.",
params: &[
ToolParam {
name: "query",
ty: "string",
description: "Optional: what kind of level, e.g. \"road\", \"dungeon\", \"city\", \
\"race track\", \"cave\", \"platformer\".",
required: false,
},
ToolParam {
name: "role",
ty: "string",
description: "Optional: only kits providing this piece, e.g. \"junction\".",
required: false,
},
],
};
/// Execute `find_kit`. Compact by design: kit id, tile size, and role counts —
/// enough to plan a layout, without spending tokens on individual tile ids
/// (those come from a follow-up `find_model` scoped to the kit).
pub fn execute_kit(index: &AssetIndex, query: Option<&str>, role: Option<&str>) -> Vec<KitResult> {
let q = query.unwrap_or("").to_ascii_lowercase();
let mut out: Vec<KitResult> = index
.kits()
.into_iter()
.filter(|k| {
role.is_none_or(|r| k.roles.iter().any(|(kr, _)| kr == r || kr.starts_with(r)))
})
.filter(|k| {
q.is_empty()
|| q.split_whitespace().any(|w| {
k.pack.contains(w) || k.name.to_ascii_lowercase().contains(w)
})
})
.map(|k| KitResult {
id: k.pack,
name: k.name,
tiles: k.tiles,
tile_size: k.tile_size,
roles: k.roles,
})
.collect();
// Most roles first: a kit with junctions and ramps composes into more
// interesting levels than one with a single straight piece.
out.sort_by(|a, b| b.roles.len().cmp(&a.roles.len()).then(a.id.cmp(&b.id)));
out
}
pub struct KitResult {
pub id: String,
pub name: String,
pub tiles: u32,
pub tile_size: Option<f32>,
pub roles: Vec<(String, u32)>,
}
pub fn kits_to_json(kits: &[KitResult]) -> String {
let mut s = String::from("[");
for (i, k) in kits.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&format!("{{\"id\":\"{}\",\"tiles\":{}", k.id, k.tiles));
if let Some(t) = k.tile_size {
s.push_str(&format!(",\"tile_size\":{t:.2}"));
}
s.push_str(",\"roles\":{");
for (j, (r, n)) in k.roles.iter().enumerate() {
if j > 0 {
s.push(',');
}
s.push_str(&format!("\"{r}\":{n}"));
}
s.push_str("}}");
}
s.push(']');
s
}
/// Discover the rigged casts — characters that share a skeleton.
///
/// Separate from `find_model` because the useful unit here is the SET, not the
/// individual: an AI casting a village wants to know that twelve civilians
/// share one rig (so one animation path drives them all, and swapping a
/// villager for an orc costs nothing), and that a different, richer rig holds
/// the heroes and undead. Choosing two characters from two rigs is the
/// character-level version of building a level from five different tile kits.
pub const FIND_CAST: ToolDescriptor = ToolDescriptor {
name: "find_cast",
description: "List the rigged, animated character casts. Each cast is a set of characters \
sharing one skeleton, so ANY animation state works on EVERY member and you \
can recast a part without changing animation code. Returns the cast's joint \
count (its identity), its members, and the states they can all perform \
(idle, walk, run, jump, attack, block, dodge, hurt, die, sit, dance, spawn, \
resurrect, taunt, ...). Pick characters from ONE cast for a scene, and use \
DIFFERENT members so your villagers are not clones.",
params: &[ToolParam {
name: "state",
ty: "string",
description: "Optional: only casts whose members can all do this, e.g. \"attack\", \
\"resurrect\", \"sit\".",
required: false,
}],
};
pub struct CastResult {
pub joints: u32,
pub members: Vec<String>,
pub states: Vec<String>,
pub max_clips: u32,
/// Members carrying the largest clip set — a superset worth knowing about
/// when one part needs a state the rest of the cast lacks.
pub richest: Vec<String>,
}
pub fn execute_cast(index: &AssetIndex, state: Option<&str>) -> Vec<CastResult> {
let want = state.map(|s| s.to_ascii_lowercase());
index
.casts()
.into_iter()
.filter(|c| {
want.as_ref()
.is_none_or(|w| c.shared_states.iter().any(|s| s == w))
})
.map(|c| CastResult {
joints: c.joints,
members: c.members,
states: c.shared_states,
max_clips: c.max_clips,
richest: c.richest,
})
.collect()
}
/// Compact JSON: the AI needs the member ids and what they can do, not every
/// clip name — a 95-clip list per character would dwarf the rest of the turn.
pub fn casts_to_json(casts: &[CastResult], max_members: usize) -> String {
let mut s = String::from("[");
for (i, c) in casts.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&format!(
"{{\"rig\":{},\"count\":{},\"members\":[",
c.joints,
c.members.len()
));
for (j, m) in c.members.iter().take(max_members).enumerate() {
if j > 0 {
s.push(',');
}
s.push_str(&format!("\"{m}\""));
}
s.push_str("],\"states\":[");
for (j, st) in c.states.iter().enumerate() {
if j > 0 {
s.push(',');
}
s.push_str(&format!("\"{st}\""));
}
s.push(']');
if c.richest.len() < c.members.len() {
s.push_str(&format!(",\"most_clips\":{}", c.max_clips));
}
// One brace: the object opened by the `{{` in the format! above.
s.push('}');
}
s.push(']');
s
}

View file

@ -1,503 +0,0 @@
//! The hand-curated alias table — the heart of the asset library.
//!
//! A model nobody can name is a model that does not exist. Filename tokens
//! are the *floor* (the indexer always folds them in); everything here is the
//! curation on top, written along six axes:
//!
//! 1. synonyms — truck/lorry/van/pickup
//! 2. kid vocabulary and misspellings — "digger", "baddie", "vehical"
//! 3. function and use — "something to drive", "somewhere to hide"
//! 4. visual qualities — colour, size class, material
//! 5. setting and theme — city, medieval, racing, sci-fi
//! 6. phrasing a 7-year-old or an LLM would actually type
//!
//! Axis 3 earns its keep the most: a kid says what they want to DO, and an
//! agent says what it needs the model FOR. Neither says "vehicle-truck-yellow".
/// One curated row. `key` is `<pack>/<file-stem>`, which is anchored to where
/// the file lives — so ids stay stable when categories are retuned.
pub struct Alias {
pub key: &'static str,
pub name: &'static str,
pub categories: &'static [&'static str],
pub aliases: &'static [&'static str],
}
/// Category hierarchy, stated in one sentence: things you drive, characters,
/// nature, buildings, roads you drive on, ground you stand on, props you use,
/// and effects. Items may sit in more than one.
pub const CATEGORIES: &[&str] = &[
"vehicle/ground",
"character/player",
"character/enemy",
"nature/tree",
"nature/plant",
"nature/sky",
"building/residential",
"building/structure",
"road/street",
"road/track",
"terrain/platform",
"terrain/floor",
"terrain/wall",
"terrain/stairs",
"prop/decoration",
"prop/weapon",
"prop/pickup",
"effect/particle",
];
/// Query-time expansion: a term the user typed -> terms we search for.
/// Includes British/American pairs and the misspellings a child actually
/// produces, because a 7-year-old's spelling should still hit.
pub const SYNONYMS: &[(&str, &[&str])] = &[
// vehicles
("lorry", &["truck"]),
("van", &["truck"]),
("pickup", &["truck"]),
("hauler", &["truck"]),
("semi", &["truck"]),
("auto", &["car", "vehicle"]),
("automobile", &["car", "vehicle"]),
("motor", &["car", "vehicle"]),
("racecar", &["car", "racer"]),
("motorbike", &["motorcycle"]),
("moto", &["motorcycle"]),
("bike", &["motorcycle"]),
("scooter", &["motorcycle"]),
("motercycle", &["motorcycle"]), // misspelling
("motorcyle", &["motorcycle"]), // misspelling
("vehical", &["vehicle"]), // misspelling
("vehicel", &["vehicle"]), // misspelling
("bycicle", &["bicycle", "motorcycle"]),
("truk", &["truck"]), // misspelling
// aircraft (no models yet — expansions ready for the next pack)
("aeroplane", &["plane", "aircraft"]),
("airplane", &["plane", "aircraft"]),
("jet", &["plane", "aircraft"]),
("aircraft", &["plane"]),
("chopper", &["helicopter"]),
("heli", &["helicopter"]),
("hilicopter", &["helicopter"]), // misspelling
// construction
("digger", &["excavator", "construction"]),
("jcb", &["excavator", "construction"]),
("bulldozer", &["excavator", "construction"]),
// characters
("baddie", &["enemy"]),
("baddy", &["enemy"]),
("villain", &["enemy"]),
("monster", &["enemy"]),
("mob", &["enemy"]),
("foe", &["enemy"]),
("alien", &["enemy"]),
("dude", &["character"]),
("guy", &["character"]),
("man", &["character"]),
("person", &["character"]),
("avatar", &["character"]),
("hero", &["character", "player"]),
("charecter", &["character"]), // misspelling
("caracter", &["character"]), // misspelling
("knight", &["character", "medieval", "warrior"]),
("warrior", &["character", "soldier"]),
("army", &["soldier", "military"]),
// nature
("boulder", &["rock"]),
("stone", &["rock"]),
("pine", &["tree", "conifer"]),
("oak", &["tree"]),
("fir", &["tree", "conifer"]),
("conifer", &["tree"]),
("woods", &["forest", "tree"]),
("wood", &["forest", "tree"]),
("bush", &["grass", "plant", "shrub"]),
("shrub", &["grass", "plant"]),
("plant", &["grass"]),
("greenery", &["grass", "plant"]),
("lawn", &["grass"]),
("meadow", &["grass", "field"]),
("tre", &["tree"]), // misspelling
("treee", &["tree"]), // misspelling
// buildings
("home", &["house"]),
("cottage", &["house"]),
("hut", &["house"]),
("cabin", &["house"]),
("shack", &["house"]),
("shop", &["building", "house"]),
("store", &["building", "house"]),
("skyscraper", &["building"]),
("tower", &["building"]),
("hows", &["house"]), // misspelling
("hosue", &["house"]), // misspelling
// roads and ground
("tarmac", &["road", "asphalt"]),
("asphalt", &["road"]),
("highway", &["road"]),
("motorway", &["road"]),
("street", &["road"]),
("sidewalk", &["pavement"]),
("footpath", &["pavement", "path"]),
("walkway", &["pavement", "path"]),
("kerb", &["border", "curb"]),
("curb", &["border"]),
("crossroads", &["intersection"]),
("junction", &["intersection"]),
("roundabout", &["intersection"]),
("bend", &["corner"]),
("turn", &["corner"]),
("curve", &["corner"]),
// structures
("staircase", &["stairs"]),
("steps", &["stairs"]),
("ladder", &["stairs"]),
("fence", &["wall", "barrier"]),
("barrier", &["wall"]),
("gateway", &["gate"]),
("doorway", &["gate", "door"]),
("entrance", &["gate"]),
("arch", &["gate"]),
("pillar", &["column"]),
("post", &["column"]),
("ledge", &["platform"]),
("stairs", &["steps"]),
// props
("blade", &["sword"]),
("sworde", &["sword"]), // misspelling
("lance", &["spear"]),
("pike", &["spear"]),
("gun", &["blaster", "weapon"]),
("pistol", &["blaster", "weapon"]),
("rifle", &["blaster", "weapon"]),
("laser", &["blaster"]),
("cup", &["trophy"]),
("prize", &["trophy"]),
("award", &["trophy"]),
("money", &["coin"]),
("gold", &["coin"]),
("cash", &["coin"]),
("collectible", &["coin", "pickup"]),
("powerup", &["coin", "pickup", "block-coin"]),
("treasure", &["coin", "trophy"]),
("crate", &["block", "box"]),
("box", &["block", "crate"]),
("cube", &["block"]),
("banner", &["flag"]),
("pennant", &["flag"]),
("chequered", &["finish", "checkered"]),
("checkered", &["finish"]),
("fountain", &["water"]),
("statue", &["sculpture", "monument"]),
("sculpture", &["statue"]),
// effects and weather
("smoke", &["dust", "puff"]),
("puff", &["dust"]),
("sky", &["cloud"]),
("weather", &["cloud"]),
// function phrases — the axis that matters most
("drive", &["vehicle", "car", "truck", "road", "track"]),
("driving", &["vehicle", "car", "truck", "road"]),
("ride", &["motorcycle", "vehicle"]),
("riding", &["motorcycle", "vehicle"]),
("race", &["racing", "track", "vehicle"]),
("racing", &["track", "vehicle"]),
("shoot", &["enemy", "blaster", "target"]),
("shooting", &["enemy", "blaster"]),
("hide", &["wall", "cover", "obstacle"]),
("hiding", &["wall", "cover"]),
("cover", &["wall", "obstacle"]),
("jump", &["platform"]),
("jumping", &["platform"]),
("stand", &["platform", "floor"]),
("standing", &["platform", "floor"]),
("climb", &["stairs", "ladder"]),
("climbing", &["stairs"]),
("collect", &["coin", "pickup"]),
("collecting", &["coin", "pickup"]),
("decorate", &["decoration"]),
("decor", &["decoration"]),
("scenery", &["decoration"]),
("obstacle", &["block", "wall"]),
("play", &["character", "player"]),
// size and quality
("big", &["large"]),
("huge", &["large"]),
("giant", &["large"]),
("massive", &["large"]),
("tiny", &["small"]),
("little", &["small"]),
("lowpoly", &["blocky"]),
("blocky", &["lowpoly"]),
// --- catalogue-aware expansions -------------------------------------
// Added after measuring real misses against the full 4400-model library:
// Kenney's space pack names everything "craft_*", its pets are "animal-*",
// and kids do not use either word.
("spaceship", &["craft", "speeder", "space"]),
("rocket", &["craft", "speeder", "space"]),
("spacecraft", &["craft", "speeder"]),
("ufo", &["craft", "alien", "space"]),
("shuttle", &["craft", "space"]),
("kitten", &["cat"]),
("puppy", &["dog"]),
("doggy", &["dog"]),
("rabbit", &["bunny"]),
("hare", &["bunny"]),
("bird", &["parrot", "chick"]),
("birdie", &["parrot", "chick"]),
("piggy", &["pig", "hog"]),
("bear", &["polar", "koala"]),
("teddy", &["bear", "koala"]),
("dino", &["dinosaur"]),
("couch", &["sofa"]),
("settee", &["sofa"]),
("armchair", &["chair"]),
("telly", &["television", "tv"]),
("fridge", &["refrigerator", "kitchen"]),
("cooker", &["stove", "oven", "kitchen"]),
("burger", &["hamburger", "cheeseburger"]),
("fries", &["chips", "fry"]),
("sweets", &["candy"]),
("lolly", &["candy", "lollipop"]),
("veg", &["vegetable", "carrot", "broccoli"]),
("skyscraper", &["building", "tower", "commercial"]),
("cop", &["police"]),
("ambulance", &["emergency", "hospital"]),
("firetruck", &["fire", "emergency"]),
("digger", &["excavator", "tractor", "construction"]),
("tractor", &["farm"]),
("choo", &["train", "locomotive"]),
("carriage", &["wagon", "train"]),
("yacht", &["boat", "sail"]),
("canoe", &["boat", "row"]),
("submarine", &["boat", "ship"]),
("zombie", &["monster", "enemy", "spooky"]),
("skeleton", &["bones", "spooky", "graveyard"]),
("pumpkin", &["halloween", "graveyard"]),
("gravestone", &["grave", "tombstone", "graveyard"]),
("tombstone", &["grave", "graveyard"]),
("xmas", &["christmas", "holiday"]),
("santa", &["christmas", "holiday"]),
("snowman", &["snow", "winter", "holiday"]),
("conveyor", &["belt", "factory"]),
("crane", &["construction", "factory"]),
("hoop", &["ring", "arch"]),
("ramp", &["jump", "slope"]),
// theme
("town", &["city", "urban"]),
("urban", &["city"]),
("outdoors", &["nature"]),
("outdoor", &["nature"]),
("scifi", &["space", "sci-fi"]),
("space", &["sci-fi"]),
("fantasy", &["medieval"]),
("castle", &["medieval", "fortress"]),
("fortress", &["medieval", "castle"]),
("roman", &["ancient"]),
("greek", &["ancient"]),
];
/// The curated catalogue. Keyed by `<pack>/<stem>`.
pub const ALIASES: &[Alias] = &[
// ------------------------------------------------------- nature: rocks
// Landscape rocks lost "boulder" to `tower-defense-kit/weapon-ammo-boulder`
// — catapult ammo — purely because that filename says the word and these
// reach it only through the boulder->rock synonym. A confidently wrong top
// hit is worse than a miss here, because a composer places it five times.
Alias { key: "nature-kit/rock_largeA", name: "Boulder", categories: &["nature/rock"],
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
Alias { key: "nature-kit/rock_largeB", name: "Boulder", categories: &["nature/rock"],
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
Alias { key: "nature-kit/rock_largeC", name: "Boulder", categories: &["nature/rock"],
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
Alias { key: "nature-kit/rock_largeD", name: "Boulder", categories: &["nature/rock"],
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
Alias { key: "nature-kit/rock_largeE", name: "Boulder", categories: &["nature/rock"],
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
Alias { key: "nature-kit/rock_largeF", name: "Boulder", categories: &["nature/rock"],
aliases: &["boulder", "rock", "stone", "big rock", "large rock", "landscape", "scenery", "nature", "outcrop"] },
Alias { key: "nature-kit/rock_smallA", name: "Rock", categories: &["nature/rock"],
aliases: &["rock", "stone", "boulder", "small rock", "pebble", "landscape", "scenery", "nature"] },
Alias { key: "nature-kit/rock_smallB", name: "Rock", categories: &["nature/rock"],
aliases: &["rock", "stone", "boulder", "small rock", "pebble", "landscape", "scenery", "nature"] },
// ---------------------------------------------------------------- arena
Alias { key: "arena/banner", name: "Banner", categories: &["prop/decoration"],
aliases: &["banner", "flag", "pennant", "cloth", "hanging", "decoration", "medieval", "castle", "scenery", "wall hanging"] },
Alias { key: "arena/block", name: "Stone Block", categories: &["prop/decoration", "terrain/platform"],
aliases: &["block", "cube", "box", "crate", "stone block", "obstacle", "building block", "medieval", "stone", "something to jump on", "push"] },
Alias { key: "arena/border-corner", name: "Border Corner", categories: &["terrain/floor"],
aliases: &["border", "corner", "kerb", "curb", "edge", "trim", "boundary", "arena edge", "medieval"] },
Alias { key: "arena/border-straight", name: "Border", categories: &["terrain/floor"],
aliases: &["border", "straight", "kerb", "curb", "edge", "trim", "boundary", "arena edge", "medieval"] },
Alias { key: "arena/bricks", name: "Rubble", categories: &["prop/decoration"],
aliases: &["bricks", "rubble", "debris", "stones", "pile", "ruins", "broken", "scenery", "medieval", "wreckage"] },
Alias { key: "arena/character-soldier", name: "Soldier", categories: &["character/player"],
aliases: &["soldier", "character", "guy", "man", "person", "player", "fighter", "warrior", "army", "military", "someone to play as", "hero", "dude", "avatar"] },
Alias { key: "arena/column-damaged", name: "Broken Column", categories: &["building/structure", "prop/decoration"],
aliases: &["column", "pillar", "broken column", "ruined pillar", "ruin", "ancient", "temple", "greek", "roman", "obstacle", "cover", "medieval", "damaged"] },
Alias { key: "arena/column", name: "Column", categories: &["building/structure"],
aliases: &["column", "pillar", "post", "temple", "ancient", "greek", "roman", "support", "obstacle", "cover", "somewhere to hide", "medieval"] },
Alias { key: "arena/floor-detail", name: "Detailed Floor", categories: &["terrain/floor"],
aliases: &["floor", "ground", "tile", "paving", "surface", "somewhere to stand", "detailed floor", "arena floor", "medieval", "stone floor"] },
Alias { key: "arena/floor", name: "Floor", categories: &["terrain/floor"],
aliases: &["floor", "ground", "tile", "paving", "surface", "somewhere to stand", "arena floor", "medieval", "stone floor", "platform"] },
Alias { key: "arena/stairs-corner-inner", name: "Inner Corner Stairs", categories: &["terrain/stairs"],
aliases: &["stairs", "steps", "staircase", "corner stairs", "inner corner", "climb", "way up", "medieval"] },
Alias { key: "arena/stairs-corner", name: "Corner Stairs", categories: &["terrain/stairs"],
aliases: &["stairs", "steps", "staircase", "corner stairs", "climb", "way up", "medieval"] },
Alias { key: "arena/stairs", name: "Stairs", categories: &["terrain/stairs"],
aliases: &["stairs", "steps", "staircase", "climb", "way up", "ramp", "medieval", "somewhere to climb"] },
Alias { key: "arena/statue", name: "Statue", categories: &["prop/decoration"],
aliases: &["statue", "sculpture", "monument", "figure", "landmark", "decoration", "stone figure", "medieval", "ancient", "scenery"] },
Alias { key: "arena/tree", name: "Tree", categories: &["nature/tree"],
aliases: &["tree", "oak", "leafy tree", "plant", "nature", "forest", "wood", "shade", "scenery", "greenery", "big tree"] },
Alias { key: "arena/trophy", name: "Trophy", categories: &["prop/pickup", "prop/decoration"],
aliases: &["trophy", "cup", "prize", "award", "winner", "goal", "reward", "treasure", "gold cup", "champion", "something to win"] },
Alias { key: "arena/wall-corner", name: "Wall Corner", categories: &["terrain/wall"],
aliases: &["wall", "corner wall", "barrier", "cover", "obstacle", "fortress", "castle", "medieval", "somewhere to hide"] },
Alias { key: "arena/wall-gate", name: "Gate", categories: &["terrain/wall", "building/structure"],
aliases: &["gate", "gateway", "doorway", "entrance", "arch", "wall with door", "castle gate", "way in", "portal", "medieval", "door"] },
Alias { key: "arena/wall", name: "Wall", categories: &["terrain/wall"],
aliases: &["wall", "barrier", "block", "cover", "obstacle", "fence", "fortress", "castle", "medieval", "somewhere to hide", "something to hide behind"] },
Alias { key: "arena/weapon-rack", name: "Weapon Rack", categories: &["prop/decoration"],
aliases: &["weapon rack", "rack", "armoury", "armory", "stand", "weapons", "decoration", "medieval", "castle", "scenery"] },
Alias { key: "arena/weapon-spear", name: "Spear", categories: &["prop/weapon"],
aliases: &["spear", "lance", "pike", "polearm", "weapon", "medieval", "pointy stick", "stabby", "knight weapon"] },
Alias { key: "arena/weapon-sword", name: "Sword", categories: &["prop/weapon"],
aliases: &["sword", "blade", "weapon", "knight sword", "medieval", "sharp", "fighting", "something to fight with"] },
// ----------------------------------------------------------------- city
Alias { key: "city/building-garage", name: "Garage", categories: &["building/residential"],
aliases: &["garage", "carport", "workshop", "shed", "building", "car park", "urban", "city", "town", "somewhere to park"] },
Alias { key: "city/building-small-a", name: "Small House A", categories: &["building/residential"],
aliases: &["house", "home", "building", "small house", "cottage", "hut", "shop", "urban", "city", "town", "somewhere to live", "residential"] },
Alias { key: "city/building-small-b", name: "Small House B", categories: &["building/residential"],
aliases: &["house", "home", "building", "small house", "cottage", "hut", "shop", "urban", "city", "town", "somewhere to live", "residential"] },
Alias { key: "city/building-small-c", name: "Small House C", categories: &["building/residential"],
aliases: &["house", "home", "building", "small house", "cottage", "hut", "shop", "urban", "city", "town", "somewhere to live", "residential"] },
Alias { key: "city/building-small-d", name: "Small House D", categories: &["building/residential"],
aliases: &["house", "home", "building", "small house", "cottage", "hut", "shop", "urban", "city", "town", "somewhere to live", "residential"] },
Alias { key: "city/grass-trees-tall", name: "Tall Trees", categories: &["nature/tree"],
aliases: &["trees", "tall trees", "forest", "woods", "nature", "park", "greenery", "scenery", "treeline", "trees for a forest"] },
Alias { key: "city/grass-trees", name: "Trees", categories: &["nature/tree"],
aliases: &["trees", "forest", "woods", "nature", "park", "greenery", "scenery", "grass with trees", "trees for a forest"] },
Alias { key: "city/grass", name: "Grass", categories: &["nature/plant", "terrain/floor"],
aliases: &["grass", "lawn", "field", "green", "ground", "nature", "park", "meadow", "somewhere to stand", "greenery"] },
Alias { key: "city/pavement-fountain", name: "Fountain", categories: &["prop/decoration"],
aliases: &["fountain", "water", "water feature", "plaza", "square", "decoration", "park", "town centre", "town center", "city", "scenery"] },
Alias { key: "city/pavement", name: "Pavement", categories: &["terrain/floor", "road/street"],
aliases: &["pavement", "sidewalk", "paving", "path", "walkway", "footpath", "ground", "street", "city", "somewhere to walk"] },
Alias { key: "city/road-corner", name: "Road Corner", categories: &["road/street"],
aliases: &["road", "street", "corner", "bend", "turn", "curve", "tarmac", "asphalt", "drive on", "city", "something to drive on"] },
Alias { key: "city/road-intersection", name: "Crossroads", categories: &["road/street"],
aliases: &["road", "crossroads", "intersection", "junction", "four way", "street", "drive on", "city", "traffic"] },
Alias { key: "city/road-split", name: "T Junction", categories: &["road/street"],
aliases: &["road", "junction", "t junction", "split", "fork", "street", "drive on", "city", "intersection"] },
Alias { key: "city/road-straight-lightposts", name: "Road with Lights", categories: &["road/street"],
aliases: &["road", "street", "straight road", "lamp posts", "street lights", "lighting", "drive on", "city", "lit road", "lampposts"] },
Alias { key: "city/road-straight", name: "Straight Road", categories: &["road/street"],
aliases: &["road", "street", "straight", "tarmac", "asphalt", "drive on", "highway", "city", "something to drive on"] },
// ------------------------------------------------------------------ fps
Alias { key: "fps/blaster-repeater", name: "Repeater Blaster", categories: &["prop/weapon"],
aliases: &["blaster", "gun", "rifle", "repeater", "weapon", "laser gun", "shooter", "sci-fi", "space gun", "rapid fire", "machine gun", "something to shoot with"] },
Alias { key: "fps/blaster", name: "Blaster", categories: &["prop/weapon"],
aliases: &["blaster", "gun", "pistol", "weapon", "laser", "shooter", "sci-fi", "space gun", "raygun", "something to shoot with"] },
Alias { key: "fps/cloud", name: "Cloud", categories: &["nature/sky"],
aliases: &["cloud", "sky", "weather", "floating", "decoration", "fluffy", "white cloud", "scenery"] },
Alias { key: "fps/enemy-flying", name: "Flying Enemy", categories: &["character/enemy"],
aliases: &["enemy", "baddie", "bad guy", "monster", "flying enemy", "drone", "alien", "something to shoot at", "villain", "foe", "mob", "scary", "flying monster", "boss"] },
Alias { key: "fps/grass-small", name: "Small Grass", categories: &["nature/plant"],
aliases: &["grass", "small grass", "tuft", "plant", "bush", "nature", "ground cover", "greenery", "little grass"] },
Alias { key: "fps/grass", name: "Grass Tuft", categories: &["nature/plant"],
aliases: &["grass", "plant", "bush", "shrub", "nature", "greenery", "ground cover", "foliage"] },
Alias { key: "fps/platform-large-grass", name: "Large Grass Platform", categories: &["terrain/platform"],
aliases: &["platform", "grass platform", "ledge", "floating island", "somewhere to stand", "jump on", "large platform", "big platform", "island"] },
Alias { key: "fps/platform", name: "Platform", categories: &["terrain/platform"],
aliases: &["platform", "ledge", "floating platform", "somewhere to stand", "jump on", "step", "block"] },
Alias { key: "fps/wall-high", name: "High Wall", categories: &["terrain/wall"],
aliases: &["wall", "high wall", "tall wall", "barrier", "cover", "obstacle", "somewhere to hide", "big wall", "something to hide behind"] },
Alias { key: "fps/wall-low", name: "Low Wall", categories: &["terrain/wall"],
aliases: &["wall", "low wall", "barrier", "cover", "crouch behind", "obstacle", "somewhere to hide", "small wall", "something to hide behind"] },
// ----------------------------------------------------------- platformer
Alias { key: "platformer/block-coin", name: "Coin Block", categories: &["prop/pickup"],
aliases: &["coin block", "question block", "mystery box", "item block", "powerup box", "hit block", "surprise", "bonus", "mario block"] },
Alias { key: "platformer/brick-particle", name: "Brick Chunk", categories: &["effect/particle"],
aliases: &["brick particle", "debris", "chunk", "rubble", "effect", "broken piece", "smash", "fragment"] },
Alias { key: "platformer/brick", name: "Brick Block", categories: &["prop/decoration", "terrain/platform"],
aliases: &["brick", "block", "breakable block", "wall block", "obstacle", "smash", "break", "something to jump on"] },
Alias { key: "platformer/character", name: "Platformer Character", categories: &["character/player"],
aliases: &["character", "player", "guy", "hero", "dude", "person", "someone to play as", "avatar", "mascot", "little guy", "my guy"] },
Alias { key: "platformer/cloud", name: "Cloud", categories: &["nature/sky"],
aliases: &["cloud", "sky", "floating", "weather", "decoration", "fluffy", "white cloud"] },
Alias { key: "platformer/coin", name: "Coin", categories: &["prop/pickup"],
aliases: &["coin", "money", "gold", "pickup", "collectible", "treasure", "points", "score", "collect", "something to collect", "reward"] },
Alias { key: "platformer/dust", name: "Dust Puff", categories: &["effect/particle"],
aliases: &["dust", "puff", "smoke", "effect", "particle", "cloud", "landing effect", "poof"] },
Alias { key: "platformer/flag", name: "Flag", categories: &["prop/decoration"],
aliases: &["flag", "goal", "finish", "checkpoint", "banner", "end of level", "marker", "target", "somewhere to reach"] },
Alias { key: "platformer/grass-small", name: "Small Grass", categories: &["nature/plant"],
aliases: &["grass", "small grass", "tuft", "plant", "nature", "greenery", "little grass"] },
Alias { key: "platformer/grass", name: "Grass", categories: &["nature/plant"],
aliases: &["grass", "plant", "bush", "nature", "greenery", "foliage"] },
Alias { key: "platformer/platform-falling", name: "Falling Platform", categories: &["terrain/platform"],
aliases: &["falling platform", "crumbling platform", "unstable", "trap", "platform", "hazard", "danger", "collapsing"] },
Alias { key: "platformer/platform-grass-large-round", name: "Round Grass Platform", categories: &["terrain/platform"],
aliases: &["round platform", "grass platform", "island", "large platform", "somewhere to stand", "big platform", "circle platform"] },
Alias { key: "platformer/platform-large", name: "Large Platform", categories: &["terrain/platform"],
aliases: &["large platform", "big platform", "ledge", "somewhere to stand", "jump on", "wide platform"] },
Alias { key: "platformer/platform-medium", name: "Medium Platform", categories: &["terrain/platform"],
aliases: &["medium platform", "platform", "ledge", "jump on", "somewhere to stand"] },
Alias { key: "platformer/platform", name: "Small Platform", categories: &["terrain/platform"],
aliases: &["platform", "ledge", "block", "somewhere to stand", "jump on", "small platform", "step"] },
// --------------------------------------------------------------- racing
Alias { key: "racing/decoration-empty", name: "Empty Roadside", categories: &["prop/decoration"],
aliases: &["decoration", "empty", "plain", "scenery", "filler", "roadside", "blank", "racing"] },
Alias { key: "racing/decoration-forest", name: "Roadside Forest", categories: &["nature/tree", "prop/decoration"],
aliases: &["forest", "trees", "woods", "roadside trees", "scenery", "nature", "decoration", "racing", "trees for a forest", "treeline"] },
Alias { key: "racing/decoration-tents", name: "Roadside Tents", categories: &["prop/decoration"],
aliases: &["tents", "camp", "marquee", "spectators", "event", "scenery", "decoration", "race day", "racing", "crowd"] },
Alias { key: "racing/track-bump", name: "Track Bump", categories: &["road/track"],
aliases: &["track", "bump", "jump", "ramp", "hump", "race track", "obstacle", "racing", "something to jump off"] },
Alias { key: "racing/track-corner", name: "Track Corner", categories: &["road/track"],
aliases: &["track", "corner", "bend", "turn", "curve", "race track", "drive on", "racing", "something to drive on"] },
Alias { key: "racing/track-finish", name: "Finish Line", categories: &["road/track"],
aliases: &["finish line", "start line", "finish", "goal", "chequered", "checkered", "race track", "end", "racing", "winner", "start"] },
Alias { key: "racing/track-straight", name: "Straight Track", categories: &["road/track"],
aliases: &["track", "straight", "race track", "drive on", "road", "racing", "something to drive on"] },
Alias { key: "racing/track-tents", name: "Track with Tents", categories: &["road/track"],
aliases: &["track with tents", "race track", "grandstand", "spectators", "event", "racing", "crowd", "pit lane"] },
Alias { key: "racing/vehicle-motorcycle", name: "Motorcycle", categories: &["vehicle/ground"],
aliases: &["motorcycle", "motorbike", "bike", "moto", "motor bike", "two wheeler", "something to ride", "fast", "racer", "racing", "speed", "vehicle"] },
Alias { key: "racing/vehicle-truck-green", name: "Green Truck", categories: &["vehicle/ground"],
aliases: &["truck", "lorry", "van", "pickup", "hauler", "car", "green truck", "green", "something to drive", "racer", "racing", "vehicle", "fast"] },
Alias { key: "racing/vehicle-truck-purple", name: "Purple Truck", categories: &["vehicle/ground"],
aliases: &["truck", "lorry", "van", "pickup", "hauler", "car", "purple truck", "purple", "something to drive", "racer", "racing", "vehicle", "fast"] },
Alias { key: "racing/vehicle-truck-red", name: "Red Truck", categories: &["vehicle/ground"],
aliases: &["truck", "lorry", "van", "pickup", "hauler", "car", "red truck", "red", "something to drive", "racer", "racing", "vehicle", "fast"] },
Alias { key: "racing/vehicle-truck-yellow", name: "Yellow Truck", categories: &["vehicle/ground"],
aliases: &["truck", "lorry", "van", "pickup", "hauler", "car", "yellow truck", "yellow", "something to drive", "racer", "racing", "vehicle", "fast"] },
// ------------------------------------------------------------- kaykit
Alias { key: "characters/knight", name: "Knight", categories: &["character/player"],
aliases: &["knight", "character", "player", "hero", "warrior", "soldier", "armour", "armor", "medieval", "fighter", "someone to play as", "guy", "dude", "man", "person", "avatar", "animated", "rigged", "walking"] },
];
/// Look up a curated row by `<pack>/<stem>`.
pub fn lookup(key: &str) -> Option<&'static Alias> {
ALIASES.iter().find(|a| a.key == key)
}
/// Expand one query term into the terms we should search for. Consults the
/// model table and the audio table, so one query ranks across both libraries.
/// Deterministic order: model table, then audio table.
pub fn expand(term: &str) -> Vec<&'static str> {
let mut out = Vec::new();
for (from, to) in SYNONYMS.iter().chain(crate::audio_aliases::AUDIO_SYNONYMS) {
if *from == term {
for t in *to {
if !out.contains(t) {
out.push(*t);
}
}
}
}
out
}

View file

@ -1,433 +0,0 @@
//! Curated aliases for the sound library, keyed by *family* rather than file.
//!
//! Kenney's audio ships in numbered variants (`footstep_wood_000..004`), so the
//! curation lives on the family and every variant inherits it — 550-odd files
//! collapse to ~116 rows a human can actually maintain.
//!
//! Audio needs axes the models don't:
//!
//! - **event/trigger**: hit, crash, jump, land, shoot, pickup, win, click
//! - **material**: wood, metal, glass, stone, dirt — an impact is chosen by
//! *what hit what*, so these are load-bearing for automatic emission
//! - **mood/intensity**: soft/hard, small/big, retro/modern, happy/tense
//! - **function**: "sound for when you get hit", "music for a race"
//!
//! Music is marked so a game never fires a 30-second track as a hit sound.
use crate::AssetKind;
pub struct AudioAlias {
/// `<pack>/<family>` — family is the filename with its variant number
/// stripped.
pub key: &'static str,
pub name: &'static str,
pub kind: AssetKind,
pub categories: &'static [&'static str],
pub aliases: &'static [&'static str],
}
/// Sound categories, kept parallel in spirit to the model tree.
pub const AUDIO_CATEGORIES: &[&str] = &[
"sound/footstep",
"sound/impact",
"sound/weapon",
"sound/ui",
"sound/pickup",
"sound/engine",
"sound/ambient",
"sound/voice",
"music/jingle",
];
pub const AUDIO_ALIASES: &[AudioAlias] = &[
// ------------------------------------------------------ footsteps
AudioAlias { key: "impact-sounds/footstep_carpet", name: "Footstep (carpet)", kind: AssetKind::Sound,
categories: &["sound/footstep"],
aliases: &["footstep", "step", "walk", "walking", "running", "carpet", "soft", "indoor", "quiet", "sound when you walk"] },
AudioAlias { key: "impact-sounds/footstep_concrete", name: "Footstep (concrete)", kind: AssetKind::Sound,
categories: &["sound/footstep"],
aliases: &["footstep", "step", "walk", "walking", "running", "concrete", "stone", "pavement", "hard", "city", "sound when you walk"] },
AudioAlias { key: "impact-sounds/footstep_grass", name: "Footstep (grass)", kind: AssetKind::Sound,
categories: &["sound/footstep"],
aliases: &["footstep", "step", "walk", "walking", "running", "grass", "outdoor", "nature", "soft", "sound when you walk"] },
AudioAlias { key: "impact-sounds/footstep_snow", name: "Footstep (snow)", kind: AssetKind::Sound,
categories: &["sound/footstep"],
aliases: &["footstep", "step", "walk", "walking", "snow", "ice", "winter", "crunch", "cold"] },
AudioAlias { key: "impact-sounds/footstep_wood", name: "Footstep (wood)", kind: AssetKind::Sound,
categories: &["sound/footstep"],
aliases: &["footstep", "step", "walk", "walking", "running", "wood", "wooden", "plank", "floorboard", "footsteps on wood"] },
AudioAlias { key: "rpg-audio/footstep", name: "Footstep", kind: AssetKind::Sound,
categories: &["sound/footstep"],
aliases: &["footstep", "step", "walk", "walking", "running", "medieval", "rpg"] },
// --------------------------------------------------------- impacts
AudioAlias { key: "impact-sounds/impactGeneric_light", name: "Light Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "hit", "bump", "tap", "knock", "light", "small", "soft", "gentle", "sound when you get hit", "collide"] },
AudioAlias { key: "impact-sounds/impactBell_heavy", name: "Bell Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "bell", "clang", "ring", "chime", "metal", "heavy", "gong", "hit"] },
AudioAlias { key: "impact-sounds/impactGlass_light", name: "Glass Impact (light)", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "glass", "tink", "clink", "light", "small", "break", "shatter", "hit", "fragile"] },
AudioAlias { key: "impact-sounds/impactGlass_medium", name: "Glass Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "glass", "clink", "break", "shatter", "smash", "medium", "hit", "fragile", "window"] },
AudioAlias { key: "impact-sounds/impactGlass_heavy", name: "Glass Smash", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "glass", "smash", "shatter", "break", "crash", "heavy", "big", "window", "hit hard"] },
AudioAlias { key: "impact-sounds/impactMetal_light", name: "Metal Impact (light)", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "metal", "clink", "ting", "light", "small", "hit", "metallic"] },
AudioAlias { key: "impact-sounds/impactMetal_medium", name: "Metal Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "metal", "clang", "bang", "hit", "medium", "metallic", "car", "crash into metal"] },
AudioAlias { key: "impact-sounds/impactMetal_heavy", name: "Metal Crash", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "metal", "clang", "crash", "bang", "heavy", "big", "hard", "car crash", "smash", "collision"] },
AudioAlias { key: "sci-fi-sounds/impactMetal", name: "Sci-Fi Metal Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "metal", "clang", "sci-fi", "space", "hit", "robot", "mech"] },
AudioAlias { key: "impact-sounds/impactWood_light", name: "Wood Impact (light)", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "wood", "wooden", "knock", "tap", "light", "small", "hit"] },
AudioAlias { key: "impact-sounds/impactWood_medium", name: "Wood Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "wood", "wooden", "thud", "knock", "hit", "medium", "crate", "box"] },
AudioAlias { key: "impact-sounds/impactWood_heavy", name: "Wood Crash", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "wood", "wooden", "crash", "smash", "break", "heavy", "big", "crate", "hit hard"] },
AudioAlias { key: "impact-sounds/impactPlank_medium", name: "Plank Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "plank", "wood", "board", "thud", "hit", "wooden"] },
AudioAlias { key: "impact-sounds/impactPlate_light", name: "Plate Impact (light)", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "plate", "metal", "sheet", "light", "hit", "ting"] },
AudioAlias { key: "impact-sounds/impactPlate_medium", name: "Plate Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "plate", "metal", "sheet", "hit", "clang", "medium"] },
AudioAlias { key: "impact-sounds/impactPlate_heavy", name: "Plate Crash", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "plate", "metal", "sheet", "crash", "clang", "heavy", "big", "loud"] },
AudioAlias { key: "impact-sounds/impactPunch_medium", name: "Punch", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["punch", "hit", "impact", "fight", "thump", "melee", "attack", "sound when you get hit", "smack"] },
AudioAlias { key: "impact-sounds/impactPunch_heavy", name: "Heavy Punch", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["punch", "hit", "impact", "fight", "thump", "melee", "attack", "heavy", "big", "hard", "smack"] },
AudioAlias { key: "impact-sounds/impactSoft_medium", name: "Soft Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "soft", "thud", "muffled", "land", "landing", "body", "hit", "gentle"] },
AudioAlias { key: "impact-sounds/impactSoft_heavy", name: "Heavy Soft Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "soft", "thud", "heavy", "land", "landing", "big", "body", "fall"] },
AudioAlias { key: "impact-sounds/impactTin_medium", name: "Tin Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["impact", "tin", "can", "metal", "rattle", "hit", "clank"] },
AudioAlias { key: "impact-sounds/impactMining", name: "Mining Impact", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["mining", "pickaxe", "dig", "digging", "rock", "stone", "hit", "mine", "quarry"] },
// ---------------------------------------------------------- weapons
AudioAlias { key: "sci-fi-sounds/laserSmall", name: "Small Laser", kind: AssetKind::Sound,
categories: &["sound/weapon"],
aliases: &["laser", "shoot", "shot", "pew", "blaster", "gun", "small", "sci-fi", "space", "zap", "laser gun", "fire"] },
AudioAlias { key: "sci-fi-sounds/laserLarge", name: "Large Laser", kind: AssetKind::Sound,
categories: &["sound/weapon"],
aliases: &["laser", "shoot", "shot", "blaster", "gun", "big", "large", "heavy", "sci-fi", "cannon", "laser gun", "fire"] },
AudioAlias { key: "sci-fi-sounds/laserRetro", name: "Retro Laser", kind: AssetKind::Sound,
categories: &["sound/weapon"],
aliases: &["laser", "shoot", "retro", "arcade", "8-bit", "pew", "blaster", "gun", "old school", "classic"] },
AudioAlias { key: "digital-audio/laser", name: "Digital Laser", kind: AssetKind::Sound,
categories: &["sound/weapon"],
aliases: &["laser", "shoot", "zap", "blaster", "gun", "retro", "8-bit", "arcade", "pew", "digital"] },
AudioAlias { key: "digital-audio/zap", name: "Zap", kind: AssetKind::Sound,
categories: &["sound/weapon"],
aliases: &["zap", "shock", "electric", "laser", "shoot", "retro", "arcade", "8-bit", "hit"] },
AudioAlias { key: "digital-audio/zapTwoTone", name: "Zap (two tone)", kind: AssetKind::Sound,
categories: &["sound/weapon"],
aliases: &["zap", "shoot", "laser", "retro", "arcade", "8-bit", "electric"] },
AudioAlias { key: "digital-audio/zapThreeToneUp", name: "Zap Up", kind: AssetKind::Sound,
categories: &["sound/weapon", "sound/pickup"],
aliases: &["zap", "up", "rising", "powerup", "retro", "arcade", "8-bit", "collect"] },
AudioAlias { key: "digital-audio/zapThreeToneDown", name: "Zap Down", kind: AssetKind::Sound,
categories: &["sound/weapon"],
aliases: &["zap", "down", "falling", "lose", "retro", "arcade", "8-bit", "fail"] },
AudioAlias { key: "sci-fi-sounds/explosionCrunch", name: "Explosion", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["explosion", "boom", "blast", "crunch", "bang", "destroy", "big", "crash", "blow up", "die"] },
AudioAlias { key: "sci-fi-sounds/lowFrequency_explosion", name: "Deep Explosion", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["explosion", "boom", "blast", "deep", "low", "rumble", "big", "heavy", "destroy", "blow up"] },
AudioAlias { key: "rpg-audio/knifeSlice", name: "Knife Slice", kind: AssetKind::Sound,
categories: &["sound/weapon"],
aliases: &["knife", "slice", "slash", "sword", "cut", "blade", "attack", "swing", "melee", "medieval"] },
AudioAlias { key: "rpg-audio/drawKnife", name: "Draw Knife", kind: AssetKind::Sound,
categories: &["sound/weapon"],
aliases: &["knife", "draw", "unsheathe", "sword", "blade", "equip", "medieval", "weapon"] },
AudioAlias { key: "rpg-audio/chop", name: "Chop", kind: AssetKind::Sound,
categories: &["sound/weapon"],
aliases: &["chop", "axe", "cut", "wood", "hit", "attack", "melee", "hack"] },
// ---------------------------------------------------------- engines
AudioAlias { key: "sci-fi-sounds/spaceEngine", name: "Space Engine", kind: AssetKind::Sound,
categories: &["sound/engine"],
aliases: &["engine", "motor", "rocket", "thrust", "sci-fi", "space", "hum", "flying", "loop"] },
AudioAlias { key: "sci-fi-sounds/spaceEngineSmall", name: "Small Space Engine", kind: AssetKind::Sound,
categories: &["sound/engine"],
aliases: &["engine", "motor", "small", "thrust", "sci-fi", "space", "hum", "loop"] },
AudioAlias { key: "sci-fi-sounds/spaceEngineLarge", name: "Large Space Engine", kind: AssetKind::Sound,
categories: &["sound/engine"],
aliases: &["engine", "motor", "big", "large", "thrust", "sci-fi", "space", "rumble", "loop"] },
AudioAlias { key: "sci-fi-sounds/spaceEngineLow", name: "Low Space Engine", kind: AssetKind::Sound,
categories: &["sound/engine"],
aliases: &["engine", "motor", "low", "deep", "sci-fi", "space", "rumble", "hum", "loop"] },
AudioAlias { key: "sci-fi-sounds/engineCircular", name: "Circular Engine", kind: AssetKind::Sound,
categories: &["sound/engine"],
aliases: &["engine", "motor", "machine", "loop", "hum", "whirr", "sci-fi", "machinery"] },
AudioAlias { key: "sci-fi-sounds/thrusterFire", name: "Thruster", kind: AssetKind::Sound,
categories: &["sound/engine"],
aliases: &["thruster", "rocket", "boost", "jet", "engine", "fire", "sci-fi", "space", "launch"] },
// -------------------------------------------------------- ui / menu
AudioAlias { key: "interface-sounds/click", name: "Click", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["click", "button", "press", "tap", "ui", "menu", "select", "interface", "tick"] },
AudioAlias { key: "ui-audio/click", name: "UI Click", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["click", "button", "press", "ui", "menu", "interface", "select"] },
AudioAlias { key: "ui-audio/mouseclick", name: "Mouse Click", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["click", "mouse", "button", "press", "ui", "menu", "interface"] },
AudioAlias { key: "ui-audio/mouserelease", name: "Mouse Release", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["release", "click", "mouse", "button", "unpress", "ui", "menu"] },
AudioAlias { key: "ui-audio/rollover", name: "Rollover", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["rollover", "hover", "highlight", "ui", "menu", "focus", "interface"] },
AudioAlias { key: "interface-sounds/select", name: "Select", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["select", "choose", "pick", "ui", "menu", "confirm", "interface"] },
AudioAlias { key: "interface-sounds/confirmation", name: "Confirm", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["confirm", "ok", "accept", "yes", "success", "ui", "menu", "positive", "good"] },
AudioAlias { key: "interface-sounds/error", name: "Error", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["error", "wrong", "fail", "bad", "no", "denied", "buzz", "ui", "menu", "negative", "lose"] },
AudioAlias { key: "interface-sounds/question", name: "Question", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["question", "prompt", "ask", "ui", "menu", "dialog", "notify"] },
AudioAlias { key: "interface-sounds/back", name: "Back", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["back", "cancel", "return", "undo", "ui", "menu", "escape", "previous"] },
AudioAlias { key: "interface-sounds/close", name: "Close", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["close", "shut", "dismiss", "ui", "menu", "window", "exit"] },
AudioAlias { key: "interface-sounds/open", name: "Open", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["open", "show", "reveal", "ui", "menu", "window", "appear"] },
AudioAlias { key: "interface-sounds/maximize", name: "Maximize", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["maximize", "expand", "grow", "bigger", "ui", "window", "up"] },
AudioAlias { key: "interface-sounds/minimize", name: "Minimize", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["minimize", "shrink", "smaller", "collapse", "ui", "window", "down"] },
AudioAlias { key: "interface-sounds/toggle", name: "Toggle", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["toggle", "switch", "flip", "on", "off", "ui", "menu", "checkbox"] },
AudioAlias { key: "interface-sounds/switch", name: "Switch", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["switch", "toggle", "change", "flip", "ui", "menu", "lever"] },
AudioAlias { key: "ui-audio/switch", name: "UI Switch", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["switch", "toggle", "change", "ui", "menu"] },
AudioAlias { key: "interface-sounds/scroll", name: "Scroll", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["scroll", "wheel", "list", "ui", "menu", "move"] },
AudioAlias { key: "interface-sounds/tick", name: "Tick", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["tick", "click", "small", "ui", "menu", "increment", "timer"] },
AudioAlias { key: "interface-sounds/drop", name: "Drop", kind: AssetKind::Sound,
categories: &["sound/ui", "sound/impact"],
aliases: &["drop", "place", "put", "release", "ui", "inventory", "item"] },
AudioAlias { key: "interface-sounds/pluck", name: "Pluck", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["pluck", "pop", "pick", "ui", "menu", "string", "note"] },
AudioAlias { key: "interface-sounds/bong", name: "Bong", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["bong", "bell", "chime", "gong", "notify", "alert", "ui", "deep"] },
AudioAlias { key: "interface-sounds/glass", name: "Glass UI", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["glass", "chime", "ting", "ui", "menu", "delicate", "clean"] },
AudioAlias { key: "interface-sounds/glitch", name: "Glitch", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["glitch", "error", "digital", "broken", "static", "corrupt", "sci-fi", "bad"] },
AudioAlias { key: "interface-sounds/scratch", name: "Scratch", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["scratch", "scrape", "rough", "record", "ui", "rub"] },
// ------------------------------------------------------- pickups etc
AudioAlias { key: "digital-audio/powerUp", name: "Power Up", kind: AssetKind::Sound,
categories: &["sound/pickup"],
aliases: &["powerup", "power up", "collect", "pickup", "coin", "bonus", "good", "reward", "retro", "8-bit", "arcade", "level up", "get"] },
AudioAlias { key: "digital-audio/pepSound", name: "Pep Sound", kind: AssetKind::Sound,
categories: &["sound/pickup"],
aliases: &["pep", "blip", "collect", "pickup", "coin", "point", "retro", "8-bit", "arcade", "positive"] },
AudioAlias { key: "digital-audio/highUp", name: "High Up", kind: AssetKind::Sound,
categories: &["sound/pickup"],
aliases: &["up", "rising", "high", "collect", "pickup", "jump", "positive", "good", "retro", "8-bit"] },
AudioAlias { key: "digital-audio/highDown", name: "High Down", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["down", "falling", "high", "lose", "negative", "bad", "retro", "8-bit"] },
AudioAlias { key: "digital-audio/lowDown", name: "Low Down", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["down", "low", "falling", "lose", "fail", "negative", "bad", "die", "retro", "8-bit", "game over"] },
AudioAlias { key: "digital-audio/phaseJump", name: "Phase Jump", kind: AssetKind::Sound,
categories: &["sound/pickup"],
aliases: &["jump", "warp", "teleport", "phase", "retro", "8-bit", "arcade", "sci-fi", "hop"] },
AudioAlias { key: "digital-audio/phaserUp", name: "Phaser Up", kind: AssetKind::Sound,
categories: &["sound/pickup"],
aliases: &["phaser", "up", "rising", "charge", "powerup", "retro", "8-bit", "sci-fi"] },
AudioAlias { key: "digital-audio/phaserDown", name: "Phaser Down", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["phaser", "down", "falling", "power down", "retro", "8-bit", "sci-fi", "lose"] },
AudioAlias { key: "digital-audio/tone", name: "Tone", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["tone", "beep", "note", "blip", "simple", "retro", "8-bit"] },
AudioAlias { key: "digital-audio/twoTone", name: "Two Tone", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["two tone", "beep", "notify", "alert", "retro", "8-bit", "double"] },
AudioAlias { key: "digital-audio/threeTone", name: "Three Tone", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["three tone", "beep", "notify", "alert", "retro", "8-bit", "triple", "jingle"] },
AudioAlias { key: "digital-audio/lowThreeTone", name: "Low Three Tone", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["three tone", "low", "deep", "beep", "alert", "retro", "8-bit", "warning"] },
AudioAlias { key: "digital-audio/lowRandom", name: "Low Random", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["low", "random", "noise", "beep", "retro", "8-bit", "glitch"] },
AudioAlias { key: "digital-audio/spaceTrash", name: "Space Trash", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["noise", "static", "trash", "junk", "sci-fi", "space", "retro", "8-bit", "glitch"] },
// --------------------------------------------------------- sci-fi misc
AudioAlias { key: "sci-fi-sounds/computerNoise", name: "Computer Noise", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["computer", "beep", "data", "terminal", "sci-fi", "tech", "processing", "machine"] },
AudioAlias { key: "sci-fi-sounds/forceField", name: "Force Field", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["force field", "shield", "barrier", "energy", "hum", "sci-fi", "loop", "protect"] },
AudioAlias { key: "sci-fi-sounds/slime", name: "Slime", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["slime", "squelch", "goo", "wet", "gross", "monster", "splat", "organic"] },
AudioAlias { key: "sci-fi-sounds/doorOpen", name: "Sci-Fi Door Open", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["door", "open", "sci-fi", "space", "hiss", "automatic", "airlock"] },
AudioAlias { key: "sci-fi-sounds/doorClose", name: "Sci-Fi Door Close", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["door", "close", "shut", "sci-fi", "space", "hiss", "automatic", "airlock"] },
// -------------------------------------------------------------- rpg
AudioAlias { key: "rpg-audio/doorOpen", name: "Door Open", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["door", "open", "creak", "wooden", "medieval", "rpg", "enter"] },
AudioAlias { key: "rpg-audio/doorClose", name: "Door Close", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["door", "close", "shut", "slam", "wooden", "medieval", "rpg"] },
AudioAlias { key: "rpg-audio/creak", name: "Creak", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["creak", "squeak", "wood", "old", "spooky", "scary", "door", "medieval"] },
AudioAlias { key: "rpg-audio/handleCoins", name: "Coins", kind: AssetKind::Sound,
categories: &["sound/pickup"],
aliases: &["coins", "money", "gold", "jingle", "collect", "pickup", "treasure", "rich", "shop", "buy", "reward"] },
AudioAlias { key: "rpg-audio/bookOpen", name: "Book Open", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["book", "open", "page", "read", "menu", "journal", "medieval", "rpg"] },
AudioAlias { key: "rpg-audio/bookClose", name: "Book Close", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["book", "close", "shut", "read", "menu", "journal", "medieval", "rpg"] },
AudioAlias { key: "rpg-audio/bookFlip", name: "Page Flip", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["page", "flip", "turn", "book", "paper", "read", "menu", "next"] },
AudioAlias { key: "rpg-audio/bookPlace", name: "Book Place", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["book", "place", "put", "down", "thud", "menu", "medieval"] },
AudioAlias { key: "rpg-audio/cloth", name: "Cloth", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["cloth", "fabric", "rustle", "clothes", "equip", "swish", "medieval"] },
AudioAlias { key: "rpg-audio/clothBelt", name: "Cloth Belt", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["belt", "cloth", "buckle", "equip", "armour", "armor", "medieval"] },
AudioAlias { key: "rpg-audio/beltHandle", name: "Belt Handle", kind: AssetKind::Sound,
categories: &["sound/ambient"],
aliases: &["belt", "leather", "handle", "equip", "inventory", "medieval"] },
AudioAlias { key: "rpg-audio/dropLeather", name: "Drop Leather", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["leather", "drop", "place", "inventory", "item", "medieval", "bag"] },
AudioAlias { key: "rpg-audio/handleSmallLeather", name: "Leather Handle", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["leather", "handle", "bag", "pouch", "inventory", "item", "medieval"] },
AudioAlias { key: "rpg-audio/metalClick", name: "Metal Click", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["metal", "click", "latch", "lock", "mechanism", "equip", "medieval"] },
AudioAlias { key: "rpg-audio/metalLatch", name: "Metal Latch", kind: AssetKind::Sound,
categories: &["sound/ui"],
aliases: &["metal", "latch", "lock", "chest", "click", "mechanism", "open", "medieval"] },
AudioAlias { key: "rpg-audio/metalPot", name: "Metal Pot", kind: AssetKind::Sound,
categories: &["sound/impact"],
aliases: &["pot", "metal", "pan", "clang", "kitchen", "cooking", "medieval", "hit"] },
// ------------------------------------------------------------- music
AudioAlias { key: "music-jingles/jingles_HIT", name: "Jingle (hit)", kind: AssetKind::Music,
categories: &["music/jingle"],
aliases: &["jingle", "music", "sting", "hit", "short", "fanfare", "tune", "melody"] },
AudioAlias { key: "music-jingles/jingles_NES", name: "Jingle (NES)", kind: AssetKind::Music,
categories: &["music/jingle"],
aliases: &["jingle", "music", "nes", "retro", "8-bit", "chiptune", "arcade", "sting", "happy", "victory", "win", "tune"] },
AudioAlias { key: "music-jingles/jingles_PIZZI", name: "Jingle (pizzicato)", kind: AssetKind::Music,
categories: &["music/jingle"],
aliases: &["jingle", "music", "pizzicato", "strings", "playful", "cute", "light", "happy", "sting", "tune"] },
AudioAlias { key: "music-jingles/jingles_SAX", name: "Jingle (sax)", kind: AssetKind::Music,
categories: &["music/jingle"],
aliases: &["jingle", "music", "sax", "saxophone", "jazzy", "smooth", "sting", "happy", "tune"] },
AudioAlias { key: "music-jingles/jingles_STEEL", name: "Jingle (steel drum)", kind: AssetKind::Music,
categories: &["music/jingle"],
aliases: &["jingle", "music", "steel drum", "tropical", "happy", "cheerful", "sting", "tune", "island"] },
];
/// Extra query expansions that only make sense for audio.
pub const AUDIO_SYNONYMS: &[(&str, &[&str])] = &[
("sfx", &["sound"]),
("noise", &["sound"]),
("audio", &["sound"]),
("smash", &["impact", "crash", "break"]),
("crash", &["impact", "smash"]),
("bump", &["impact", "hit"]),
("thud", &["impact", "soft"]),
("clang", &["metal", "impact"]),
("bang", &["impact", "explosion"]),
("boom", &["explosion"]),
("pew", &["laser"]),
("shoot", &["laser", "weapon"]),
("gunshot", &["laser", "weapon"]),
("fanfare", &["jingle", "victory"]),
("victory", &["win", "jingle"]),
("win", &["victory", "jingle", "powerup"]),
("winning", &["victory", "win", "jingle"]),
("lose", &["fail", "error", "down"]),
("losing", &["lose", "fail"]),
("gameover", &["lose", "fail", "down"]),
("background", &["music", "loop", "ambient"]),
("soundtrack", &["music", "jingle"]),
("song", &["music", "jingle"]),
("tune", &["music", "jingle"]),
("chiptune", &["8-bit", "retro", "nes"]),
("footsteps", &["footstep"]),
("splash", &["water", "slime"]),
("whoosh", &["swish", "cloth"]),
("skid", &["engine", "tyre"]),
("beep", &["tone", "ui"]),
];
pub fn lookup(key: &str) -> Option<&'static AudioAlias> {
AUDIO_ALIASES.iter().find(|a| a.key == key)
}
/// Strip Kenney's variant suffix so `footstep_wood_003` maps to the curated
/// `footstep_wood` family.
pub fn family_of(stem: &str) -> String {
let trimmed = stem.trim_end_matches(|c: char| c.is_ascii_digit());
trimmed.trim_end_matches(['_', '-', ' ']).to_string()
}

View file

@ -1,326 +0,0 @@
//! Minimal GLB probe: rigged/animated flags and an approximate bounding size.
//!
//! Deliberately not a glTF parser — `libs/game/render` owns the real one. This
//! reads the container header and scans the JSON chunk textually for the three
//! facts the index needs. Everything is optional: a file we cannot understand
//! yields `Probe::default()` and the entry is still indexed and searchable.
use std::path::Path;
#[derive(Default, Debug)]
pub struct Probe {
pub rigged: bool,
pub animated: bool,
pub size: Option<[f32; 3]>,
/// Animation clip names in file order. This is the fact the AI cannot
/// guess: it must know a character HAS `Jump_Land` before writing code
/// that plays it, and every pack names its states differently.
pub clips: Vec<String>,
/// Joint count of the first skin. Equal counts usually mean the same rig,
/// which is what lets one animation vocabulary drive many characters.
pub joints: u32,
}
/// "glTF" little-endian. The previous value (0x4655_4C67) spells "gLUF", so
/// the magic check rejected every real GLB and `probe` returned defaults for
/// the whole library — no model was ever detected as rigged, animated or
/// sized. Kept as a named constant with this note because the failure was
/// invisible: the index still built, it just believed nothing.
const GLB_MAGIC: u32 = 0x4654_6C67;
const CHUNK_JSON: u32 = 0x4E4F_534A; // "JSON"
/// The JSON chunk of a Kenney-scale model is a few KB; cap the read so a
/// hostile or corrupt file cannot make us allocate wildly.
const MAX_JSON: usize = 4 * 1024 * 1024;
pub fn probe(path: &Path) -> Probe {
// Read only the JSON chunk, not the mesh data behind it. A GLB states its
// JSON length in the header, so indexing 5000 models touches a few KB
// each instead of the whole file — the difference between a 7 s startup
// and a fraction of a second.
let Ok(bytes) = read_head(path) else {
return Probe::default();
};
// Two containers reach us: .glb wraps the JSON in a binary chunk (Kenney,
// KayKit), while .gltf IS the JSON (Quaternius ships self-contained .gltf
// with the buffer base64'd inline). Only the header differs.
let Some(json) = json_chunk(&bytes).or_else(|| gltf_json(&bytes)) else {
return Probe::default();
};
let clips = clip_names(&json);
Probe {
// Presence of a skin means a skeleton; `"skins"` only appears as a
// top-level array key in glTF.
rigged: json.contains("\"skins\""),
animated: !clips.is_empty() || json.contains("\"animations\""),
size: bounds(&json),
joints: joint_count(&json),
clips,
}
}
/// Read just enough of the file to answer the probe.
///
/// For a GLB that is the 20-byte header plus the JSON chunk it declares. For
/// a plain `.gltf` there is no such framing, so the document is read whole but
/// capped — those embed their buffer as base64 and run to megabytes.
fn read_head(path: &Path) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let mut f = std::fs::File::open(path)?;
let mut head = [0u8; 20];
let n = f.read(&mut head)?;
if n == 20 && u32(&head, 0) == Some(GLB_MAGIC) {
let len = u32(&head, 12).unwrap_or(0) as usize;
if u32(&head, 16) == Some(CHUNK_JSON) && len <= MAX_JSON {
let mut out = head.to_vec();
out.resize(20 + len, 0);
// A truncated file yields a short read; the caller's bounds check
// rejects it rather than reading past what arrived.
let got = f.read(&mut out[20..])?;
out.truncate(20 + got);
return Ok(out);
}
return Ok(head.to_vec());
}
// Not a GLB: fall back to the whole document, capped.
let mut out = head[..n].to_vec();
f.take(MAX_JSON as u64).read_to_end(&mut out)?;
Ok(out)
}
/// A plain `.gltf` file is the JSON document itself. Cap it like the GLB path:
/// Quaternius embeds its buffer as base64, so these run to a few MB.
fn gltf_json(bytes: &[u8]) -> Option<String> {
if bytes.len() > 64 * 1024 * 1024 {
return None;
}
let s = String::from_utf8_lossy(bytes);
let head = &s[..s.len().min(512)];
if !head.trim_start().starts_with('{') || !head.contains("\"asset\"") {
return None;
}
Some(s.into_owned())
}
/// Clip names, read from the `"animations"` array only.
///
/// Scoped by bracket-matching rather than a global scan for `"name"`, because
/// meshes, nodes and materials all carry names too — a global scan would
/// report "Cube.003" as an animation state.
fn clip_names(json: &str) -> Vec<String> {
let Some(arr) = array_after(json, "\"animations\"") else {
return Vec::new();
};
let mut out = Vec::new();
let mut depth = 0i32;
let mut rest = arr;
// Names at object-depth 1 are the animations themselves; deeper ones
// belong to channels and samplers. `]` at depth 0 ends the array — without
// that stop the scan runs on into `materials` and reports texture names as
// animation states.
while let Some(i) = rest.find(|c| c == '{' || c == '}' || c == '[' || c == ']' || c == '"') {
let ch = rest.as_bytes()[i];
match ch {
b'{' | b'[' => {
depth += 1;
rest = &rest[i + 1..];
}
b']' if depth == 0 => break,
b'}' | b']' => {
depth -= 1;
rest = &rest[i + 1..];
}
_ => {
let after = &rest[i..];
if depth == 1 && after.starts_with("\"name\"") {
if let Some(v) = string_value(&after[6..]) {
out.push(v);
}
}
// Skip the whole string so its contents can't be re-scanned.
rest = match string_end(after) {
Some(e) => &after[e..],
None => break,
};
}
}
if out.len() > 512 {
break; // absurd clip count: stop rather than grow unbounded
}
}
out
}
fn joint_count(json: &str) -> u32 {
let Some(arr) = array_after(json, "\"joints\"") else {
return 0;
};
let Some(end) = arr.find(']') else { return 0 };
arr[..end]
.split(',')
.filter(|s| !s.trim().is_empty())
.count() as u32
}
/// Slice starting just after the `[` that follows `key`.
fn array_after<'a>(json: &'a str, key: &str) -> Option<&'a str> {
let at = json.find(key)?;
let open = json[at..].find('[')? + at;
Some(&json[open + 1..])
}
/// Read `"..."` from the head of `s` (after a `:`), honouring backslash escapes.
fn string_value(s: &str) -> Option<String> {
let colon = s.find(':')?;
let rest = &s[colon + 1..];
let open = rest.find('"')?;
let body = &rest[open + 1..];
let end = unescaped_quote(body)?;
Some(body[..end].to_string())
}
/// Byte offset just past the closing quote of the string starting at `s[0]`.
fn string_end(s: &str) -> Option<usize> {
let body = &s[1..];
Some(1 + unescaped_quote(body)? + 1)
}
fn unescaped_quote(s: &str) -> Option<usize> {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
match b[i] {
b'\\' => i += 2,
b'"' => return Some(i),
_ => i += 1,
}
}
None
}
fn json_chunk(bytes: &[u8]) -> Option<String> {
if bytes.len() < 20 || u32(bytes, 0)? != GLB_MAGIC {
return None;
}
let len = u32(bytes, 12)? as usize;
if u32(bytes, 16)? != CHUNK_JSON || len > MAX_JSON {
return None;
}
let start = 20usize;
let end = start.checked_add(len)?;
if end > bytes.len() {
return None;
}
Some(String::from_utf8_lossy(&bytes[start..end]).into_owned())
}
fn u32(b: &[u8], at: usize) -> Option<u32> {
let s = b.get(at..at + 4)?;
Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
/// Approximate bounds from the first 3-component accessor min/max pair, which
/// by glTF convention is a POSITION accessor (they are the only accessors
/// required to carry min/max). Approximate on purpose: it drives "big vs
/// small" filters, not collision.
fn bounds(json: &str) -> Option<[f32; 3]> {
// Search each key independently rather than assuming an order. Kenney's
// exporter writes "max" BEFORE "min", so looking for "max" only after
// "min" found nothing and every Kenney model came back sizeless — which
// silently disabled the big/small filters across the whole library.
let min = first_triple(json, "\"min\"")?;
let max = first_triple(json, "\"max\"")?;
let (lo, hi) = (min, max);
Some([
(hi[0] - lo[0]).abs(),
(hi[1] - lo[1]).abs(),
(hi[2] - lo[2]).abs(),
])
}
/// First `[a,b,c]` following `key` anywhere in the document. The first
/// accessor carrying bounds is POSITION by glTF convention (only it is
/// required to have them), so the first hit is the mesh's own extent.
fn first_triple(json: &str, key: &str) -> Option<[f32; 3]> {
let mut rest = json;
while let Some(pos) = rest.find(key) {
let after = &rest[pos + key.len()..];
if let Some(v) = triple(after) {
return Some(v);
}
rest = &rest[pos + key.len()..];
}
None
}
/// Read `[a,b,c]` (exactly three numbers) from the head of `s`.
fn triple(s: &str) -> Option<[f32; 3]> {
let open = s.find('[')?;
let close = s[open..].find(']')? + open;
let mut it = s[open + 1..close].split(',');
let a = it.next()?.trim().parse::<f32>().ok()?;
let b = it.next()?.trim().parse::<f32>().ok()?;
let c = it.next()?.trim().parse::<f32>().ok()?;
if it.next().is_some() {
return None; // 4+ components: not a POSITION accessor
}
Some([a, b, c])
}
#[cfg(test)]
mod tests {
use super::*;
/// Clip extraction must read the `animations` array and nothing else:
/// meshes, nodes and materials all carry `"name"` too, and a global scan
/// would report mesh names as playable animation states.
#[test]
fn clip_names_come_only_from_the_animations_array() {
let json = r#"{"asset":{},"meshes":[{"name":"Cube.003"}],
"nodes":[{"name":"Armature"}],
"animations":[{"name":"Idle","channels":[{"target":{"path":"translation"}}]},
{"name":"Walk_A","samplers":[{"input":0}]}],
"materials":[{"name":"Atlas"}]}"#;
assert_eq!(clip_names(json), vec!["Idle", "Walk_A"]);
}
#[test]
fn clip_names_survive_escapes_and_missing_animations() {
let json = r#"{"animations":[{"name":"Say \"Hi\""},{"name":"Wave"}]}"#;
assert_eq!(clip_names(json), vec!["Say \\\"Hi\\\"", "Wave"]);
assert!(clip_names(r#"{"meshes":[{"name":"X"}]}"#).is_empty());
}
#[test]
fn joint_count_reads_the_first_skin() {
assert_eq!(joint_count(r#"{"skins":[{"joints":[1,2,3,4]}]}"#), 4);
assert_eq!(joint_count(r#"{"meshes":[]}"#), 0);
}
/// A plain .gltf is the JSON itself — Quaternius ships those with the
/// buffer base64'd inline, so there is no binary chunk to unwrap.
#[test]
fn plain_gltf_is_recognised_and_junk_is_not() {
let ok = br#"{"asset":{"version":"2.0"},"animations":[{"name":"Idle"}]}"#;
assert!(gltf_json(ok).is_some());
assert!(gltf_json(b"not json at all").is_none());
assert!(gltf_json(br#"{"nope":1}"#).is_none());
}
}
#[cfg(test)]
mod bounds_tests {
use super::*;
/// Kenney's exporter writes "max" BEFORE "min". Searching for "max" only
/// after "min" found nothing, so every Kenney model indexed with no size
/// and the big/small filters silently matched nothing.
#[test]
fn bounds_do_not_depend_on_key_order() {
let kenney = r#"{"accessors":[{"type":"VEC3","max":[0.3,1.02,0.125],"min":[-0.3,0.0,-0.1]}]}"#;
let other = r#"{"accessors":[{"type":"VEC3","min":[-0.3,0.0,-0.1],"max":[0.3,1.02,0.125]}]}"#;
let a = bounds(kenney).expect("max-first must parse");
let b = bounds(other).expect("min-first must parse");
assert!((a[0] - 0.6).abs() < 1e-5 && (a[1] - 1.02).abs() < 1e-5);
assert_eq!(a, b);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,130 +0,0 @@
//! Per-pack theme curation — the cheap half of making 4700 models findable.
//!
//! Hand-curating every model does not survive a catalogue this size, so the
//! work is split three ways:
//!
//! 1. **this file** — ~55 rows, one per pack, giving every model in that pack
//! its setting and theme keywords for free. A model in `castle-kit` is
//! findable as "medieval" without anyone writing a row for it.
//! 2. **filename tokens** (`lib.rs`) — Kenney's names are systematic
//! (`tree_pineDefaultA`, `boat-sail-a`), so splitting on separators and
//! camelCase yields real nouns at zero curation cost.
//! 3. **query-time synonyms** (`aliases.rs`) — one table that applies to the
//! whole catalogue regardless of its size, which is why that is where
//! curation effort pays best.
//!
//! Item-level rows in `aliases.rs` are then spent only on the few hundred
//! things people actually ask for by name.
/// Theme keywords contributed to every model in a pack, plus the pack's
/// display name. Keyed by directory name.
pub struct PackTheme {
pub pack: &'static str,
pub name: &'static str,
pub themes: &'static [&'static str],
}
pub const PACK_THEMES: &[PackTheme] = &[
// ---- starter kits (pinned from the KenneyNL GitHub repos) -----------
PackTheme { pack: "arena", name: "Mini Arena", themes: &["arena", "battle", "roman", "medieval", "fight", "combat", "colosseum"] },
PackTheme { pack: "city", name: "City Builder", themes: &["city", "town", "urban", "street", "modern", "builder"] },
PackTheme { pack: "fps", name: "FPS Kit", themes: &["shooter", "fps", "sci-fi", "combat", "arena", "first person"] },
PackTheme { pack: "platformer", name: "Platformer Starter", themes: &["platformer", "jumping", "level", "arcade", "side scroller"] },
PackTheme { pack: "racing", name: "Racing Starter", themes: &["racing", "race", "speed", "track", "driving"] },
// ---- full catalogue -------------------------------------------------
PackTheme { pack: "3d-road-tiles", name: "3D Road Tiles", themes: &["road", "street", "tile", "modular", "driving", "city"] },
PackTheme { pack: "blaster-kit", name: "Blaster Kit", themes: &["weapon", "blaster", "gun", "sci-fi", "shooter", "target", "shooting"] },
PackTheme { pack: "blocky-characters", name: "Blocky Characters", themes: &["character", "person", "people", "blocky", "player", "avatar", "someone to play as"] },
PackTheme { pack: "brick-kit", name: "Brick Kit", themes: &["brick", "toy", "plastic", "building block", "lego", "construction", "build"] },
PackTheme { pack: "building-kit", name: "Building Kit", themes: &["building", "house", "structure", "modular", "architecture"] },
PackTheme { pack: "car-kit", name: "Car Kit", themes: &["car", "vehicle", "driving", "transport", "road", "something to drive"] },
PackTheme { pack: "castle-kit", name: "Castle Kit", themes: &["castle", "medieval", "fantasy", "fortress", "knight", "king", "kingdom"] },
PackTheme { pack: "city-kit-commercial", name: "City Kit: Commercial", themes: &["city", "building", "skyscraper", "commercial", "shop", "urban", "downtown", "town"] },
PackTheme { pack: "city-kit-industrial", name: "City Kit: Industrial", themes: &["city", "factory", "warehouse", "industrial", "urban", "works"] },
PackTheme { pack: "city-kit-roads", name: "City Kit: Roads", themes: &["road", "street", "city", "town", "driving", "traffic", "something to drive on"] },
PackTheme { pack: "city-kit-suburban", name: "City Kit: Suburban", themes: &["city", "suburban", "house", "home", "neighbourhood", "neighborhood", "town", "somewhere to live"] },
PackTheme { pack: "coaster-kit", name: "Coaster Kit", themes: &["rollercoaster", "coaster", "theme park", "ride", "fairground", "attraction", "amusement"] },
PackTheme { pack: "cube-pets", name: "Cube Pets", themes: &["animal", "pet", "cute", "creature", "cube", "blocky"] },
PackTheme { pack: "factory-kit", name: "Factory Kit", themes: &["factory", "industrial", "conveyor", "warehouse", "machine", "production", "belt"] },
PackTheme { pack: "fantasy-town-kit", name: "Fantasy Town Kit", themes: &["fantasy", "medieval", "town", "village", "building", "rpg"] },
PackTheme { pack: "food-kit", name: "Food Kit", themes: &["food", "eat", "kitchen", "cooking", "meal", "snack", "something to eat"] },
PackTheme { pack: "furniture-kit", name: "Furniture Kit", themes: &["furniture", "interior", "house", "home", "room", "indoor", "decor"] },
PackTheme { pack: "graveyard-kit", name: "Graveyard Kit", themes: &["graveyard", "halloween", "spooky", "horror", "scary", "monster", "creepy", "ghost"] },
PackTheme { pack: "hexagon-kit", name: "Hexagon Kit", themes: &["hexagon", "hex", "tile", "terrain", "strategy", "board", "modular"] },
PackTheme { pack: "holiday-kit", name: "Holiday Kit", themes: &["christmas", "holiday", "winter", "snow", "festive", "xmas", "cabin"] },
PackTheme { pack: "marble-kit", name: "Marble Kit", themes: &["marble", "track", "ball", "run", "puzzle", "rolling"] },
PackTheme { pack: "mini-arcade", name: "Mini Arcade", themes: &["arcade", "game", "machine", "retro", "play", "cabinet"] },
PackTheme { pack: "mini-arena", name: "Mini Arena", themes: &["arena", "battle", "roman", "fight", "combat", "colosseum"] },
PackTheme { pack: "mini-characters", name: "Mini Characters", themes: &["character", "person", "people", "player", "avatar", "someone to play as"] },
PackTheme { pack: "mini-dungeon", name: "Mini Dungeon", themes: &["dungeon", "rpg", "roguelike", "medieval", "cave", "adventure", "crawl"] },
PackTheme { pack: "mini-forest", name: "Mini Forest", themes: &["forest", "nature", "woods", "camp", "outdoors", "archer", "tent"] },
PackTheme { pack: "mini-market", name: "Mini Market", themes: &["market", "shop", "store", "supermarket", "shopping", "grocery"] },
PackTheme { pack: "mini-skate", name: "Mini Skate", themes: &["skate", "skateboard", "park", "ramp", "street", "trick"] },
PackTheme { pack: "minigolf-kit", name: "Minigolf Kit", themes: &["golf", "minigolf", "course", "putting", "level", "sport"] },
PackTheme { pack: "modular-buildings", name: "Modular Buildings", themes: &["building", "modular", "house", "city", "town", "architecture"] },
PackTheme { pack: "modular-cave-kit", name: "Modular Cave Kit", themes: &["cave", "underground", "modular", "tunnel", "rock", "dungeon"] },
PackTheme { pack: "modular-dungeon-kit", name: "Modular Dungeon Kit", themes: &["dungeon", "modular", "underground", "rpg", "medieval", "tunnel"] },
PackTheme { pack: "modular-space-kit", name: "Modular Space Kit", themes: &["space", "sci-fi", "station", "modular", "future", "spaceship", "corridor"] },
PackTheme { pack: "nature-kit", name: "Nature Kit", themes: &["nature", "outdoors", "forest", "tree", "plant", "scenery", "landscape", "countryside"] },
PackTheme { pack: "pirate-kit", name: "Pirate Kit", themes: &["pirate", "ship", "boat", "island", "sea", "ocean", "treasure", "sailing"] },
PackTheme { pack: "platformer-kit", name: "Platformer Kit", themes: &["platformer", "level", "jumping", "arcade", "obstacle"] },
PackTheme { pack: "prototype-kit", name: "Prototype Kit", themes: &["prototype", "placeholder", "blockout", "greybox", "test", "simple"] },
PackTheme { pack: "racing-kit", name: "Racing Kit", themes: &["racing", "race", "track", "car", "speed", "driving", "circuit"] },
PackTheme { pack: "retro-fantasy-kit", name: "Retro Fantasy Kit", themes: &["retro", "fantasy", "medieval", "town", "castle", "pixel", "old school"] },
PackTheme { pack: "retro-urban-kit", name: "Retro Urban Kit", themes: &["retro", "urban", "city", "street", "old school", "town"] },
PackTheme { pack: "space-kit", name: "Space Kit", themes: &["space", "sci-fi", "future", "planet", "rocket", "spaceship", "astronaut", "alien"] },
PackTheme { pack: "space-station-kit", name: "Space Station Kit", themes: &["space", "station", "sci-fi", "interior", "future", "corridor", "spaceship"] },
PackTheme { pack: "survival-kit", name: "Survival Kit", themes: &["survival", "nature", "camp", "outdoors", "wilderness", "craft"] },
PackTheme { pack: "tower-defense-kit", name: "Tower Defense Kit", themes: &["tower defense", "defense", "castle", "medieval", "strategy", "tower"] },
PackTheme { pack: "toy-car-kit", name: "Toy Car Kit", themes: &["toy", "car", "vehicle", "track", "play", "cute", "something to drive"] },
PackTheme { pack: "train-kit", name: "Train Kit", themes: &["train", "railway", "railroad", "rail", "track", "locomotive", "tram"] },
PackTheme { pack: "watercraft-kit", name: "Watercraft Kit", themes: &["boat", "ship", "water", "sea", "sailing", "vehicle", "ocean", "river"] },
];
pub fn theme_of(pack: &str) -> Option<&'static PackTheme> {
PACK_THEMES.iter().find(|p| p.pack == pack)
}
/// Fallback category for a pack, so the 4400 uncurated models still land
/// somewhere sensible in the category tree (and so the prompt summary counts
/// mean something). Item-level curation overrides this when present.
pub fn default_category(pack: &str) -> Option<&'static str> {
Some(match pack {
"car-kit" | "toy-car-kit" | "racing-kit" | "racing" => "vehicle/ground",
"watercraft-kit" | "pirate-kit" => "vehicle/water",
"train-kit" => "vehicle/rail",
"blocky-characters" | "mini-characters" => "character/player",
"cube-pets" => "character/animal",
"nature-kit" | "mini-forest" | "survival-kit" => "nature/plant",
"food-kit" => "prop/food",
"furniture-kit" => "prop/furniture",
"blaster-kit" => "prop/weapon",
"castle-kit" | "fantasy-town-kit" | "retro-fantasy-kit" | "tower-defense-kit" => {
"building/medieval"
}
"city-kit-commercial" | "city-kit-industrial" | "city-kit-suburban" | "modular-buildings"
| "building-kit" | "retro-urban-kit" | "mini-market" => "building/urban",
"city-kit-roads" | "3d-road-tiles" => "road/street",
"space-kit" | "space-station-kit" | "modular-space-kit" => "sci-fi/space",
"mini-dungeon" | "modular-dungeon-kit" | "modular-cave-kit" => "building/dungeon",
"graveyard-kit" | "holiday-kit" => "prop/decoration",
"platformer-kit" | "platformer" | "marble-kit" | "minigolf-kit" | "coaster-kit"
| "mini-skate" | "hexagon-kit" | "brick-kit" | "prototype-kit" | "factory-kit"
| "mini-arcade" | "mini-arena" | "arena" | "city" | "fps" => "terrain/platform",
_ => return None,
})
}
/// Tokens that carry no meaning in a Kenney filename: variant markers and
/// filler. Stripped so `tree_pineDefaultA` indexes as "tree pine", not as a
/// thing called "default a".
pub fn is_noise_token(t: &str) -> bool {
if t.len() == 1 {
return true; // trailing A/B/C variant letters
}
matches!(
t,
"default" | "type" | "variant" | "alt" | "version" | "new" | "old2" | "obj" | "mesh"
) || (t.starts_with("type") && t[4..].chars().all(|c| c.is_ascii_digit()))
|| t.chars().all(|c| c.is_ascii_digit())
}

View file

@ -1,233 +0,0 @@
//! Animation-state and modular-tile vocabulary.
//!
//! Two problems, one shape: an asset's *usefulness* is described by words that
//! never appear in its filename. A character is useful because it can attack;
//! a road tile is useful because it is a corner. Both facts live in structure
//! the AI cannot see — clip names inside the GLB, and a kit's naming
//! convention — so both are translated into ordinary search keywords here.
/// Query words → the clip-name fragments that satisfy them.
///
/// Clip vocabularies differ per pack (`Running_A` vs `Run` vs `Gallop`), so a
/// game asking for "a character that can run" must match all of them. Matching
/// is case-insensitive substring against each clip name.
pub const STATE_WORDS: &[(&str, &[&str])] = &[
("idle", &["idle", "stand"]),
("walk", &["walk"]),
("run", &["run", "sprint", "gallop", "dash"]),
("sprint", &["sprint", "run"]),
("jump", &["jump", "leap", "hop"]),
("fall", &["fall", "falling", "air"]),
("land", &["land", "jump_land", "landing"]),
("attack", &["attack", "slash", "chop", "stab", "slice", "punch", "kick", "swing", "melee", "headbutt"]),
("punch", &["punch", "unarmed"]),
("kick", &["kick"]),
("shoot", &["shoot", "ranged", "aiming", "throw", "spellcast"]),
("block", &["block", "shield", "defend"]),
("dodge", &["dodge", "roll", "evade"]),
("hurt", &["hit", "hitreact", "recievehit", "receivehit", "damage", "hurt"]),
("die", &["death", "die", "defeat", "dead"]),
("dance", &["dance", "cheer", "celebrate"]),
("sit", &["sit", "sitdown", "chair"]),
("wave", &["wave", "greet", "interact"]),
("swim", &["swim"]),
("fly", &["fly", "flying", "hover"]),
("eat", &["eat", "eating", "graze"]),
("carry", &["carry", "pickup", "pick_up"]),
("climb", &["climb"]),
("sleep", &["sleep", "lie", "rest"]),
// The skeleton packs' 19 extra clips were invisible to the vocabulary
// above: an undead rising from the floor is exactly the thing a game asks
// for by name, and "Skeletons_Awaken_Floor" matched nothing.
("spawn", &["spawn", "awaken", "summon", "emerge"]),
("resurrect", &["resurrect", "revive", "reanimate"]),
("taunt", &["taunt", "provoke", "jeer"]),
("use", &["use_item", "useitem", "interact", "activate"]),
];
/// Modular-tile roles → filename fragments that identify them.
///
/// Grounded in the actual Kenney naming seen in the downloaded kits:
/// `road-bend`, `corridor-intersection`, `building-corner-window`,
/// `block-grass-slope`. Order matters — the more specific variants are tested
/// before the general ones, so `corner-inner` never resolves to `corner`.
pub const TILE_ROLES: &[(&str, &[&str])] = &[
("corner-inner", &["corner-inner", "cornerinner", "inner-corner"]),
("corner-outer", &["corner-outer", "cornerouter", "outer-corner"]),
("junction", &["intersection", "junction", "crossroad", "crossing", "-cross", "split"]),
("corner", &["corner", "bend", "curve", "turn", "-elbow"]),
("end", &["-end", "end-", "cap", "deadend", "stub"]),
("ramp", &["ramp", "slope", "incline", "-hill"]),
("stairs", &["stair", "steps"]),
("door", &["door", "gate", "entrance", "archway"]),
("window", &["window"]),
("bridge", &["bridge"]),
("roof", &["roof", "ceiling"]),
("wall", &["wall", "fence", "railing", "barrier"]),
("pillar", &["pillar", "column", "post"]),
("floor", &["floor", "ground", "tile", "platform", "block-"]),
("straight", &["straight", "-line", "middle", "section", "corridor", "road", "track"]),
];
/// Packs that are MODULAR KITS: their models are designed to snap together on
/// a grid, so they are level vocabulary rather than standalone props.
///
/// Curated rather than inferred. A filename heuristic mislabels both ways —
/// `castle-kit` is named "kit" but is mostly scenery, while `city` genuinely
/// tiles — and 52 packs is few enough that being right matters more than
/// being clever.
pub const KIT_PACKS: &[&str] = &[
"city-kit-roads",
"city-kit-commercial",
"city-kit-suburban",
"city-kit-industrial",
"modular-buildings",
"modular-dungeon-kit",
"modular-cave-kit",
"modular-space-kit",
"hexagon-kit",
"brick-kit",
"building-kit",
"platformer-kit",
"tower-defense-kit",
"mini-dungeon",
"mini-arena",
"retro-urban-kit",
"coaster-kit",
"marble-kit",
"minigolf-kit",
"train-kit",
"racing-kit",
"city",
"arena",
];
pub fn is_kit(pack: &str) -> bool {
KIT_PACKS.contains(&pack)
}
/// Which roles may legitimately connect to which. Data, not prose, because a
/// composition layer consumes it.
///
/// Deliberately coarse: Kenney filenames say what a piece IS, never which of
/// its edges are open, so anything finer would be invented. A layout planner
/// uses this to know a corridor may follow a corner; it must still decide
/// rotation from its own grid.
pub const ROLE_ADJACENCY: &[(&str, &[&str])] = &[
("straight", &["straight", "corner", "junction", "end", "ramp", "door", "bridge"]),
("corner", &["straight", "junction", "corner", "end"]),
("junction", &["straight", "corner", "end", "junction"]),
("end", &["straight", "corner", "junction"]),
("ramp", &["straight", "floor", "stairs"]),
("stairs", &["floor", "straight", "ramp"]),
("floor", &["floor", "wall", "ramp", "stairs", "door"]),
("wall", &["wall", "corner", "door", "window", "pillar"]),
("door", &["wall", "straight", "floor"]),
("window", &["wall"]),
("roof", &["roof", "wall"]),
("pillar", &["wall", "floor"]),
("bridge", &["straight", "end"]),
];
pub fn connects_to(role: &str) -> &'static [&'static str] {
ROLE_ADJACENCY
.iter()
.find(|(r, _)| *r == role)
.map(|(_, v)| *v)
.unwrap_or(&[])
}
/// Composition words a person uses when BUILDING rather than when naming a
/// thing. These are query-side: they expand to role names above.
pub const COMPOSITION_WORDS: &[(&str, &str)] = &[
("road", "straight"),
("track", "straight"),
("path", "straight"),
("straight bit", "straight"),
("corner piece", "corner"),
("bend", "corner"),
("t junction", "junction"),
("crossroads", "junction"),
("wall section", "wall"),
("dungeon floor", "floor"),
("roof piece", "roof"),
("dead end", "end"),
("ramp", "ramp"),
("doorway", "door"),
("slope", "ramp"),
("crossroad", "junction"),
("intersection", "junction"),
("floor tile", "floor"),
("platform", "floor"),
];
/// State keywords implied by a model's clip list, e.g. a model whose clips
/// include `1H_Melee_Attack_Chop` becomes findable as "attack".
pub fn states_from_clips(clips: &[String]) -> Vec<String> {
let lower: Vec<String> = clips.iter().map(|c| c.to_ascii_lowercase()).collect();
let mut out = Vec::new();
for (word, frags) in STATE_WORDS {
if lower
.iter()
.any(|c| frags.iter().any(|f| c.contains(&f.to_ascii_lowercase())))
{
out.push((*word).to_string());
}
}
out
}
/// The tile role a filename implies, if any. First match wins, and the more
/// specific inner/outer corners are tested before plain "corner".
pub fn role_of(stem: &str) -> Option<&'static str> {
let s = stem.to_ascii_lowercase();
for role in ["corner-inner", "corner-outer"] {
if let Some((_, frags)) = TILE_ROLES.iter().find(|(r, _)| *r == role) {
if frags.iter().any(|f| s.contains(f)) {
return Some(role);
}
}
}
TILE_ROLES
.iter()
.find(|(r, frags)| {
!r.starts_with("corner-") && frags.iter().any(|f| s.contains(f))
})
.map(|(r, _)| *r)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clip_vocabularies_differ_per_pack_but_map_to_the_same_words() {
// KayKit names it Running_A, Quaternius names it Run, an animal
// gallops. "run" must find all three or the query is useless.
let kaykit = vec!["Running_A".into(), "1H_Melee_Attack_Chop".into(), "Death_A".into()];
let quaternius = vec!["Run".into(), "Punch".into(), "Death".into()];
let animal = vec!["Gallop".into(), "Attack_Headbutt".into(), "Eating".into()];
for set in [&kaykit, &quaternius, &animal] {
let s = states_from_clips(set);
assert!(s.contains(&"run".to_string()), "no run in {s:?}");
assert!(s.contains(&"attack".to_string()), "no attack in {s:?}");
}
assert!(states_from_clips(&animal).contains(&"eat".to_string()));
assert!(states_from_clips(&kaykit).contains(&"die".to_string()));
}
#[test]
fn a_model_with_no_clips_claims_no_states() {
assert!(states_from_clips(&[]).is_empty());
}
#[test]
fn tile_roles_come_from_kenney_naming() {
assert_eq!(role_of("road-corner"), Some("corner"));
assert_eq!(role_of("road-straight"), Some("straight"));
assert_eq!(role_of("road-intersection"), Some("junction"));
assert_eq!(role_of("wall-doorway"), Some("door"));
assert_eq!(role_of("stairs-corner-inner"), Some("corner-inner"));
assert_eq!(role_of("banner"), None);
}
}

View file

@ -1,411 +0,0 @@
//! Variety and palette selection — turning a search engine into a composition
//! tool.
//!
//! The ranking was never the problem. Asking for "suburban house building"
//! already returns 21 distinct houses at equal score, and "pine tree" returns
//! six distinct pines. The problem was that [`crate::AssetIndex::find`] hands
//! back a ranked list, the caller takes hit #1, and places it N times — so a
//! village came out as five identical houses on five identical lots. With 4400
//! models installed, a scene used about six of them.
//!
//! So the fix is an API that *affords* variety rather than better scoring:
//! ask for N models and get N DIFFERENT ones, or ask for a palette and get a
//! set that visually belongs together.
use crate::{AssetEntry, AssetIndex, Filters, Hit};
/// How different the returned models should be from each other.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Spread {
/// Round-robin across variant families, then within them. Maximum spread
/// of shapes first, filled out with variants of those shapes. Never
/// repeats a model.
///
/// The right default, and it handles both real cases with one rule: a
/// village asking for 5 houses gets `building-type-a..e` (one family, five
/// members), while a forest asking for 5 trees gets pine, oak, palm and
/// two more species before it takes a second pine.
#[default]
Mixed,
/// At most one model per family — maximum shape diversity, fewer results.
/// For when repetition of a silhouette is the thing to avoid.
Kinds,
/// All from the best-matching family: `building-type-a`, `-b`, `-c`. A
/// coherent row of the same kind of thing.
Variants,
}
/// Parameters for a variety query.
#[derive(Clone, Debug)]
pub struct VarietyParams {
pub count: usize,
pub spread: Spread,
/// Seeds the choice WITHIN families, so a re-run of the same game places
/// the same models and two different seeds place different ones. Never the
/// world rng: selection has to be reproducible from (query, seed) alone
/// for multiplayer to replicate a scene without shipping model lists.
pub seed: u64,
pub filters: Filters,
}
impl Default for VarietyParams {
fn default() -> Self {
VarietyParams { count: 5, spread: Spread::Mixed, seed: 0, filters: Filters::default() }
}
}
impl VarietyParams {
pub fn new(count: usize) -> Self {
VarietyParams { count, ..Default::default() }
}
pub fn spread(mut self, s: Spread) -> Self {
self.spread = s;
self
}
pub fn seed(mut self, s: u64) -> Self {
self.seed = s;
self
}
}
/// A coherent set of models drawn from ONE pack, grouped by what each is for.
///
/// This is what a composer actually wants: not "the best house" but "houses,
/// trees, fences and street furniture that look like they come from the same
/// game". Drawing from one pack is what guarantees that — Kenney authors each
/// pack as a matched set.
#[derive(Clone, Debug)]
pub struct Palette {
pub pack: String,
pub name: String,
/// Group name (the family, e.g. "building", "fence") → model ids, most
/// relevant first.
pub groups: Vec<(String, Vec<String>)>,
}
impl Palette {
/// Ids in a named group, empty when the pack has nothing of that sort.
pub fn group(&self, name: &str) -> &[String] {
self.groups
.iter()
.find(|(g, _)| g == name)
.map(|(_, v)| v.as_slice())
.unwrap_or(&[])
}
pub fn total(&self) -> usize {
self.groups.iter().map(|(_, v)| v.len()).sum()
}
}
/// The variant family a model belongs to: its name with variant markers
/// stripped.
///
/// Kenney names variants systematically, but the SAME suffix means different
/// things in different packs — `building-type-a`..`-u` are twenty-one
/// genuinely different house designs, while `tree_blocks` / `tree_blocks_dark`
/// is one tree in two colours. A name cannot tell those apart, and guessing
/// wrong in either direction hurts: collapse too much and a village has one
/// house, collapse too little and a "forest" is five recolours of one trunk.
///
/// So the family is deliberately coarse — it drops only markers that are
/// certainly not descriptive (single letters, digits, "default"/"type") via
/// the same noise rule the indexer uses — and the SELECTION strategy does the
/// rest. `Mixed` draws across families before drawing within one, so it is
/// correct whichever way a pack happens to be named.
pub fn family_of(entry: &AssetEntry) -> String {
let stem = entry.id.rsplit('/').next().unwrap_or(&entry.id);
let tokens: Vec<String> = crate::split_ident(stem)
.into_iter()
.filter(|t| !crate::packs::is_noise_token(t))
.filter(|t| !is_reskin_token(t))
.collect();
if tokens.is_empty() {
stem.to_string()
} else {
tokens.join(" ")
}
}
/// Tokens that mark a RE-SKIN rather than a different shape: colours, seasons
/// and lighting words. `tree_blocks`, `tree_blocks_dark` and `tree_blocks_fall`
/// are one tree in three palettes, and treating them as three "kinds" made a
/// request for six kinds of tree return the same silhouette six times.
///
/// Colour is deliberately included even though "a red car" is a real request —
/// dropping it only affects GROUPING, and `Mixed` still returns the red and
/// blue cars as distinct members of one family. What it prevents is a
/// "variety" of six identical cars in six colours being mistaken for variety.
fn is_reskin_token(t: &str) -> bool {
matches!(
t,
"dark" | "light" | "fall" | "autumn" | "winter" | "summer" | "spring" | "snow"
| "red" | "blue" | "green" | "yellow" | "orange" | "purple" | "pink" | "white"
| "black" | "grey" | "gray" | "brown" | "teal" | "beige" | "tan"
)
}
/// A coarse bucket for palette grouping: the first meaningful token, or the
/// tile role when the pack is a modular kit.
///
/// [`family_of`] is the wrong key here — it is deliberately specific, and on a
/// 300-model pack it produced 167 groups of one id each, which is a listing
/// rather than a palette. A composer wants "buildings", "trees", "fences",
/// not "balcony wall fence".
fn group_of(entry: &AssetEntry) -> String {
if let Some(role) = entry.role {
return role.to_string();
}
crate::split_ident(entry.id.rsplit('/').next().unwrap_or(&entry.id))
.into_iter()
.find(|t| !crate::packs::is_noise_token(t) && !is_reskin_token(t))
.unwrap_or_else(|| "misc".to_string())
}
/// Deterministic per-(seed, id) key. FNV-1a so the same seed always yields the
/// same order and a different seed yields a genuinely different one, without
/// carrying any RNG state around.
fn shuffle_key(seed: u64, id: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ seed.wrapping_mul(0x100_0000_01b3);
for b in id.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x100_0000_01b3);
}
h
}
/// The pack the query most belongs to.
///
/// Only hits at or near the TOP score are considered, and they are summed
/// within that band. Summing across all 60 hits instead — mass rather than
/// quality — let a pack with many weak brushes outrank the pack that actually
/// answered the query: "street light post tall" ranks three `racing-kit`
/// lightPosts at 18, but `nature-kit` has enough incidental "tall" matches to
/// out-sum them, so the preferred pack became nature-kit and asking for a
/// lamp returned a CACTUS. `find` had the right answer all along; only this
/// preference threw it away.
///
/// The band keeps the tie-breaking that summing was for: when two packs both
/// hold top-scoring hits, the one with more of them still wins.
fn dominant_pack(hits: &[Hit<'_>]) -> Option<String> {
let top = hits.first()?.score;
// Generous enough that a pack whose best hit is a point or two behind can
// still win on depth, tight enough that an unrelated pack cannot buy the
// preference with volume.
let floor = top.saturating_sub(top / 4);
let mut packs: Vec<(&str, u32)> = Vec::new();
for h in hits.iter().take(60).filter(|h| h.score >= floor) {
let p = h.entry.pack.as_str();
match packs.iter_mut().find(|(name, _)| *name == p) {
Some((_, s)) => *s += h.score,
None => packs.push((p, h.score)),
}
}
packs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
packs.first().map(|(p, _)| p.to_string())
}
/// Group ranked hits into families, preserving each family's best score and
/// the ranked order of its members.
///
/// Families from the query's dominant pack come first. Without that, asking
/// for five houses returned one house from each of five packs — a suburban
/// house, a hex-tile house, a modular house and a driveway — which is the
/// junk-drawer failure that variety was supposed to fix, arrived at from the
/// opposite direction. Crossing packs is still allowed once the best pack runs
/// out, because a wider net beats returning nothing.
fn group_families<'a>(
hits: &[Hit<'a>],
seed: u64,
prefer_pack: Option<&str>,
) -> Vec<(String, u32, Vec<&'a AssetEntry>)> {
let mut groups: Vec<(String, u32, bool, Vec<&'a AssetEntry>)> = Vec::new();
for h in hits {
let fam = family_of(h.entry);
let native = prefer_pack.is_some_and(|p| p == h.entry.pack);
match groups.iter_mut().find(|(f, _, _, _)| *f == fam) {
Some((_, best, is_native, members)) => {
*best = (*best).max(h.score);
*is_native |= native;
members.push(h.entry);
}
None => groups.push((fam, h.score, native, vec![h.entry])),
}
}
// Dominant pack first, then relevance; members shuffled by seed so a
// different seed picks different members of an equally-good family.
groups.sort_by(|a, b| {
b.2.cmp(&a.2)
.then_with(|| b.1.cmp(&a.1))
.then_with(|| a.0.cmp(&b.0))
});
let mut out: Vec<(String, u32, Vec<&'a AssetEntry>)> = Vec::new();
for (fam, score, _, mut members) in groups {
members.sort_by_key(|e| (shuffle_key(seed, &e.id), e.id.clone()));
out.push((fam, score, members));
}
out
}
impl AssetIndex {
/// Find N *distinct* models for one query.
///
/// This is the call a composer wants and [`AssetIndex::find`] is not: it
/// never returns the same model twice, and by default it spreads across
/// variant families before repeating a shape.
pub fn find_many(&self, query: &str, params: &VarietyParams) -> Vec<&AssetEntry> {
if params.count == 0 {
return Vec::new();
}
// Pull a generous candidate pool: enough to have several families to
// spread across, and cheap because the inverted index already narrowed
// it. 12x is empirical — a village asking for 5 houses wants to see
// all 21 building-types, not the first 5.
let pool = (params.count * 12).clamp(24, 400);
let hits: Vec<Hit> = self
.find_filtered(query, &params.filters)
.into_iter()
.take(pool)
.collect();
if hits.is_empty() {
return Vec::new();
}
let prefer = dominant_pack(&hits);
let mut groups = group_families(&hits, params.seed, prefer.as_deref());
// Variety must not drift off-topic. Round-robin is right when the
// families are kinds of the SAME thing (pine, oak, palm) and wrong
// when they are different things sharing a pack theme: asking for five
// houses returned one house then two driveways and two fences, because
// `city-kit-suburban` themes all of them "house".
//
// Scoring cannot separate those — an exact one-word hit (`tree`)
// outscores a compound sibling (`tree_blocks`) purely for being
// shorter, so a relevance band cuts real variety while keeping the
// drift. What actually distinguishes them is whether the family IS the
// thing asked for: "tree", "tree blocks" and "tree cone" all name a
// tree; "driveway" and "fence" do not name a house.
//
// Applied only when it leaves something, because a functional query
// ("somewhere to hide") names no noun the filenames share, and there
// the ranking's own judgement is all we have.
let named: Vec<(String, u32, Vec<&AssetEntry>)> = {
let terms = crate::tokenize(query);
groups
.iter()
.filter(|(fam, _, _)| {
terms.iter().any(|t| {
fam.split_whitespace()
.any(|w| w == t || crate::stem(t).is_some_and(|s| w == s))
})
})
.cloned()
.collect()
};
if !named.is_empty() {
groups = named;
}
let mut out: Vec<&AssetEntry> = Vec::new();
match params.spread {
Spread::Kinds => {
for (_, _, members) in &groups {
if out.len() >= params.count {
break;
}
if let Some(first) = members.first() {
out.push(first);
}
}
}
Spread::Variants => {
if let Some((_, _, members)) = groups.first() {
out.extend(members.iter().take(params.count));
}
}
Spread::Mixed => {
// Round-robin: one from each family, then a second from each,
// and so on — the widest spread the pool allows before any
// shape repeats.
//
// Run it over the dominant pack FIRST and only spill into
// other packs if that pack cannot fill the count. Breadth
// across packs is not free: five houses drawn from five packs
// are five art styles in one street. `city-kit-suburban` alone
// holds 21 house designs, so a village never needs to leave it.
let native: Vec<_> = groups
.iter()
.filter(|(_, _, m)| m.first().is_some_and(|e| Some(&e.pack) == prefer.as_ref()))
.collect();
let foreign: Vec<_> = groups
.iter()
.filter(|(_, _, m)| m.first().is_none_or(|e| Some(&e.pack) != prefer.as_ref()))
.collect();
for stage in [native, foreign] {
if out.len() >= params.count {
break;
}
let deepest = stage.iter().map(|(_, _, m)| m.len()).max().unwrap_or(0);
'stage: for depth in 0..deepest {
for (_, _, members) in &stage {
if let Some(e) = members.get(depth) {
out.push(e);
if out.len() >= params.count {
break 'stage;
}
}
}
}
}
}
}
out
}
/// A coherent set of models drawn from ONE pack, for building a scene that
/// looks authored rather than assembled from a junk drawer.
///
/// The pack is chosen by which one the query's best hits actually come
/// from, so "a village" lands on a village pack and takes its houses,
/// fences and props together.
pub fn palette(&self, query: &str, seed: u64) -> Option<Palette> {
// Which pack owns this query? Score by summed relevance of its hits,
// not by count, so a pack with two perfect matches beats one with ten
// weak brushes.
let hits = self.find(query);
if hits.is_empty() {
return None;
}
let mut packs: Vec<(&str, u32)> = Vec::new();
for h in hits.iter().take(60) {
let p = h.entry.pack.as_str();
match packs.iter_mut().find(|(name, _)| *name == p) {
Some((_, s)) => *s += h.score,
None => packs.push((p, h.score)),
}
}
packs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let pack = packs.first()?.0.to_string();
// Everything that pack ships, bucketed by family — that IS the palette.
let mut groups: Vec<(String, Vec<String>)> = Vec::new();
let mut members: Vec<&AssetEntry> = self
.entries()
.iter()
.filter(|e| e.pack == pack && e.kind == crate::AssetKind::Model)
.collect();
members.sort_by_key(|e| (shuffle_key(seed, &e.id), e.id.clone()));
for e in members {
let g = group_of(e);
match groups.iter_mut().find(|(name, _)| *name == g) {
Some((_, ids)) => ids.push(e.id.clone()),
None => groups.push((g, vec![e.id.clone()])),
}
}
// Biggest groups first: they are what the pack is mostly about.
groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
let name = crate::packs::theme_of(&pack)
.map(|t| t.name.to_string())
.unwrap_or_else(|| pack.replace('-', " "));
Some(Palette { pack, name, groups })
}
}

Some files were not shown because too many files have changed in this diff Show more