Commit graph

640 commits

Author SHA1 Message Date
ec2c0aaf15 feat(widgets): reexport optional Makepad sibling crates
Adds feature-gated optional deps and re-exports (makepad-test, makepad-csg,
makepad-gltf, makepad-mbtile-reader, makepad-fast-inflate) so a downstream
workspace can depend on makepad-widgets as its sole Makepad source.
2026-08-16 14:38:41 +03:00
Jason Yau
41b41f1d11
Drain platform_ops FIFO so host commands run in enqueue order. (#1183)
Vec::pop inverted CreateWindow/prepare/IME sequences; VecDeque pop_front matches causal order, and SetTopmost defer no longer livelocks on an empty Windows queue.

Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-08-15 20:34:16 +02:00
Kevin Boos
e0a5a23f2f
Splash improvements for running untrusted mini-apps (#1139)
* fix a pile of splash script-vm bugs: newline statements, short-circuit args, tail calls

went through the script VM and parser and fixed a batch of correctness bugs
that were biting the launcher's mini-apps:

- newline-delimited statements: a `(` or `[` at the start of the next line no
  longer greedily glues onto the previous value as a call/index. leading infix
  operators and `.` still continue the expression (the shader DSL needs that),
  and the divert is suppressed inside ()/[] groupings.
- short-circuit `&&`/`||` used as a call argument no longer loses its value to
  nil when the jump skips a multi-op right-hand side.
- a call as the very last statement of a script actually executes now, in both
  end-of-parse unwind loops (also patched a zero-offset ShortCircuitEnd).
- custom widgets that deref to a base with a #[source] field now forward
  script_source, so script_apply_eval works on them instead of silently no-op'ing.

plus regression tests for the newline and short-circuit cases.

* harden splash isolates: scoped timers, net gating, effective-visibility snapshots

isolate-safety work so mini-apps can't reach outside their sandbox:

- isolate-safe script-timer dispatch hook + gc for stale timers
- gate net.socket_stream on the net runtime being present
- widget-tree snapshot reports effective visibility (a widget counts as hidden
  if any ancestor is hidden)
- macos_activate_app (plus a headless no-op) so the launcher can focus itself

* widen the host->splash surface: splash setters, view/glassbutton script calls

everything the host needs to poke into a running mini-app's script:

- Splash: call_script_fn, set_script_global, set_allow_net, and a cached body id
  so host->script calls don't rescan for the body every time
- View.set_visible and GlassButton set_text/text are callable from script now
- makepad_test learned right-click (secondary button) so the headless tests can
  exercise long-press / context menus

* fix small-size glass lens + sdf box degeneration, warn on missing glyphs

visual correctness fixes we kept tripping over:

- cap the gauss lens band at 35% of the surface's smaller side so tiny discs
  degrade gracefully instead of smearing
- clamp the Sdf2d.box (and box_x/box_y/box_all) radius so an oversized radius
  saturates at a circle instead of collapsing into a rotated diamond
- log once per codepoint when no loaded font can render it (was silently
  drawing .notdef boxes)

* guard stale rect areas in clipped_rect/abs_to_rel/set_rect against out-of-bounds panics

* add switch_finger_capture to hand a live finger capture between widgets mid-drag

* add promote_finger_capture_over: hand a child-grabbed finger up to a co-capturing container

* splash: add validate_splash_body, a dry-run eval for externally-sourced scripts

evaluates a body in a throwaway isolate with the exact prefix/limits the
Splash widget uses and returns the captured script errors instead of logging
them. lets hosts that install source from outside (downloads, AI generation,
user input) reject bad scripts with real errors to show or feed back, where
the widget's own eval silently keeps the old view.

* strip mod.res from splash isolates; document validate_splash_body caveats

the res module's handles reach both the filesystem (abs_path loads) and the
network (web_url / http resources) without going through the gated net
runtime, so a 'no-net' isolate could still fetch and exfiltrate. found by an
adversarial review of AI-generated app installs, but it applies to any
untrusted splash source.

also note on validate_splash_body that the instruction limit bounds compute,
not heap growth, and that top-level timers live until isolate reclamation --
same exposure as actually installing the source, so validation adds nothing
new.

* splash: jailed per-app file storage (mod.fs inside isolates)

mini-apps get an OS-style private data directory, like an android app's
internal storage or an iOS container: the app sees a filesystem rooted at
"/", and that root IS its host-assigned sandbox directory
(Splash::set_sandbox_dir / SplashRef forwarder). registered as mod.fs in
isolates -- deliberately shadowing the stripped real fs module, so inside
an app "the filesystem" simply is the jail:

  fs.read fs.write fs.append fs.exists fs.remove fs.mkdir fs.list

containment lives entirely in the host layer:
- lexical path resolution against the root; `..` above the root, NUL, deep
  or overlong paths are errors before any I/O
- the per-VM root is rust state keyed by the isolate's heap -- script code
  can neither read nor retarget it
- symlink defense in depth: nothing here can create links, and every
  existing component under the root is verified non-symlink before use
- quotas: 1MB/file, 16MB/jail, 256 entries
- no root assigned (previews) -> every call errors cleanly

validate_splash_body gives dry runs a throwaway jail (temp dir, removed
after) so top-level fs.read boot loads validate instead of erroring. roots
are dropped with their isolates in the gc.

unit tests cover the containment: traversal/absolute/backslash escapes,
depth/name caps, and the symlink block.

* splash: put the jailed fs module in scope as a bare name

app scripts say fs.read("/x"), but the eval prefix only used the widgets
prelude, so bare fs resolved to a not-found error value and every storage
call failed silently. bind it in the prefix (let fs = mod.fs) for both the
plain and net variants; a script reassigning fs only shadows its own name,
the jail stays host-side.

* splash storage: quota + boundary hardening from adversarial review

three confirmed jail findings:
- mkdir bypassed every quota (target + create_dir_all, no jail_usage check)
  -> unbounded inode/dir-metadata exhaustion on the shared host volume.
  now charges new dirs against MAX_ENTRIES via missing_entries(); write's
  entry check does the same so a deep write can't overshoot the cap either.
- write/append/mkdir lacked remove's root guard: fs.write("/", data)
  resolved real == root and reached create_dir_all(root.parent()) -- one
  dir above the jail (the shared app_data/). now rejected like remove does.
- validate_splash_body's scratch jail used a predictable temp name created
  with create_dir_all (would follow a planted symlink out of temp). now an
  exclusive create_dir on a per-process+vm name (EEXIST-safe against a
  planted entry), reclaimed via gc before the dir is removed so a top-level
  timer can't resurrect it.

unit tests added for missing_entries; the containment tests still pass.

* splash: empty set_text tears down the isolate instead of no-oping

set_text("") was a silent no-op (eval_body early-returns on an empty body),
so a reused Splash that goes back to empty -- the widget-gallery live preview
on Back -- left its old isolate running its timers (and holding a storage-jail
binding) behind a blank view. now an empty body reclaims the isolate: the
isolate-minted view is replaced with a fresh empty one built in the main vm
BEFORE the isolate heap is freed, then the isolate is gc'd (stopping its
timers, dropping its jail root); vm_id resets to MAIN so a later non-empty
set_text allocs a fresh isolate as before. the existing host_launcher
teardown call sites (widget picker back()/reset()) become correct unchanged.

* overlay: composite glass in draw order, not creation order

every gauss/glass surface opens its own draw list and registers it in the
window's single Overlay via store_sub_list, which hands out the first free
slot and keeps it for the life of the process. renderers walk that table in
index order, so the paint order of all glass in an app was the order the
surfaces were first *created* — permanently, with freed slots reused by
whatever registered next. draw order never came into it, so a widget rebuilt
after a layout change, or a panel opened later, could land on top of anything
drawn after it. the only workarounds available to apps were "don't draw the
thing that's winning", which looks like a bug.

the hook for fixing it was already there and unused: CxDrawList's
draw_item_reorder, honoured by every backend (metal, d3d11, opengl, vulkan,
web_gl, headless raster). so stamp each overlay sub-list with the position it
was begun in this frame (Cx2d::overlay_seq, reset in Overlay::begin) and have
Overlay::end stable-sort the table by that stamp.

this also gets parent-then-child right without special cases, which matters
because glass.GlassButton / glass.GlassSegmented call begin_overlay_reuse
unconditionally instead of checking is_drawing_overlay(), so they hold their
own slots rather than riding their parent's.

* glass.GlassSegmented: size segments to their labels, add set_selected

three things, all of them things that looked broken to a user:

- segments were width/count, so "Max" got the same room as "Default": the long
  word crowded, the short one floated. each segment is now measured (DrawText
  layout size_in_lpxs) and gets its text plus padding, with leftover width
  shared equally so every label keeps the same margin. if the labels don't fit,
  the padding shrinks (never the text) to a floor. the pill's x/width are
  computed in rust and passed as uniforms since they can't come from a segment
  count any more, and hit-testing is a boundary lookup rather than a division.

- `selected` was public but the pill is drawn from a private sel_pos that only
  followed it via the click animation, so restoring a saved value from code
  left the control showing one segment while reporting another — and a click on
  the segment it really held was then ignored as "already selected". that reads
  as the control eating your clicks. set_selected keeps both in step.

- the travel easing was 0.30, which arrived before the eye could follow it.
  0.16.

* text_input: re-layout when max_lines changes

the laidout text was cached on width alone, so flipping draw_text.max_lines
at runtime (collapsing a composer to one line) kept the old multi-row layout
and the field never shrank. make max_lines part of the cache key.

* text_input: add set_max_lines instead of making callers script it

applying script to a TextInput re-applies its #[live] fields, and text is
one of them, so toggling max_lines through script_apply_eval! silently
wiped whatever the user had typed. give it a typed setter.

* text_input: don't drop the layout in set_max_lines

clearing laidout_text there leaves the field with no layout for the rest
of the event batch, so every cursor op in that window bails out with
"can't move cursor because layout was invalidated by an earlier event".
since set_max_lines gets called from focus/blur handling, that window is
exactly when you're clicking into the field — so the click placed no
caret at all. max_lines is already part of the layout cache key, so the
next draw re-lays out on its own.

* text_input: add scroll_to_top

for a field that folds to a fixed height when it loses focus: the scroll
offset survives the blur, so a draft last edited near its end folds
showing whichever line the caret had scrolled to rather than its first.
leaves laidout_text alone — scrolling doesn't change the layout, and
dropping it would break every cursor op for the rest of the event batch,
same trap as set_max_lines.

* text_input: add set_height

for a composer that folds to one line when it loses focus. pinning the
height is the safe way to fold — unlike clamping max_lines it leaves the
laid-out text alone, and the laid-out text is what maps a click to a
caret position. fold by re-layout and the press that re-focuses the
field resolves against the folded layout while the expanded one is on
screen, so the caret and any drag-selection land on the wrong text.

* text_input: add take_key_focus, which actually shows the caret

the caret draws as (1.0 - blink) * focus, and both come from animators
that only move when the widget is dealt a Hit::KeyFocus. setting key
focus on a field that ALREADY holds it dispatches no hit — so a field
that was focused, then hidden (hiding doesn't clear Cx's key focus) and
shown again comes back typable but with no caret and no selection
highlight, animators still parked where the last focus-lost left them.

plays focus.on unconditionally rather than only when focus changed:
repairing the case where it did NOT change is the entire point.

* splash: name scripts in errors, and stop using line as an identity slot

a runtime error from a Splash app logged `:1804943384:12 - widget has
no uid`: empty file, and a "line" that is really a pointer address.
the format is {file}:{line}:{col}, and both fields were casualties of
the same hack — ScriptMod.line carried self_id so the body could be
found again (m.line == self_id && m.file.is_empty()), while ip_to_loc
adds that same field to the script's real line when reporting. so every
location came out as real_line + a pointer, and nothing said WHICH app.

identity moves to module_path, which nothing else reads for these
bodies, freeing line to be a line. file gets a real name via a new
set_debug_name the host calls with the mini-app's id.

the validator's ScriptMod gets the same treatment — its errors are
shown to the user AND fed back to the agent as repair input, so a
location offset by a vm id was actively misleading there.

* splash: document the constant offset in reported script lines

the host prefix is two lines, so a reported line is two ahead of the
app's own file. it can't be zero — a zero-line prefix would share line 1
with the app's first line, and that line is always the // name: header,
which would comment the prefix out.

* script: stop silently losing widgets emitted from branches and loops

Splash mini-apps kept rendering nothing from on_render closures with zero
errors logged. Bisected live and in pure-VM probes, this was a pile of
separate bugs in the same corner:

- an if/else whose branch emits a widget lost the taken true branch: the
  statement's POP_TO_ME got fused onto the else tail, which the IF_ELSE
  jump skips. Generalized last_short_circuit_target into last_jump_target
  and record every branch join (if/elif/else, match, try/err), so the
  commit lands standalone AT the join and every path runs it. Same disease
  as the short-circuit-argument bug, new jump sites.
- elif never patched its arm's IF_ELSE jump (relative 0), so a taken arm
  spun the interpreter until the instruction limit killed the whole entry.
  elif now desugars into else { if ... } via IfElseExpr, which also gives
  its arms the join treatment.
- for x in <non-iterable> silently skipped the body where the equivalent
  while errored; now raises "for loop source is not iterable" (nil and
  empty sources stay silent). for k v in <number> passed key/index swapped
  and lost k after the first iteration (the advance only rebound v).
- a line-leading { after a value-ending line glued onto the previous
  expression as a proto instantiation; it now starts a new statement,
  same divert rule the ( and [ newline fix added.
- int literals were second-class numbers: U40/I32/F32/F16/U32 didn't
  collapse to the number bucket in to_redux, so 6 .is_number() missed
  method dispatch entirely and an int arg against a float default failed
  with "expected number, got number". They're all just numbers now.

Regression tests in tests/on_render_emission.rs cover each shape, incl.
the exact calendar/weather patterns that were blank in the launcher.

* splash: keep an on_render closure's final widget, and say when a render fails

Two host-side halves of the emission-loss story:

- the parser turns a closure's last statement into its return value, so a
  render closure that ENDS with a widget literal built it and then threw it
  away (this is why wrapping a whole render in one extra View{} produced
  nothing). script_result now pushes a returned widget object into me as
  the last child; non-widget returns still get skipped downstream.
- a render closure that errors mid-run used to have its output discarded
  with no diagnostics at all, which is what made this whole bug family
  cost days to find. Now it logs the error before dropping the result.

* script: auto-close still-open fn and let states at end of source

Both parse drivers dropped EndFnExpr/EndFnBlock/EmitLetDyn through their
auto-close catch-all when the source ended with them still open. A module
whose FINAL statement was let c = <lambda> got a body whose jump-over
stayed 0 — FN_BODY_DYN re-ran, found its me already popped, logged
"me stack is empty" and fell straight INTO the body, running it inline
at definition time and ending the module eval early — and the let itself
never emitted, so the binding silently didn't exist (same for
let c = <call>). Now those states close the way the live handlers do:
return + jump patch for the body, LET_DYN/LET_TYPED for the binding, and
the let's own EndStmt no longer marks a statement value (LET consumed
it; the final RETURN would pop an empty stack).

Fun consequence: the old test idiom of reading a result via
  let out = r
  out
only ever worked BECAUSE the trailing let was dropped — RETURN popped
the naked value off the stack. With the let actually binding, scripts
must end on a call (echo(r)); the emission tests are updated to do that.

Regression tests in tests/auto_close_eof.rs, including the exact
deferred-boot-timer closure shape the launcher apps use (which was
already fine — it just LOOKED guilty, see the splash commit).

* splash: probe optional script hooks without spamming the error log

call_script_fn checks whether the fn exists and bails quietly — but it
probed with a trapping scope_value, which had already queued a NotFound
by the time the miss was handled. Every host broadcast of an optional
hook (on_app_resize, on_widget_resize) against a script that doesn't
define it logged
  variable 00001e93e419c77c not found in scope
— maximally misleading: the hex is just id!(on_app_resize) (Rust-side
ids aren't in the reverse-lookup table so they print raw), and the
line:col is the stale ip from the end of that script's eval, which
pointed at whatever closure happened to be compiled last. In the
launcher that was the boot-timer line of every generated app, sending
the investigation down a deferred-closure rabbit hole the pure-VM tests
then cleared. Probe with NoTrap.

* script derive: don't name the eval values vec 'v'

script_apply_eval!'s generated values block bound 'let mut v' and then
spliced #(expr) interpolations in verbatim — so a caller interpolating a
variable that happened to be called v got the macro's own half-built Vec
(borrow errors if you were lucky, the wrong value if not). Obscure name
instead.

* headless: don't compile the Apple video path

Upstream's zero-copy video work put CoreVideo/Metal code in
gpu_texture.rs (plus two consumers) behind cfg(target_os = "macos")
alone. Under cfg(headless) the apple backend isn't built at all, so
every one of those symbols — ObjcId, msg_send!, CVPixelBufferRef,
CVMetalTextureCache* — is missing and makepad-platform fails to
compile with 94 errors. That takes the headless harness down with it,
which is what host_launcher's UI tests run on.

Gate the Apple blocks on not(headless) too. Nothing is lost: headless
has no Metal device to import a CVPixelBuffer into, so the whole path
is inapplicable there.

Not caused by the rebase — pristine dev has it: its headless
CxOsTexture is an empty struct while gpu_texture.rs reads .os.texture.
2026-08-12 01:55:46 +02:00
Kevin Boos
c2d3b019b4
PortalList: make scroll-to-end reach the end, animate, and resume auto-tailing (#1172)
* PortalList: make `smooth_scroll_to_end` actually reach the end, smoothly

Two separate bugs made robrix's jump-to-bottom button unreliable.

* `smooth_scroll_to_end` multiplied the caller's speed by `range_end`, but that
  value is the per-frame pixel delta handed to `delta_top_scroll`. On a list of
  a few hundred items it became tens of thousands of pixels per frame, so the
  scroll completed in a single frame and read as a hard jump. The longer the
  list, the more instant it got. Pass the speed through unchanged; the
  `max_items_to_show` teleport already bounds how far the animation has to run.

* `smooth_scroll_to` treated "the target's top is somewhere in the viewport" as
  already arrived, and returned after emitting `SmoothScrollReached` without
  scrolling. When the target is the last item that is wrong: a tall final item
  can have its top on screen with most of it below the fold, so the button did
  nothing or stopped part way. Targeting the last item now settles only once
  `at_end` is true, which is the same boundary condition the in-flight
  termination check already used.

* PortalList: don't let a press or a jump-to-end silently stop tailing

`auto_tail` pins the list to the end via `tail_range`, and every scroll path
that clears it also sets `detect_tail_in_draw` so the next draw can turn it
back on if we're still at the end. Two paths cleared it with no way back.

* `Hit::FingerDown` cleared `tail_range` and never armed the re-detect, so any
  press on the list stopped tailing for good, including the press that begins a
  drag that ends back at the bottom. Clicking a message was enough.
* `smooth_scroll_to_end` left tailing off at the end of its own animation, so
  the jump-to-bottom button parked the user at the bottom without resuming
  tracking. Since the button is only shown when not at the end, this was a
  reliable way to end up bottom-flush and no longer tailing.

* PortalList: actually animate `smooth_scroll_to_end` from anywhere in the list

It only looked smooth when you were already near the bottom. `smooth_scroll_to`
teleports its anchor to within `max_items_to_show` (20 by default) of the target
before animating, so from further up, most of the travel happened in one frame
and only the last few items were ever animated.

* Pass an unbounded window from `smooth_scroll_to_end`, so the anchor isn't
  teleported and the whole distance is animated.
* Scale the per-frame delta to the remaining distance so the animation takes the
  same time whatever the list length, instead of crawling at a fixed rate.
* Target `range_end - 1`. `range_end` is exclusive, so scroll-to-end was naming
  an index with no item behind it, which left `at_end` as the only thing that
  could end the animation.

* PortalList: resume tailing when a scroll-to-end finishes, not when it starts

`smooth_scroll_to_end` armed `detect_tail_in_draw` up front, but the draw that
consumes it happens on the next frame, while the animation is still running and
`at_end` is still false. The arm was therefore always spent before we arrived,
so jump-to-bottom left the list at the end without tailing and the next message
wasn't revealed. Manually scrolling down worked because those paths arm the flag
on a frame that is already at the end.

Arm it where the scroll actually lands instead: both when `ScrollingTo`
terminates and on the immediate-settle path, in each case only when the target
was the last item.
2026-08-11 23:55:24 +02:00
Kevin Boos
dd6498a459
Add Event::ClearHover, and expose the pointer's claim and capture areas (#1170)
* platform: expose the capturing area of a touch by uid

Raw `Event::LongPress` handlers (it has no `handled` field and is
broadcast to every widget) had no way to tell whether the press was
captured by an unrelated widget, so a geometric containment check
alone could steal a press that e.g. a dock `Splitter` owns.

* add `CxFingers::touch_capture_area(uid)`, mirroring the existing
  public `is_area_captured`

* platform+widgets: add `Event::ClearHover` to reset stale hover state

An overlay (context menu, modal) claims all pointer hits while open, so
widgets that were hovered or pressed when it appeared never receive the
`FingerHoverOut`/`FingerUp` that would clear their visuals: makepad's
hover slot holds exactly one area, and once a widget loses it, nothing
is ever delivered to it again. Apps had no way to reset widgets they
don't own.

* `Cx::queue_clear_hover()` requests a one-shot `Event::ClearHover`
  dispatch after the current event and its follow-up actions finish
  (drained in `call_event_handler`, mirroring
  `handle_pending_window_geom_changes`)
* `View` plays `hover.off`/`down.off` on it when its animator is
  defined, `Button` cuts `hover.off`, `HtmlLink` cuts its link hover,
  and `CalloutTooltip` hides itself
* overlays call `cx.queue_clear_hover()` when they close

* platform: rename `queue_clear_hover` to `clear_all_hovers`

Name the effect rather than the mechanism, matching `set_key_focus`
and friends, which also defer their commit internally.

* platform: expose `Event::pointer_claimed_area` for claim bracketing

A widget cannot tell whether the area that claimed a pointer event
belongs to one of its own descendants or to an unrelated widget drawn
on top of it; geometric containment is a false proxy (a floating
overlay's rect can sit entirely inside a larger widget's rect).

Dispatch order answers it exactly: dispatch IS the widget tree
traversal, and claims are first-wins, so a claim that appears between
a widget's own `handle_event` entry and its end was made by that
widget or a descendant, while a pre-existing claim belongs to a widget
drawn above it.

* add `Event::pointer_claimed_area()`, reading the claim cell of
  MouseMove/MouseDown/TouchUpdate(Start); snapshot it at dispatch
  entry and compare after
2026-08-11 20:19:29 +02:00
Jason Yau
947f815181
Video playback fixes and improvements (#1155)
* android oes video zero-copy and gles shader fixes

* regenerate windows-rs by windows-strip

* fix windows video freezes with MF on an MTA worker

* regenerate windows-rs by windows-strip

* MTA MF video, COM notify, YUV texture reuse

* add local video file playback support to video-player example

* NV12 Metal present, seek warm-up gate, YUV full range

* fix Linux GStreamer video: A/V mute, HLS prepare, async teardown

* no-op SelectVideoTrack/SelectAudioTrack on non-Linux backends

* Linux ALSA/Pulse: bigger buffers, fix resample setup, report device rate

* fix some warnings

* Move XInput/DirectInput device discovery off the UI thread

* fix GLSL unpack4u8 for GLES 3.0 (#version 300 es)

* Linux GStreamer: DMA-Buf NV12 OES zero-copy, optional GLMemory path

* Linux MediaPlugin: DMA-Buf NV12 and GLMemory zero-copy present APIs

* Add Windows SourceReader DXGI NV12 zero-copy video path

* fix build error

* fix GLES: add highp precision for sampler2DArray in Linux shaders

* playback speed support for android

---------

Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
Co-authored-by: jasonqiu <jasonqiu@futunn.com>
2026-08-10 12:29:33 +02:00
Kevin Boos
ed3eb88d4a
TextFlow: fix max_lines clamping and row alignment for wrapped text with inline widgets (#1160)
* Windows: fix DPI-change problems: freezing, blank windows, drag-n-drop errors

- win32: do_callback queues re-entrant events (WM_DPICHANGED pumped inside
  Present, nested WM_SIZE) instead of dropping them; the drops left dpi/geometry
  stale, the window blank, and hit-testing offset after display-scale changes.
- win32: WM_DPICHANGED applies the OS-suggested rect and returns 0; removed the
  duplicate-delivery compensations the lossless queue obsoletes.
- d3d11: no unbounded Present(1) block after a timed-out frame-latency wait
  (DO_NOT_WAIT + paced retry); DXGI errors log instead of panicking.
- dnd: Drag/Drop events carry WindowId and are remapped via dpi_override_scale,
  fixing drop-zone offset under UI zoom (Windows OLE, macOS external drops,
  Wayland internal drags).
- win32: packaged-build window icons load at native shell sizes via LoadImageW.

* TextFlow: enforce `max_lines` for inline code wraps, list items, and inline widgets

`max_lines` + `text_overflow: Ellipsis` under-counted or skipped visual lines
whenever anything but a plain text run started one, so rich content (rooms-list
previews, anything with inline `<code>` or mention pills) could overflow its
line budget or hard-clip without an ellipsis.

* TextFlow: the inline `<code>` forced wrap left `is_continuation` stale, so a
  wrapped code run was granted one extra row AND reported one fewer — a 2-line
  overflow per wrap. The wrap now refreshes the continuation state and row
  budget, is refused when no line remains, and its prediction probes measure
  an unclamped layout (a clamped probe always claimed "fits on one line").
* Layouter: a non-wrapping layout truncated by `max_lines` never reported
  `is_truncated` (detection was row-count only), so `max_lines: 1` labels
  hard-clipped mid-word instead of showing "…". Width overflow on the
  surviving last row now also triggers the ellipsis.
* TextFlow: list items charged two lines each (marker run + content run); the
  marker now shares one visual line with the item's first content run.
* Inline child widgets (mention pills, images) bypassed the budget entirely:
  Html now gates them on `is_content_truncated()`, charges the rows they open
  via `track_inline_content()`, and holds a widget that lands on the last
  allowed line in place (`Turtle::set_flow_wrap`) instead of letting the
  turtle relocate it onto a row the budget can't pay for. A held widget that
  still overruns the line is hidden behind a tracked clip and replaced with a
  drawn "…" — the same contract text truncation has.

* TextFlow: fix row alignment and spacing for wrapped text beside inline widgets

On the single-batch text path (Windows/Linux), a multi-row text run never told
the turtle where its internal row boundaries were: every walk since the last
boundary — a bold sender on one visual row, a mention pill on the next — was
centered by `finish_row` against one merged row. Text sat a few px off the
pill baselines, rows around wrapped runs squeezed or overlapped, and an
up-centered pill on a padding-less first row rose above the clip and lost the
top of its rounded corners.

* DrawText: the single-walk path now allocates per visual row and runs
  `turtle_new_line_with_spacing` at each internal boundary, keeping one glyph
  instance batch. It emits a first-row walk owning the run's align entries and
  a last-row walk with an empty range, so the run is never shifted twice.
* Turtle: `FinishedWalk` carries a `RowAlignRole`. A wrapped run's rows are
  immovable (their glyphs share one batch): rows holding its visible text are
  an `Anchor` — `finish_row_center` centers every shiftable walk on the
  anchor's line, shifting a taller pill UP onto the text — while a
  whitespace-only first row (a continuation that wrapped on a leading space)
  is `Fixed`, so it cannot anchor a row to an invisible line. Anchor shifts
  are clamped so no walk's top can rise above the turtle's clip.
* Layouter: `first_row_min_line_spacing_below_in_lpxs` is now honored (it was
  stored and hashed but never read). Resumable draws pass the current row's
  height plus wrap spacing plus the centering overhang, so a continuation's
  second row lands where centered content beside it starts exactly one wrap
  gap below the previous row — zero residual shift for uniform-height pills.
  Selection capture and the `<code>` wrap probe pass the same floor so every
  layout of a run shares one cache entry.
* Turtle: an anchored row returns its bottom forgiveness — the surplus its
  allocation extends below the risen content — and `turtle_new_line`
  subtracts it, keeping inter-row gaps uniform on both sides of anchored
  rows. A turtle's final row keeps its full extent so nothing clips at the
  bottom.

* uizoo: Html fixtures for line clamping and pill/text row alignment

Repro and regression fixtures mirroring a chat client's message surfaces:
max_lines clamping with inline <code> at several widths, atomic inline-widget
(pill) relocation/hold/ellipsis under a line budget, and timeline-style
pill+text rows at 9.3pt and 11pt metrics — including a CJK-titled pill and a
padding-0 first-row case that guards against clipped pill tops.

* Layouter: fill the last permitted row by grapheme when ellipsizing

When `max_rows` is set together with ellipsis truncation, word wrapping
would move a word that didn't fit off the final permitted row before
truncation ran, leaving that row ellipsized at the last word boundary
("@…") rather than at the last glyph that fits ("@quokka:…").

Word integrity is meaningless on a row that ends in an ellipsis, so lay
the final permitted row out by grapheme and let truncation cut at the
actual width limit.

* DrawText: account for walk margins when resolving a Fit max bound

The layout bound for a Fit-width walk with a relative max bound was the
raw resolved max, but the turtle's final width — and with it the clip
rect — gets clamped to that max minus the walk's outer margins. On top
of that, a Label passes the same margined walk to its inner DrawText,
re-applying those margins inside the turtle. Text laid out near the
bound therefore extended past the clamped clip, which sliced letters
off the end of untruncated text and cut the appended ellipsis down to
a single dot.

Subtract the walk's own horizontal margins from the resolved bound, and
when the enclosing turtle is itself an unresolved Fit with a max bound
(a Label sizing itself around this text), subtract that turtle's outer
margins too, so the layout bound matches the width the clip is actually
clamped to.

* uizoo: fixture for bounded pill labels ellipsizing at the width cap

Pill-like labels bounded to a fraction of the enclosing width, at
container widths that walk across the cap, plus one inline in an Html
flow. Truncation must always end in a visible trailing ellipsis, never
a bare mid-glyph cut.
2026-08-05 19:35:06 +02:00
Admin
51cf444bf2 bake: ungate the shadow sweep — sink-gated, the baker captured deck-only shadow sets, invisible while the sig bug masked it; fingerprint now also hashes shadow shape/point counts so signature-neutral capture bugs change it
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:45:44 +02:00
Admin
f883c689dd The three fixes the AMS single-tile test demanded: shadow-sig sink filter (baker stored a jobs-less signature no runtime could match — shadow bake was dead weight), transmux merges below-z14 duplicate border tiles (protobuf layer-union; first-wins blanked every cell boundary), mapfleet --rebake-from + stale-field strip (rebake straight from baked cells, no respool)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 09:56:05 +02:00
Admin
0e20c4e2e7 bake: NaN-height guards (nan-tagged buildings panicked 3 cells) + bounded reorder window + MAPBAKE_TRACE
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 09:25:48 +02:00
Admin
0ebe270c20 Script VM GC campaign: isolate GC, host-transient release, cross-VM resource handle fix
- Escape barrier: every checked store funnel tags stored objects REFFED
  (stored => REFFED structural), ret==args guards at native completion,
  fn-arg bind-time barrier (closure-captured scopes retained args untagged
  -> latent use-after-free under eager release)
- vm.release_transient() for host per-call objects; *_unchecked pushes are
  the releasable-container path; call_with_scope native branch no longer
  leaks one scope object per Rust->native vm.call()
- pump_widget_async: dead-isolate reclaim every pump + needs_gc-gated
  round-robin mark/sweep — isolate heaps were never collected before
- res.rs: resource path cache was Cx-global and cached handle VALUES across
  VMs, so isolate heaps held main-heap handle indices (exposed by the first
  isolate mark pass ever to run). Now: data shared globally, handles minted
  per-heap, cache keyed (heap_key, path), per-heap detach on GC
- vm.gc() no longer shrink_to_fits every run; explicit gc_and_compact()
- script-test: GC campaign suite (flat-heap tick loops, retained-input
  survival, escaped-scope capture, isolate churn collection)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 22:27:27 +02:00
Admin
b30ff3ede3 Fleet compression architecture: q2 throwaway slices, workers deliver uniform q11
map_bake --recompress re-encodes EVERY tile (both pass-through sites) at
the target quality — compression parallelizes across the whole fleet and
the final dataset is uniformly q11. The slicer ships q2 intermediates
(fast, disk-lean). Bake mode also skips runtime-only work per tile:
building/tree vertex extrusion and POI point parsing never reach the
sink and no longer run on workers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 18:42:21 +02:00
Admin
0a0ad2e65a Map: plain alpha fade restored; established-3D arrivals snap (no fade, no grow)
The dithered dissolve traded one artifact for another (user call): the
real fix is not fading at all in an already-3D scene — fades stay for 2D
and the single flat->3D mode reveal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:44 +02:00
Admin
f4af4db88d Map: growing-archive watcher — the app reloads as the world spiral weaves cells in
5s mtime watch on the active archive (root.mkidx for mkmap); on change,
Failed placeholders clear and the visible loop re-requests. Workers
already reopen per batch, so an atomic shard-set swap appears live —
apps/route now points at world.mkmap and starts empty until cell-001
(NL) lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:44 +02:00
Admin
152b936084 Map: tile crossfade = ordered-dither dissolve — kills the arrival flash
Alpha-multiplying opaque 3D through a depth-writing fade let the clear
color bleed through for 0.25s per arriving tile. Surviving fragments now
stay fully opaque; a 4x4 Bayer threshold ramps coverage instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:44 +02:00
Admin
41c517e4b5 Map: grow-heights plays only on the flat->3D mode reveal — established 3D never re-pops
Fresh/evicted/rebucketed tiles arriving into an already-3D scene were
replaying the pop-up (the zoom-crossing flash).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:44 +02:00
Admin
cf6e6c5025 Map: road-icon append test updated for packed buffers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:44 +02:00
Admin
1ed8334e4d Map: dissolve eligibility caps monster same-height sets — jetsam-proof bake
Tile ~22000 (Westland greenhouse belt) put thousands of identical-height
rings in one union and the memory spike got the bake SIGKILLed at the
same tile every run. Groups over 800 jobs / 120k ring points stay
per-building; the cap is computed identically at bake and runtime so
signatures agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:44 +02:00
Admin
09bbb33616 Map: dissolve fix — one group per connected shape, winding matched to the extruder
Flattened ring lists turned disjoint outers into phantom earcut holes;
i_overlay orientation normalizes via the SAME shoelace form as
polygon_signed_area (outer > 0, holes < 0, y-down).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:44 +02:00
Admin
59ff31ede0 Map: v4 baked building block dissolve — same-height blocks union at bake, zero runtime booleans
BakedFacesBucket v4 carries pre-dissolved (height, tint) building groups
behind their own input signature; the bake sink unions eligible grounded
jobs per group (chunked i_overlay); on a runtime HIT the eligible jobs
swap for the dissolved groups (paint order re-sorted), on MISS the
per-building path runs unchanged. v3 streams parse with an empty section
so the current NL cut keeps working until the next bake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
c10a4d3e81 Map: 3D LOD ring derived from the real frustum extent — visible tiles never lose detail
near = frustum half-diagonal (rotation-safe, full tilt stretch) plus a
tile diagonal of margin; the fixed viewport-height radii bit into
visible tile columns at some zooms (2D band with a hard seam). Rings now
only ever act beyond what the camera can see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
a362f385ec Map: building/tree LOD rings — collinear wall merge, walls/trees bands, crossed-quad far trees
simplify_wall_ring merges sub-2-degree collinear runs (digitization
noise) so adjacent wall quads fuse. fill_3d sub-splits by material:
MAT_WALL and MAT_CANOPY get their own bands; mid ring draws roofs +
two-quad crossed trees (geometric, no atlases), near ring full detail,
heights sink only toward the horizon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
2209701b55 Map: fix packed-stride corruption in road-decal restore — packed vertex format complete
append_cached_road_icons appended logical 19-float records into the
GPU-packed icon buffer with the old stride's index base (fan-triangle
streaks after 2D/3D mode switches). Decals stay logical CPU-side and
pack on the way in. This closes the packed-format bring-up: 76B -> 48B
per vertex across all map buffers, packing on the builder thread, all
three DrawVector-family shaders (vector, map, svg) unpacking via the new
intrinsics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
3d5c54f2f3 Map: packed layout v2 — stroke_dist f32 (f16 overflow rainbowed long roads), shape_id+param0 pair, param3+clip_radius pair; draw_svg packed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
52c836256c Map: GPU-pack on the builder thread — uploads ship pre-packed, drain unthrottled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
63b48a0de5 Map: packed vertex format live — 19 f32 (76B) -> 12 slots (48B) per vertex
VectorVertexPacked: f16 pairs (uv, dist+shape, param0+3, param1+2, clipr)
and unorm8x4 color bitcast into f32 slots, unpacked by the new shader
intrinsics; positions, stroke_mult sentinels, param4 icon composite,
param5 depth ladder and zbias stay f32. Emitters unchanged — one packer
runs at upload (map tiles + generic vector picture path). -37% vertex
fetch bandwidth across every map buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
7ee121d2f6 Map: 3D LOD radii tilt-aware — full frustum keeps 3D, only deep horizon sinks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
2c8cfd79d5 Map: 3D distance LOD sinks heights instead of alpha-fading (no see-through walls)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
9feb1ebfee Map: 3D-volume LOD — walls/trees/roofs split to fill_3d, ground-circle distance fade
The 3D suffix between the fills and buildings laps splits off; the draw
fades it from 0.75 to 1.5 viewport-heights around the view focus. Under
tilt the far (blurred) field skips the bulk of the fill vertex mass;
flat views are inside the near radius everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
f1a883729b Map: fringe band split — AA skirts skipped at strong tilt (~2/3 of casing verts)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
d63b4732b4 Map: icon zoom-banding — street-band icons (floor>16) split to a gated pass
Post-build O(n) partition on the per-vertex zoom-floor slot; pass 4 draws
the band only at view >= 16.25. Kills millions of shader-collapsed icon
verts per frame at mid zooms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
097a57ab70 Metal: MAKEPAD_GPU_PROFILE=1 per-pass GPU profiler + gpu_ms in map frame log
Per command buffer: pass debug name, GPU start->end interval, draw calls,
vertices, instances, upload bytes — accumulated and printed as a 1Hz
table from the completion threads. Map frame stats gain gpu_ms/gpu_max
from the existing PERF_CHANNEL_GPU ring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
e61b9af7fc Fix all compile warnings across map crates (unused mut/import, key visibility)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
ec3c78e892 Map: frame-stats logger splits icons_ms/tail_ms — hunting the 10ms draw baseline
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
fea68f8c22 Map: labels pan/zoom on the GPU — cam_scale/cam_shift uniforms, glyphs emit in cached space
The cache-hit path re-emitted every glyph CPU-side with the pan shift
baked into vertices; under tilt that visibly trailed the tile geometry
(which pans purely by uniform). The pan/zoom delta now rides two new
uniforms applied before the existing camera-delta matrix, in the same
frame as map_offset — labels physically cannot lag the map. Pin-interior
text keeps constant px via a billboard instance flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
7b02d81023 Map: labels ride the pan — re-place at rest, not every 48px/125ms mid-gesture
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
075f8ed507 Map: switch-only integer keyframes 15-18 (face morph stays opt-in experiment)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
85c1c1a531 Map: morph offsets = smoothed direction x full half-width (constant-width morph)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
c6c5197a10 Map: fringe outer-carrier verts ride rigidly with their boundary partner
Pinned carriers stretched the 1px AA band into a wide smear at corr>1 —
the dirty road edges across the morph band.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
db0f938621 Map: smooth morph-offset vectors along rings — clean interpolation path
At the keyframe corr==1 cancels offsets entirely, so ring-circular 1-2-1
smoothing (3 passes) costs zero keyframe fidelity; mid-morph the edge no
longer saw-tooths where junction wrap-arounds swing miter direction
per vertex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
edc4c433e0 Map: clamped face_correction uniform + face expand-class band — stale cross-band tiles widen, never invert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
a7a11c670b Map: bake deck shadows into the shadow section — the last unbaked boolean
The deck-shadow dissolve ran after the bake sink's early return, so no
cut ever contained it and every dz-covered street tile paid ~59ms at
runtime (found via emit-section fencing: ebuild=59). Extracted
dissolve_deck_shadows; the sink concatenates it into the baked shadow
shapes (concat == today's two separate emits exactly); a shadow HIT now
gates the runtime block off. Also: face-arm/stroke-arm/events laps, icon
template cache, batched expanded writer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
83782badc0 Map: icon mesh template cache — memcpy + 4-slot patch per instance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
82caed10d3 Map: band-top keyframes (15,18) + face-morph file flag
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
406c26c0b6 Map: face morph opt-in (MAKEPAD_FACE_MORPH=1) — inward morph inverts narrow features
Serving above the keyframe needs corr<1 (inward): narrow union features
self-cross into bowties, junction-pinned verts tear shards (user
screenshot, city center). Redesign queued: band-TOP keyframes (15,18)
so corr>=1 everywhere — outward-only morph is opaque-safe. App stable
pinned meanwhile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
c8062a4ad4 Map: morph offsets survive dz subdivision — decked city faces morph too
subdivide_face_mesh_morph carries the offset channel (midpoints average
endpoints); decked bodies emit via the expanded layout with per-vertex
deck override. Fixes the magnified-wide roads across bridge-dz coverage
(all central Amsterdam) at deep zoom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
9893c8acc2 Map: tree template instancing + stalk-clearance cell grid
Street trees memcpy one origin-built mesh (anchor+zbias patch); icon
stalk clearance queries a building-ring cell grid instead of scanning
icons x groups x rings. Worst AMS 368 -> ~310ms serial.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
b8a126c47b Map: bounded point-key whitelist at the icon horizon — detail-merge 137 -> 54ms
The icon matchers read a finite key set; parsing micro-POI layers with
the whitelist off was ~140ms/tile at kf16. Worst AMS 504 -> 368ms serial.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:43 +02:00
Admin
b99f328f01 Map: distance-first tile eviction + self-scaling byte budget (street-zoom thrash)
Icon-horizon tiles run 50-85MB; a fixed 1.2GB budget sat below
visible+pan-ring at street zoom and pure LRU evicted the very neighbors
a circling pan re-enters — every loop around a center rebuilt its ring.
Budget now grows to hold 2x the visible set and eviction orders by
distance from the view center (LRU only as tiebreak): the ring survives,
the trail is what dies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:42 +02:00
Admin
def3afd360 Map: morphable faces steps 3-5 — band-100 face emit, two-keyframe buckets, icon horizon
Morphable (flat, opaque, non-emissive) face bodies+fringes emit through
append_expanded_stroke_geometry (anchor = pos - miter*hw_key): the live
zoom re-widths them per frame via the existing width_correction path, and
the expanded branch already zeroes the fragment params faces with glow
would need — those stay pinned. render_bucket collapses to keyframes
{<=14 native, 15->14, >=16 -> 16}; icons include to a z18 horizon from
the high keyframe and reveal via the live icon_zoom uniform (mechanism
existed). Existing v3 streams serve both keyframes unchanged — the only
restyle event left on the zoom axis is the single 14<->16 crossover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:48:42 +02:00