Commit graph

564 commits

Author SHA1 Message Date
Jason Yau
14fe611e66
Add Apply::Rebake so script_mod re-runs stop clobbering imperative state (#1219)
Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-09-05 23:26:03 +02:00
Kevin Boos
a13034d85d
wayland: stop inverting the scroll direction (#1216)
* wayland: stop inverting the scroll direction

Wayland's wl_pointer axis values already carry Makepad's scroll
convention -- positive vertical means scroll down, i.e. the viewport
moves down. The backend negated them, so wheel and touchpad both
scrolled backwards relative to X11, macOS, Windows and web.

The spec pins the sign in wl_pointer::axis_relative_direction, whose
`identical` case is a user's fingers moving down producing a
"vertical_scroll down" axis event. libinput, which produces the values
compositors forward, documents the same: "the positive direction being
down or right". Makepad's own convention matches -- ScrollBar applies
`scroll_pos + e.scroll.y` against a position clamped to
[0, view_total - view_visible], and the turtle draws content at
`origin - layout.scroll` inside a clip rect fixed at the unshifted
origin, so a positive delta moves the viewport down.

The negation came from #875, which read a positive axis value as content
sliding down and cited winit's negation as precedent. But winit's
MouseScrollDelta is documented as positive = content moves down, the
inverse of Makepad's convention -- winit's own comment reads "Wayland
sign convention is the inverse of winit" -- so copying it was a double
negation. Whether a toolkit negates is decided by its own convention,
not by anything about Wayland: GTK, which shares Makepad's convention,
passes the values through; SDL and Chromium negate because theirs are
inverted, and SDL negates vertical only, which is self-consistent just
in case Wayland's +y is down and +x is right. #875 also cited the web
backend as agreeing, but web forwards DOM deltaY unnegated, and deltaY
is positive when scrolling down.

The AxisDiscrete and AxisValue120 handlers added later inherited the
sign, so all six sites flip together; the spec states each expresses its
direction along the same axis as the coupled axis event.

Natural scrolling needs no client-side handling. libinput applies it in
evdev_notify_axis_*, below the compositor, so the delivered axis value
already reflects the user's setting -- the negation was not implementing
that, it inverted both settings equally. AxisRelativeDirection stays
ignored, which is correct for scrolling content; it exists so widgets
that should track the physical wheel regardless of the setting (the
spec's example is a volume slider) can recover the direction.

Fixes #1173

* wayland: classify the scroll source, and choose each axis's delta on its own

Five defects in the wl_pointer frame handler, adjacent to the sign fix in
the previous commit but independent of it.

The detent-vs-pixel choice was made once for both axes, so a frame
carrying detents on one axis and only a smooth value on the other scaled
that second axis by a zero detent count and silently dropped it. Each
axis now chooses on its own.

`scroll_is_wheel` collapsed a five-valued classification into "Wheel vs
everything else", and its false default meant "finger gesture". So a
wheel tilt discarded its detents, a continuous source — a trackpoint, or
button-held scrolling — was reported as a touchpad gesture, and so was a
frame from a compositor that sent no axis_source at all, the event being
optional and sent only when the source is known. That default is the one
classification that can strand a widget: ScrollPhase::Ended is what
springs a stretched rubber band back, only a finger source is guaranteed
an AxisStop, and the spec tells clients to treat every other source as
unterminated by default. The bool gives way to the source itself, and a
sourceless frame is classified by whether it carried detents.

A bare AxisStop no longer dispatches for a source with no gesture to end.
Compositors stop an axis whenever its value reaches zero, whatever the
source, and a zero-delta ScrollPhase::None clears a widget's overscroll
and cuts short a running bounce.

Nor is a stop arriving alongside live motion treated as lift-off. Per the
frame event: "When a wl_pointer.axis and a wl_pointer.axis_stop event
occur within the same frame, this indicates that axis movement in one
axis has stopped but continues in the other axis." And because
axis_source is per-frame and optional, a gesture in flight now carries
its classification forward, so a lift-off frame that omits the source
still ends the gesture instead of losing the terminator.

The raw-pixel fallback for an axis without detents stays unscaled, which
is a deliberate non-change rather than an oversight. No units-per-detent
constant exists to scale it by — compositors disagree, and hwdb ships
wheels from 10 to 30 degrees per click — and a physical wheel never
reaches it: the fallback is for virtual pointers, whose axis value the
protocol already defines as a distance.

Finally, the claim that ScrollPhase::Ended lets widgets run their own
momentum fling was wrong. Widgets start their fling on
ScrollPhase::Momentum, which only macOS emits, so Wayland touchpads have
no kinetic scrolling at all; the comment now says that rather than its
opposite.

The frame decision moves into `frame_scroll`, which puts every case above
under a unit test instead of leaving it to be re-derived by reading.
2026-09-05 00:18:26 +02:00
Kevin Boos
c5fb78ba09
wayland: client-side decorations that cast a real shadow (#1215)
Makepad windows on Wayland had no drop shadow, which on GNOME reads as
broken next to everything else on the desktop. Mutter implements no
server-side decoration protocol at all -- it advertises neither
zxdg_decoration_manager_v1 nor any KDE equivalent, and its shadow code
(MetaShadowFactory) lives in src/x11/ and isn't even in the
introspection surface. Every shadow on that desktop is drawn by the app
that owns the window.

So draw one, out of eight wl_subsurfaces hung outside the toplevel: four
corner tiles and four edge strips, backed by one memfd wl_shm pool and
sized with wp_viewport, with xdg_surface.set_window_geometry keeping them
out of the window's logical bounds. GTK instead oversizes its own surface
and paints the shadow into a transparent margin. Subsurfaces keep the GL
surface exactly window-sized, so the shadow costs no per-frame GPU fill,
and no margin ever crosses the platform/widget boundary -- which is the
entire class of off-by-a-margin bugs the other approach invites.

The profile is libadwaita 1.9's, computed rather than sampled. A
rectangle's Gaussian shadow is separable, so each box-shadow layer's 2-D
coverage is the product of two 1-D normal CDFs, and evaluating that for a
*square* rectangle is what makes the corners hug the window: sampling a
rounded window's shadow gives 14/255 where a square corner needs 44/255,
and fades the edge out over the last 20px before every corner. The
straight-edge profile this produces matches a capture of the real
libadwaita output to within 1/255, which is what the test pins. Corner
tiles reach 16px along each edge, far enough that they join the strips
bit-identically at any scale.

Resizing happens in the gutter, the way it does for every native app.
The shadow surfaces carry input regions whose union is the window rect
grown by 12px -- the same halo libadwaita gives its toplevels -- and each
piece maps to exactly one edge, so landing on a surface is the hit test.
Window controls no longer compete with the corner grabs for the pointer,
which is what let the close button swallow the top-right corner.

Server-side decorations are requested wherever a compositor offers them,
overridable per process with --wayland-decoration= or
MAKEPAD_WAYLAND_DECORATION, and fall back to the frame above. KWin and
wlroots grant them; GNOME cannot.

Alongside, the caption bar gains double-click-to-maximize, a right-click
window menu, resize cursors keyed off the wl_pointer.enter serial the
protocol actually asks for, and tiled/constrained edges that suppress the
grabs they cannot service -- degrading a corner to its free axis rather
than dropping it.

Finally, declare the toplevel's opaque region, under the same
`!transparent && backdrop == None` condition macOS already uses for its
layer's opaque flag. The buffer is ARGB8888, so without that promise a
compositor cannot learn the alpha is uniformly solid short of reading
every pixel: it must blend the whole window, cannot cull what the window
covers, and cannot scan a fullscreen buffer out directly.

Verified against a WAYLAND_DEBUG trace: over 67 committed frames the
shadow issues no protocol traffic at all, and set_window_geometry,
set_opaque_region and the nine wl_regions are each sent and destroyed
exactly once.
2026-09-04 20:23:43 +02:00
Kevin Boos
4383a13832
View: an on_item_tap hook for script-rendered lists (#1212)
* View: an on_item_tap hook for script-rendered lists

Rows built by `on_render` can't carry `on_click` closures (they stop the
list re-rendering), so lists had no way to be tappable.

* `on_item_tap: |index|` on the container fires with the direct child
  under a tap
* Runs after the scroll bars with capture overload, so a press still
  starts a drag scroll and a Button child keeps its own click

* View: on_item_tap hit-tests rows with clipped_rect, so scrolled lists map to the right row

* View: a press that catches a fling never counts as an item tap
2026-09-03 22:03:57 +02:00
Kevin Boos
6d71eda34f
Splash: host->script hook calls find fns defined after a shadowing let (#1210)
`call_script_fn` looked names up in the module body scope, but a
`let`/`fn` that shadows a name already in scope opens a child scope,
and everything the script defines after it lands there, invisible from
the module scope. The Splash prefix's own `let fs` / `let host` can be
that shadow, so app hooks never resolved.

* The VM records the scope a root frame ended in (`ScriptBody::end_scope`)
* Splash looks hooks up there, falling back to the module scope
2026-09-03 21:58:22 +02:00
Kevin Boos
493d23a763
Put the F10/F12/Shift+F12 dev overlays behind an opt-in (#1209)
* remote: honor MAKEPAD_REMOTE in requested(), and hush the close notices when the bridge is off

`requested()` only scanned argv, while `requested_bind()` also reads
MAKEPAD_REMOTE. So `MAKEPAD_REMOTE=1` started the bridge but everything
keyed off `requested()` still said no. Just delegate, so there's one
answer to "did this process ask for the remote bridge".

Also stop printing `[makepad-remote] user closed window ...` to stdout
from every app on every window close -- that line is for the agent
driving the app, so only print it when the bridge is actually up. The
log ring still gets it either way, for /log.

* devtools: put the F10/F12/Shift+F12 overlays behind an opt-in

Three dev tools are currently wired into every app with no way to turn
them off, each on a bare function key:

  F10        the exploded draw-list view. Intercepted in
             Cx::call_event_handler *before* the app's handler, and once
             it's up it also eats Escape, the arrows, +/-/0, I and H --
             no modifier needed -- plus every drag outside the flat band.
  F12        the design tweaker, a child of every Window. Once it's up it
             swallows every pointer event over the body, so the app looks
             frozen to the mouse.
  Shift+F12  the screen recorder, which starts writing mp4s to disk.

None of that is something a shipped app wants a user to find by accident,
and there was no flag, env var or property to stop it.

So: one gate, platform/src/devtools.rs. `--devtools`, or
MAKEPAD_DEVTOOLS=1, and --remote implies it since the /snap + /click loop
drives the tweaker. An explicit MAKEPAD_DEVTOOLS=0 wins over all of it,
which also keeps the off path testable under --remote.

Only the hotkeys are gated, not the tools. Cx::sploded_toggle,
set_tweak_on and ScreenCap::toggle are untouched and still public, so an
app that wants any of this puts it on a key of its own choosing -- that's
the app deciding, rather than a key nobody knew was bound.

Gating F10 and F12 is enough to reach all of it: everything else these
two claim sits behind `sploded.active` / `tweak_is_on()`, and with the
hotkeys gated the only remaining ways in are the /tweak routes (already
--remote, which implies devtools) and an app's own call.

* text_input: drop the Ctrl+Enter submit clause again

`|| mods.control` made Ctrl+Enter submit a multiline input. On
Linux/Windows that's already what is_primary() means, so it changed
nothing; on macOS it turned Ctrl+Enter from "insert a newline" into
"send", which is a surprise in the middle of a chat app's composer.

The comment right above it already described the old behavior, so this
puts the code back in line with it.
2026-09-02 23:41:39 +02:00
Kevin Boos
79f938c09d
Splash isolate host: keep inserted subtrees reachable, survive foreign-heap values, contain isolate panics (#1208)
* widget_tree: keep manually inserted children linked across a refresh

A container that owns a child outside its own child vec inserts it with
`insert_child_deep` and never reports it from `children()`, so a refresh
unlinked it from every top-down search and the removal pass then deleted
its subtree. Everything under it went quiet, and invisibly: an empty
`WidgetRef` is a silent no-op.

Mark such children `manual` and keep the live ones linked, clearing the
flag once the parent reports the child itself.

* script: survive a foreign-heap value in the GC mark walk

`GenVec`'s `Index` bounds-checks the raw Vec before the generation check,
and `len` never shrinks, so an index past the end belongs to a different
heap. Aborting the process over one takes the whole app down for a fault
confined to a single script heap.

Add bounds-checked accessors and use them in `mark_value_fields!`, so
marking skips and reports a foreign value; name the table where an index
does still panic.

* splash: contain isolate panics, and report the silent failures

A Splash isolate runs user script on the UI thread, and the VM swap in
`with_isolate_installed` was not panic-safe: a panic could leave the app
VM swapped out, with later script resolving against the wrong heap.
Restore through the unwind, and contain panics at each entry point (pump
arms, timers, host callbacks, isolate GC, body eval, hook calls).

Report what used to fail silently too: callback errors, a script->widget
call whose target is gone, and wrong-VM routing. Adds three headless
regression tests.
2026-09-02 02:26:52 +02:00
Admin
1d97d63e2f rustc 1.98 sweep: script module glob imports nothing uses, and strlen declared as libc has it
1.98's sharper macro-use tracking flags the live-id macro globs five script
modules kept without using; windows-strings' strlen extern takes *const
c_char with a cast at the two call sites instead of tripping
suspicious_runtime_symbol_definitions (PCSTR is transparent over *const u8,
so the ABI never changed).
2026-09-01 17:12:15 +02:00
Admin
dbb82b8b61 chore: the zero-warning sweeps — every target of every workspace crate compiles clean on macos, windows, linux, ios and android
Squashed from work:
- platform, draw, widgets: the cross-target zero-warning sweep
- zero-warning sweep, round two — the first full-workspace pass
2026-09-01 16:46:29 +02:00
Admin
bada23dda2 frame witnesses: presents, uploads, encode and gpu time, a pinned display link, SHIFT+F12 screen capture, MPINPUT latency
Squashed from work:
- every window can record itself: SHIFT+F12 writes picture and sound to local/screencap
- the frame has witnesses now: presents, uploads, encode time, gpu time, and a pinned display link
- the upload counter names its kind: instances and textures split, and any single item over half a megabyte logs itself
- MPINPUT: input-to-glass latency in the present pulse
2026-09-01 16:46:29 +02:00
Admin
7e6e87fa1a platform: native file and save dialogs, in-house on all three desktops plus Android and iOS
Squashed from work:
- platform: native file and save dialogs, in-house on all three desktops
- platform: file dialogs on Android and iOS, and unbreak the Android build
2026-09-01 16:46:28 +02:00
Admin
3b0b16e8ee draw: overlays composite above draw_depth content, per-session DrawVector geometry, one uniform into a whole draw list
Squashed from work:
- draw: DrawVector reused one geometry slot for every session in a frame
- draw, platform: overlays now composite above content that uses draw_depth
- DrawVars::set_uniform_on_draw_list — one uniform into every retained call of a shader in a list, pass repainted
2026-09-01 16:46:28 +02:00
Admin
149d19fcbb platform: midi pitch-bend byte order, many http connects at once, what a strategy round needs underneath
Squashed from work:
- render, sim, platform: what a strategy round needs underneath
- platform: midi pitch bend was sent with its bytes the wrong way round
- http: the connect slot is a gate, not a turnstile — many connects at once
2026-09-01 16:46:27 +02:00
Admin
e0530b061b glass + widgets: realtime parent blur, glass button icons, idling without draw_pass.time, fold_header opens honestly
Squashed from work:
- glass: a glass button can carry an icon
- widgets: the fab palette cell's colour is a field, not an object
- widgets: the glass stops reading draw_pass.time, so glass apps idle again
- live-with-parent passes: the glass blurs the world in realtime again
- fold_header: visually open is open
- fold_header: the ease snaps to its ends
- fold_header: settled state is the truth, the pane edge is the law, the area is the fold
2026-09-01 16:46:27 +02:00
Admin
b408131172 video: hardware first-frame decode from RAM, one encoder-transform report, stills without an AVAssetWriter
Squashed from work:
- video: hardware first-frame decode straight from RAM — no temp files
- video: the encoder transform is reported once, not once per encoder
- video: a single still does not need a whole AVAssetWriter
2026-09-01 16:46:27 +02:00
Admin
b3dd79978b tweaker + shader const-table: the design-feedback campaign — annotated props, hot-patchable shader constants, animator-state wells, the theme tab, typed editors, undo through anonymous paths
Squashed from work:
- widget_tree: skip-search nodes bound path-cache invalidation
- tweaker: fold the shader source view; plain TextInput, not CodeView
- docs: button shader annotations — widgets/button.rs complete + splash demo buttons
- tweaker: the Shader tab shows the pinned widget's ANIMATOR STATES as little posed swatches under the well — one per tr
- tweaker: the animator-state swatches are full-size wells stacked vertically under the main material well (same width,
- tweaker: typed editors for structured values — a reflected Vec2/3/4 is fused x/y/z/w scrub fields (the whole vector re
- shader: const-table mode — /** name min..max step s */ float literals inside fn bodies compile to hot-patchable scope-
- tweaker: the Props tab opens with TWEAKABLES — every annotated value (a /** */ doc on the key) hot-first, each with it
- tweaker: the sidebar row templates are hoisted into shared let bindings (one source of truth for the Props list, the S
- docs: shader-code constants annotated in the six core widgets
- tweaker: SHADER CONSTANTS — the Props tab opens with the annotated literals inside the pinned widget's draw-layer fn b
- tweaker: undo/redo resolve the pinned widget when a history step's path runs through anonymous segments (a '-' or a li
- shader const table: whole-number literal operands lift too — the parser packs `x + 6.`, `y - 2. - b`, `w * 4.`, `n - 1
- tweaker: the Shader tab reads well stack → SHADER CONSTANTS for the mirrored layer → INPUTS (its uniforms/instances, a
- tweaker: the panel cleaned up to the user's list — the filter (with the exploded-view and note buttons) is the top row
- tweaker: three panel polish nits — a section whose rows are all secondary leads with its first three instead of an emp
- tweaker: the VJ session's fix batch — (1) mirrored material wells clip to their scroll viewport: the well is its own d
- tweaker: theme colours, chunk 1 — the panel reads the app's palette from mod.theme's cascade once per session (every c
- tweaker: the theme hover pulse — hovering a colour chip pulses that theme colour live across the whole app, and grabs
- tweaker: the theme palette strip in the colour popover — hover names and pulses a theme colour, a click binds the prop
- tweaker: the Theme tab — every theme colour and number in a fourth panel tab, colours edited live app-wide, all of it
- tweaker: F12 works without the bridge
- tweaker: the selection wears viewfinder corners, not a box
- tweaker: radius handles come to the hand, not the eye
2026-09-01 16:46:26 +02:00
Kevin Boos
0e19a65eac
x11/opengl/wayland/metal: implement what these backends silently skipped (#1201)
* windows: publish shader-cache entries atomically, and unwind a cancelled resize

Two ways a second instance, or an interrupted drag, leaves a window permanently
degraded. Both were found auditing the backend rather than reported, and both
are cheap.

The DXBC cache is a plain per-user directory that every makepad process on the
machine reads and writes, and entries were written straight to their final path.
`fs::write` truncates first, so a second instance starting at the same moment
could read the prefix of a shader the first was still writing -- the file exists
and the read succeeds, so nothing notices until `CreateVertexShader` is handed a
truncated blob. `shader_bytes_cached` even documented the read path as catching
this, which it did not. Entries are now written to a temporary file and renamed
into place, which is atomic, and a blob that is not a complete DXBC container
(the 32-byte header and its magic) is recompiled instead of used. The temporary
name carries the process id so two writers cannot collide on it either.

`is_in_resize` and the 8 ms resize timer are armed by WM_ENTERSIZEMOVE and were
disarmed only by WM_EXITSIZEMOVE. Win32 does not guarantee that pairing, and a
drag that ends without it leaves the timer forcing repaints forever and the
window presenting unpaced for the rest of its life -- it never returns to vsync.
WM_CANCELMODE now unwinds the loop, gated on having actually been in it, since
that message also arrives for menus and capture changes and unwinding a resize
that was not running would cost a needless ResizeBuffers each time.

Verified against a release build: two instances launched simultaneously on a
cold cache both come up correctly with no panics and no temporary files left
behind, and window resizing is unaffected.

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

* d3d11: validate a cached shader against the size its own header declares

Checking the magic and a 32-byte minimum accepts a blob that was truncated
after its header, which is exactly the shape a half-written cache entry takes.
The DXBC header carries the container's own total size at offset 24, so
comparing it to the length rejects those too.

Publishing entries atomically stops this backend from writing a partial one,
but the cache is a plain shared directory that older builds have already
written to, so the read path still has to be able to tell.

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

* windows: stop double-scaling a popup window's position

`CxOsOp::CreatePopupWindow` arrives with a position the caller has already put
in physical screen pixels -- it adds the parent window's physical origin to an
offset it scaled by the parent's per-monitor DPI -- and `new_popup` then
multiplied it by the DPI again. The popup landed at `dpi` times its intended
screen coordinates: exact at 100%, and progressively further away above it.

The size was scaled by the *system* DPI, which is the primary display's and
goes stale after a live scale change, so on a second display of a different
scale it was the wrong number twice over. It is now passed through unscaled,
because it is provisional either way: `init` runs `set_inner_size` immediately
afterwards, which scales by the window's own per-monitor DPI once the HWND
exists on its target display.

Note for reviewers: this is unreachable today. `WindowHandle::new_popup` is the
only producer of the op and nothing in the tree calls it, so there is no symptom
to reproduce -- which is also why the arithmetic was free to drift.

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

* x11: implement window resizing, and stop a protocol error killing the app

Two things the X11 backend did not do, both of which it silently pretended to.

`set_inner_size` and `set_outer_size` were empty function bodies, so
`CxOsOp::ResizeWindow` dispatched to nothing: an app calling `WindowHandle`'s
resize on X11 got no error, no log and no resize. They now call
`XResizeWindow`, clamped to the CARD16 range the protocol encodes an extent in,
since a zero is a BadValue. `set_outer_size` delegates rather than pretending:
the window manager owns the decoration frame, so a client can only ask for its
own extent.

Xlib's default error handler prints to stderr and calls `exit(1)`, and makepad
installed none, so a single rejected request killed the process outright -- a
bad geometry, a race against a window the WM has already destroyed, a missing
extension. Protocol errors are asynchronous and frequently not caused by the
code that happens to be running, so terminating is never the proportionate
response. A handler is now installed before the first request and logs the
error, request and minor codes plus the resource id.

`XResizeWindow` and `XSetErrorHandler` were not in the hand-written Xlib
bindings; both are added, along with the `XErrorHandler` callback type.

Untested: the author has no X11 machine. Both changes are small, local and fail
closed -- an unusable size is clamped rather than sent, and the handler only
turns an existing hard exit into a log line -- but neither has been run.

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

* opengl/wayland/metal: name three failures the backends currently swallow

None of these is device-loss recovery -- that stays a D3D11-only feature. They
are the cases where a backend already fails and says nothing useful, so a bug
report arrives as "the window went black" with no cause attached.

EGL: `eglMakeCurrent` and `eglSwapBuffers` failures were logged unlatched on
paths that run every frame, so a persistent failure emitted thousands of lines a
second. Both are latched once per outage and cleared on the next success, and
`EGL_CONTEXT_LOST` (0x300E) is now named explicitly, since that is EGL saying
the GPU reset and every GL object is dead -- which this backend cannot yet
recover from, and should at least say so. `OpenglCx::make_current` returned `()`
while discarding the result of a call that can fail; it returns `bool` now, so a
caller can skip GL work that would otherwise run with no current context and be
silently dropped.

Wayland: `wp_viewport.set_destination` raises the `bad_value` protocol error --
which disconnects the client -- on a zero or negative extent, and a float-to-int
cast turns both a negative and a NaN into zero. `WaylandWindow::new` clamps the
size for the EGL call but stores it unclamped into the geometry, so an app
created at a degenerate size reached that request. The two destination writes
are floored the way the EGL extent already is.

Wayland `CxOsOp::ResizeWindow` was an empty arm. A client has no "set my size"
request, but window geometry defaults to whatever the surface commits, and the
paint path derives the EGL extent and viewport destination from
`window_geom.inner_size` -- so writing it is the whole operation. Refused for a
maximized or fullscreen toplevel, which must keep the configured geometry or the
compositor raises `invalid_surface_state`. `RepositionWindow` stays a no-op and
now says why: `move` is interactive and serial-gated, and `reposition` is an
xdg_popup request needing a protocol version this backend does not bind.

Metal: a command buffer that ends in `MTLCommandBufferStatusError` produced no
pixels, and nothing noticed -- the completion handler runs either way, so the
in-flight queue drains and the hang watchdog stays quiet over a stale window.
The status is now checked in the handler that already exists and the error named,
capped at 32 reports.

Untested: none of these three backends can be run here. All three are
type-checked for their targets. Deliberately NOT written: anything for Vulkan
(vulkan.rs is not compiled by any target available here, so it cannot even be
type-checked) or the web backend (no JS runtime available to syntax-check the
shim), and any Metal device-loss latch or recovery, because `MTLCreateSystemDefaultDevice`
returns the non-removable SoC GPU and loss in the D3D11 sense does not occur there.

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

* x11/wayland: cap a window extent, and refuse a resize that says nothing

Running the branch on Linux turned up four things it and #1197 let through.

A saved size is floored but never capped, and Wayland is the one backend with
no displays to fit against, so `{"inner_size":[1e9,1e9]}` reached
`eglCreateWindowSurface` and tripped its `assert!`. The app then died before it
could rewrite the state file that was killing it, so every later launch died the
same way -- the exact failure #1197 exists to prevent, one line further down.
`sanitize_window_geom` now caps as well as floors, and both Wayland EGL surface
creations fall back to the default size rather than taking the process with them.

`CxOsOp::ResizeWindow` had the same hole and no cap at all on Wayland:
`resize(100000, 100000)` went straight into the window geometry, which left the
surface EGL_BAD_SURFACE and corrupted the size the toplevel restores to -- a
maximize/restore round trip came back 1259x1259, a fullscreen one 100000x100000.
X11 had the opposite problem: it clamped a zero or a negative up to the CARD16
floor and produced a one-pixel window, silently, where Wayland refused the same
request and said why. Both now go through one `sanitize_resize`, so they answer
a bad request identically.

Last, the new X11 error handler is an `extern "C"` frame and logging panics on a
closed stdout -- `app | head` is enough -- so a protocol error aborted the
process instead of the `exit(1)` the handler exists to prevent. Reporting is
wrapped in `catch_unwind`.

Verified on Ubuntu 25.10 / GNOME on both backends: the 1e9 file now starts and
repairs itself, the resize matrix caps at 16384 with no EGL error and restores
correctly, and the two backends log the same refusal. 76 unit tests.

Still open, deliberately not fixed here: a Wayland self-resize dispatches no
WindowGeomChange. handle_platform_ops holds the Cx borrow for its whole loop, so
sending one needs a deferred-event path that does not exist yet.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:50:37 +02:00
Kevin Boos
fd6f307a2b
windows: publish shader-cache entries atomically, and unwind a cancelled resize (#1200)
* windows: publish shader-cache entries atomically, and unwind a cancelled resize

Two ways a second instance, or an interrupted drag, leaves a window permanently
degraded. Both were found auditing the backend rather than reported, and both
are cheap.

The DXBC cache is a plain per-user directory that every makepad process on the
machine reads and writes, and entries were written straight to their final path.
`fs::write` truncates first, so a second instance starting at the same moment
could read the prefix of a shader the first was still writing -- the file exists
and the read succeeds, so nothing notices until `CreateVertexShader` is handed a
truncated blob. `shader_bytes_cached` even documented the read path as catching
this, which it did not. Entries are now written to a temporary file and renamed
into place, which is atomic, and a blob that is not a complete DXBC container
(the 32-byte header and its magic) is recompiled instead of used. The temporary
name carries the process id so two writers cannot collide on it either.

`is_in_resize` and the 8 ms resize timer are armed by WM_ENTERSIZEMOVE and were
disarmed only by WM_EXITSIZEMOVE. Win32 does not guarantee that pairing, and a
drag that ends without it leaves the timer forcing repaints forever and the
window presenting unpaced for the rest of its life -- it never returns to vsync.
WM_CANCELMODE now unwinds the loop, gated on having actually been in it, since
that message also arrives for menus and capture changes and unwinding a resize
that was not running would cost a needless ResizeBuffers each time.

Verified against a release build: two instances launched simultaneously on a
cold cache both come up correctly with no panics and no temporary files left
behind, and window resizing is unaffected.

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

* d3d11: validate a cached shader against the size its own header declares

Checking the magic and a 32-byte minimum accepts a blob that was truncated
after its header, which is exactly the shape a half-written cache entry takes.
The DXBC header carries the container's own total size at offset 24, so
comparing it to the length rejects those too.

Publishing entries atomically stops this backend from writing a partial one,
but the cache is a plain shared directory that older builds have already
written to, so the read path still has to be able to tell.

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

* windows: stop double-scaling a popup window's position

`CxOsOp::CreatePopupWindow` arrives with a position the caller has already put
in physical screen pixels -- it adds the parent window's physical origin to an
offset it scaled by the parent's per-monitor DPI -- and `new_popup` then
multiplied it by the DPI again. The popup landed at `dpi` times its intended
screen coordinates: exact at 100%, and progressively further away above it.

The size was scaled by the *system* DPI, which is the primary display's and
goes stale after a live scale change, so on a second display of a different
scale it was the wrong number twice over. It is now passed through unscaled,
because it is provisional either way: `init` runs `set_inner_size` immediately
afterwards, which scales by the window's own per-monitor DPI once the HWND
exists on its target display.

Note for reviewers: this is unreachable today. `WindowHandle::new_popup` is the
only producer of the op and nothing in the tree calls it, so there is no symptom
to reproduce -- which is also why the arithmetic was free to drift.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 21:50:26 +02:00
Admin
3b4d6a4ff7 platform+widgets: sploded 3D inspect view, the tweaker design-feedback suite, script math-AOT and docs channel, pointer-pin and pixel-probe, modal layout, map warp
Squashed from work; the fine-grained history is under tag archive/work-2026-08-29:
- mp* wave: mpwm window manager + the mp app family, WM API, theme bridge, PDF engine fix
- mpwm polish wave: terminal key focus, focus-history close order, pop-back-to-origin, occupied-workspace cycling, demo
- mpwm: warm-instance pool, flat-luminance opens, flicker-free CEF resize
- work: land the sources the last commits reference
- kenney: catalogue all 50 free 3D kits; Modal dismissed() never fired
- platform: windows check green again — SetWindowTextW binding
- map: exact warp-aware inverse projections — pointer ops work folded
- mpwm: quick-look gap fixes; image cache eviction on preview unload
- tweaker: material thumbnails + vibecode popup + ctrl-space notes, undo/redo over the edit ledger, capture-semantics pi
- tweaker: vibe popup card chrome + dispatch order, ctrl-space notes verified, sploded design v2 chapter
- tweaker: tabbed side panel (Props/Shader/Tree) - shader tab with checkerboard material well + prompt, complete widget-
- sploded v2: nesting-depth z, hairline scope frames, body pass
- sploded: pin the depth convention with a test, kill the draw_depth residue
- sploded: real body-pass split (scene-pass capture, panel flat) + y-convention source of truth with anti-flip gate test
- sploded: hollow outlines, flat-band input, ray-pick unprojection
- tweaker: shader tab defaults to the selection's first draw layer, stale hint trimmed
- sploded: outlines become clipped, antialiased strips; tighter deck
- sploded: merge the lane's v2 (nesting-depth z, clipped AA strip outlines, flat-band input, unproject, SplodedStack bod
- sploded: the exploded view is a LIVE view — pointer events route through the inverse explode transform (ray -> plane -
- tweaker: tabs are real widgets (uid, tree node under the dock, own plane in 3D) and pickable; navigation-class clicks
- sploded: pinned/hover outlines render on the widget's own plane in 3D — per-widget nesting depth lives on the platform
- tweaker: the material well renders the pinned widget's actual shader — the swatch byte-copies the widget's live draw c
- tweaker: the Shader tab shows the shader as written — the layer's pixel/vertex fn source (nearest definition up the co
- sploded: I = true isometric preset (yaw 45°, pitch atan(1/√2))
- tweaker: eyedropper — the colour popover's pick button arms a pixel probe; the next press in the app samples that devi
- tweaker: the shader loop closes — /tweak/apply resolves the pinned widget by uid (anonymous path segments never round-
- vj: responsive DJ mixer + Windows drag-and-drop, cherry-picked from PR #1199 (vjroger)
- tweaker: the material well is a magnifier — the mirrored instance draws at the widget's native size in the well's own
- tweaker: per-layer material thumbnails — the Widget derive emits WidgetNode::layer_areas() (every #[live] Draw… field
- tweaker: the shader source view is the real CodeView (syntax highlighting, selection, editing) when the app registers
- tweaker: Ctrl+Enter sends on every platform (TextInput treated only Cmd as primary on macOS, so Ctrl+Enter inserted a
- Modal claims no layout slot: the DJ page fills its window again
- tweaker: every fn apply recompiles (eval_chunk ran every chunk under ONE synthetic callsite, so the script body — and
- tweaker: an apply whose draw shader fails to compile is rejected — the layer goes back (last live fns / the fn as writ
- tweaker: the Shader tab's source view owns its scrolling (the ScrollYView around the CodeView double-scrolled the care
- widgets: set_visible belongs to every widget, not just View (#1194)
- script: a dead heap's resource handles must not outlive it (#1195)
- Resources: search the executable's directory, not only the working directory (#1196)
- Windows: fit a restored window to the displays that are actually attached (#1197)
- d3d11: a failing GPU call reports the loss instead of killing the process (#1198)

Co-authored-by: Kevin Boos <1139460+kevinaboos@users.noreply.github.com>
2026-08-29 09:26:24 +02:00
Kevin Boos
7a342be4d4
d3d11: a failing GPU call reports the loss instead of killing the process (#1198)
* d3d11: a failing GPU call reports the loss instead of killing the process

The backend already notices a removed device when `Present` returns
DXGI_ERROR_DEVICE_REMOVED, but a device rarely dies at a moment as convenient
as a present. It dies between frames, and the next thing that touches it is a
resource creation or a buffer map -- of which this file had 76 unwrapped, plus
three `std::process::exit(1)`. So the usual outcome of a GPU driver reset,
a TDR or a hybrid-GPU transition across suspend/resume was a panic, and the
graceful path was unreachable.

Route the calls a dead device actually reaches through `D3d11Cx::note_error`,
which asks `GetDeviceRemovedReason` rather than pattern-matching the HRESULT the
failing call happened to return -- creation calls do not reliably return the two
DXGI device-lost codes, while the device itself always knows and keeps saying
so. That answer sets a process-wide `device_lost` latch and logs once.

The softened sites are the ones a dead device lands on first: draw-list and pass
uniform buffers, which upload every frame with no dirty gate; texture and render
target creation; and shader object creation, whose `CxOsDrawShader::new` already
returned `Option` with both callers handling `None`.

Three latent bugs fall out of auditing them, each of which loses content
permanently rather than noisily:

  - `update_vec_texture` consumed the dirty flag with `take_updated()` and then
    returned early when the pixel buffer was out on loan, so a texture that hit
    that window was never uploaded again. For the glyph atlas that means all
    text disappears for the life of the process. The Metal backend already
    guards this; D3D11 did not.
  - `hlsl_compile_shaders` drains `compile_set` destructively, so a shader whose
    object creation failed was dropped from the queue forever and everything
    drawn with it silently stopped rendering. Failed creations go back in the
    queue.
  - `render_view` unwrapped the geometry index buffer but passed the vertex
    buffer through as an `Option`, so a missing one bound null and drew nothing
    with no error anywhere. Both are now checked together, and a draw call with
    either missing is skipped under a `debug_assert!` that it only happens on a
    lost device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit cc980805b5774253e592e183f577c5ba08ce85db)

* d3d11: rebuild the device and every GPU resource after a device loss

Detection landed already: present() sets a per-window device_lost and the paint
loop stops re-dirtying the pass. Nothing rebuilt anything, so the window stayed
frozen until the app was restarted.

Recovery runs at the top of win32_event_callback, the one place holding both
&mut D3d11Cx and &mut Vec<D3d11Window> while nothing is mid-render. It releases
each window's swap chain, back buffer, view and paint-beat registration (DXGI
allows one flip-model chain per HWND, so the dead one must be gone first),
recreates the device tier when the device really is gone, drops every GPU handle
the Cx holds, and rebuilds each swap chain against the same HWND.

Clearing handles is only half of a sweep. Geometry and instance uploads are
gated on dirty flags cleared unconditionally once the upload runs, and textures
on a dirty rect consumed by take_updated, so every gate is re-armed or the empty
slot is never refilled. The CPU-side sources all survive: texture pixels live in
TextureFormat::Vec*, geometry in CxGeometry, and shaders keep their compiled
DXBC plus the on-disk cache, so recovery recreates shader objects without
compiling any HLSL.

Retries are spaced 250ms to 4s and driven by the existing signal heartbeat
rather than a new timer, because the GPU can stay absent for a long time. While
lost, the loop is forced to Wait at both EventFlow decisions -- every condition
that would otherwise choose Poll is unsatisfiable when nothing can paint, so it
would spin for the whole outage -- and pending screenshot requests are failed,
both to release their requester and because a non-empty queue is itself one of
those conditions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit d603d3e7996cd71b5da6ed3372c9dc75eebb4a77)

* d3d11: bind the hot-path buffers by reference, not by clone

The device-loss bails introduced an AddRef/Release pair per uniform-buffer
upload and per draw call, on paths that run for every draw list, pass and
geometry every frame. Borrowing reads the same Option without touching the
refcount; only IASetVertexBuffers genuinely needs an owned Option, which is what
the code built before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit ecfab9a9355d50754a7117de8303d66f8919b2ee)

* d3d11: make the device-loss recovery actually work, and add a way to exercise it

Three defects, each of which stopped recovery dead, and none of which is visible
without running it. `MAKEPAD_D3D11_TEST_DEVICE_LOSS=<seconds>` forces a full
device recreation on a timer so the path can be exercised without a driver
reset; a real removal cannot be provoked from inside the process. It is a
stronger test than merely setting the latch, because the device really is
replaced, so any GPU object the sweep fails to rebuild still belongs to the old
device and cannot render against the new one.

  - The sweep called `set_updated` on every texture, which panics for anything
    that is not a `Vec*` format. The first render target it reached took the app
    down. Only vec textures carry a dirty rect; render targets, depth buffers
    and shared textures have no CPU-side contents and get their alloc record
    cleared instead.

  - Every rebuilt swap chain failed with `E_ACCESSDENIED`. DXGI allows one
    flip-model swap chain per HWND at a time and D3D11 destroys lazily, so the
    immediate context's own reference to the back-buffer view kept the old chain
    -- and its claim on the window -- alive after the application had dropped
    every handle it held. `ClearState` + `Flush` once, after all the windows have
    released and before any rebuild.

  - The post-recovery redraw marked every pass slot dirty, including ones
    nothing had drawn into, and `draw_pass_to_texture` unwraps
    `main_draw_list_id` immediately. Only passes that have one are marked.

Verified against a release build: six consecutive forced device recreations,
no panics, the UI rendering correctly after each (text included, so the glyph
atlas re-uploads from its retained pixels), `ResizeBuffers` working on a rebuilt
chain, handle count flat across recoveries, and the loop idle afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit bb7d1c019612a4fb2668640f47b8e16c3886e1cf)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:41:33 +02:00
Kevin Boos
e40a5318f7
Windows: fit a restored window to the displays that are actually attached (#1197)
An app that persists its window geometry has to restore it into a display
arrangement that may have changed completely since it was saved: the display
the window sat on can be gone, a docked laptop can be back on its built-in
panel, and the file can hold values no display ever had. Nothing validated any
of it -- `configure_window` stored the numbers and each backend passed them
straight to `CreateWindowExW` / `initWithContentRect:` / `XCreateWindow` -- so a
window could come back off-screen or too small to grab, with no way back except
deleting the state file.

Windows also manufactured those values. Win32 reports a minimized window at
`(-32000, -32000)` with a zero-sized client rect, `WM_SIZE` published that as
the window's authoritative geometry, and an app saving on shutdown wrote it
down. `WM_MOVE` meanwhile published nothing, so a window that was dragged but
not resized persisted its pre-drag position (X11's `ConfigureNotify` and macOS's
`windowDidMove:` both already published).

Add `platform/src/screen.rs`, holding the policy in one place:

  - `sanitize_window_geom` needs no display knowledge and every backend reaches
    it through `CxWindow::create_geom`. It is what protects the backends a fit
    cannot help: Wayland enumerates no displays for a client and hands the size
    to `wl_egl_window_create`, which rejects a non-positive one -- a persisted
    `0` or `NaN` panicked the app at startup -- and X11 encodes extents as
    unsigned 16-bit and answers a zero with a protocol error that, with no error
    handler installed, terminates the process.
  - `clamp_point_to_screens` pins the origin BEFORE the window is created. The
    fit alone is too late: `set_inner_size` runs in between and works relative
    to wherever the window landed.
  - `fit_window_rect_to_screens` corrects the finished rectangle. A window
    already wholly on the desktop is returned untouched, including one
    deliberately spanning two adjacent displays; anything else moves to the
    display it overlaps most, or nearest by centre, capped to that work area.

Displays come from `EnumDisplayMonitors` + `GetMonitorInfoW` on Windows (both
absent from the vendored bindings, so linked here), `NSScreen.screens` on macOS,
and the root geometry plus EWMH `_NET_WORKAREA` on X11. Wayland is unaffected by
the class of bug: a client there cannot know or choose where its windows go.

Report a minimized window from `GetWindowPlacement().rcNormalPosition`,
converted out of workspace coordinates, so what an app persists is the geometry
the window actually returns to; skip publishing on `SIZE_MINIMIZED`; publish on
`WM_MOVE`, deferred to `WM_EXITSIZEMOVE` during a user drag because the Cx
handler redraws on every geometry event.

Positions are now documented and implemented as physical screen pixels on
Windows and X11 (points on macOS) and are never DPI-scaled, matching
`get_position`, `create_position` and the platform calls. `set_position` alone
had been scaling its argument, so `set_position(get_position())` moved a window
to twice its coordinates on a 200% display.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 07:44:21 +02:00
Kevin Boos
dd4c8309d9
Resources: search the executable's directory, not only the working directory (#1196)
A packaged desktop build addresses its resources through a relative package root
-- `cargo packager` and `robius-packaging-commands` both use `.`, with the
resource trees sitting beside the executable -- and a relative `File::open`
resolves against the process working directory. Any launcher that sets no
working directory therefore starts the app somewhere unrelated and every font,
icon and image open fails: a URL-protocol handler (`HKCR\<scheme>\shell\open\
command` carries no working directory), a file association, a service, a
shortcut created without one.

The result is not a clean failure. The window comes up and lays out correctly,
shader-drawn shapes and buttons render, and network-loaded images appear, but
every glyph and every bundled icon is missing, because those are the parts that
need a file. It reads as a renderer bug rather than a missing directory.

macOS avoids this through `apple_bundle_load_dependencies`, and a Linux `deb`
package uses an absolute `/usr/lib/<name>`, so Windows is the only desktop
target whose resource lookup depends on where it was started from.

Retry a failed open against the directory holding the executable. This is
purely additive -- a path that resolves today resolves identically, and only an
open that would have failed reaches the fallback -- so a dev build's
workspace-relative dependency paths keep working unchanged.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 07:44:10 +02:00
Kevin Boos
5aad53afbc
script: a dead heap's resource handles must not outlive it (#1195)
* widgets: set_visible belongs to every widget, not just View

`ui.value_lg.set_visible(w >= 150)` logged "widget method set_visible not
found for uid WidgetUid(1236)" once per resize, and the widget simply never
reflowed. Label, Button and every other leaf refused a method that View
alone implemented — even though visibility is a Widget-trait property that
`#[visible]` derives for exactly those widgets.

it went unnoticed because the fixed-slot list pattern wraps its rows in
Views. what it breaks is the widget tiles, where a bare Label is toggled by
an on_widget_resize, and the failure reads as a layout that just doesn't
respond to its size.

handled once now, in WidgetRef::script_call, after the widget's own
script_call declines the method — so set_visible (and a visible() getter)
work on anything, and View's copy is gone. a bad argument still keeps the
current visibility and returns an error instead of guessing true.

* script: a dead heap's resource handles must not outlive it

the launcher died about one run in five, always inside the GC and never
anywhere near what caused it:

  gc.rs:300: index out of bounds: the len is 21 but the index is 22

only after an isolate had been torn down and another started — closing a
widget preview, or granting `network` (alloc-time, so the app's isolates
restart).

CxScriptResources caches (heap_key, abs_path) -> that heap's LOCAL handle,
and a heap_key is an allocation ADDRESS. the only cleanup was
CxScriptResourceGc, which runs when the owning heap's own GC sweeps that
handle — and a heap that is dropped wholesale, as a Splash isolate's is,
never sweeps anything. so the entries outlived the heap, and the next
isolate whose root_objects landed on that freed address asked for the same
font and was handed the dead heap's handle index. it stored it in its own
FontMember{res, asc, desc}, where 22 means nothing in a table of 21 —
and nothing noticed until that heap's next collection walked the font
object it had every right to walk.

gc_heaps() drops a dead heap's entries, and detaches handles no surviving
heap still maps to (handle values are heap-local, so two heaps' handles can
be equal). called from gc_dead_splash_isolates beside the storage and
bridge purges, which already runs before a new isolate can allocate.

anything keyed by heap_key needs to be in that function, for this reason.

the same hunt turned up a second crossing, fixed here too:
View::script_call(render) built its `me` object in whatever VM happened to
be calling, protoed off the SOURCE view's heap, and forwarded the caller's
args object into the target VM. render a view whose isolate has since been
torn down and that object stays behind in the CALLER's heap holding a dead
heap's index. it refuses now when the two heaps differ.
2026-08-26 10:43:58 +02:00
Admin
fd22db91ff script: the parser fix with the case that caught it, and shader calls
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: video goes NV12 end to end, and the GPU does the unpacking
- script: parser, with the case that caught it in the test suite
2026-08-26 08:49:44 +02:00
Admin
68d80b69e5 windows: the paint beat becomes the swapchain's own beat, and /g learns to see
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: thumbnails become mp4 — hardware-coded sheets at measured-4K cells, and the bake stops racing the GPU
- windows: the paint beat becomes the swapchain's own beat, and /g learns to see
2026-08-26 08:49:44 +02:00
Admin
d5b89df37d macos+metal: the paint beat becomes the display's own beat — link-paced frames, drawables presented plainly, instance buffers and vec textures safe under a live draw, a stalled-command-buffer watchdog, a shielded resetCursorRects
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: thumbnails become mp4 — hardware-coded sheets at measured-4K cells, and the bake stops racing the GPU
- vj: the console grows real transports, and the deck stops lying about reverse
- vj: the GPU learns to see motion — realtime frame tweening on every deck
- macos: the paint beat becomes the display's own beat
- windows: the paint beat becomes the swapchain's own beat, and /g learns to see
- metal: vec textures ride the command stream — the CPU stops overwriting what the GPU is still reading
- metal: instance buffers stop being rewritten under a live draw
- macos: next frames and draws are stamped with the flip they aim at
- metal: a fresh texture forgets nothing it never had — reallocated vec textures upload whole
- metal: a watchdog for stalled command buffers — it names the pass, and only aborts when asked
- macos: resetCursorRects no longer aborts the app when AppKit re-enters it
- macos: nothing panics across resetCursorRects — the callback is shielded and its cursors are retained
- vj: the next pair's fields are fetched ahead of the change under the capacity law — a pair change costs an ordinary beat; macos: the layer's own display link paces the frame when the system offers it, the old path stays as fallback
- macos: a drawable from the layer's display link is presented plainly — presenting it at a time is forbidden and raised in every visible window
- macos: a window paced by the layer's display link never asks the layer for a drawable — the beat waits for the link's update; ObjC exceptions are logged with their reason before they unwind
- macos: the layer's display link is opt-in (MAKEPAD_METAL_DISPLAY_LINK=1) until it paces at the display's rate — 11 fps visible against 62 on the proven path
- macos: the layer's display link asks for the screen's maximum rate, consumes every drawable it hands out, and traces updates/consumed/presented per second — still 75 ms per present under a drag, so it stays opt-in
- fab: a 3D creation shell and the viewer built on it
- raytrace: the traced pane starts coarse and doubles to native, with the raster underneath
- macos: a link-paced beat blocks till the next flip, and an armed paint clock means wait — the main thread no longer polls at 100% CPU between frames
2026-08-26 08:49:44 +02:00
Admin
d1a0eb1cb8 platform: the paint clock contract — a beat per backend, and the time repaint stops resurrecting passes
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- vj: thumbnails become mp4 — hardware-coded sheets at measured-4K cells, and the bake stops racing the GPU
- vj: reverse earns a memory, and the effects stop aging
- vj: video goes NV12 end to end, and the GPU does the unpacking
- windows: the paint beat becomes the swapchain's own beat, and /g learns to see
- platform: the time repaint stops resurrecting passes their owner left behind
- metal: a fresh texture forgets nothing it never had — reallocated vec textures upload whole
- fab: a 3D creation shell and the viewer built on it
- raytrace: the traced pane starts coarse and doubles to native, with the raster underneath
2026-08-26 08:49:43 +02:00
Admin
8b5caf41e1 video: the sample-attachments call takes a CoreFoundation Boolean, so it builds on x86_64 macOS too
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 06:03:00 +02:00
Admin
152b11f20a vj: the deck models install themselves, and Windows gets its waveform back
- INSTALL MODELS under the music explorer: a download dialog naming both
  MIT weight sets (BS-RoFormer splitter 527MB, whisper large-v3-turbo
  1.6GB), where they land and their licenses; resumable sha256-pinned
  downloads through the asset-ai downloader (featureless dep — the same
  slice the asset UI links); cancel mid-flight (the button flips to
  CANCEL, .part resumes later), MB progress, and the row disappears on a
  provisioned machine. When the last model lands the loaded decks
  separate immediately: the stems worker now re-probes the checkpoint
  per job instead of latching its absence, and the lyrics transcriber
  unlatches too (the Apple fallback yields to whisper mid-session).
- DrawWaveLane's stem palette moves from instance inputs to uniforms:
  36 vertex inputs blew D3D11's vs_5_0 limit of 32 (error X4506), which
  left the music decks with NO waveform at all on Windows.
- --remote HOST:PORT binds a named interface so another machine can
  drive an app over the LAN (fleet-box testing); bare --remote stays
  loopback.
- queue chip: the + glyph centres in its 26x18 box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:16:08 +02:00
Admin
5dc8ef5256 platform: a remote control surface, streaming video codecs, and float render targets
An app built with `--remote` now serves a localhost HTTP control surface:
window list, per-window PNG grabs, real mouse/key/text injection, widget
rects, a log ring buffer, and `/gq` (grab every window, then quit). It exists
so a test or an agent can DRIVE a running app instead of reasoning about it
from source — the protocol is documented in AGENTS.md. Grabs are targeted per
window (`/g?w=N`), so a multi-window app is captured window by window rather
than whichever pass happens to present first, and `log!` mirrors into the ring
buffer without anyone owning the app's stdout.

platform/video grows a streaming half beside the file half. StreamEncoder /
StreamDecoder with Apple VideoToolbox and Windows Media Foundation backends,
Annex-B framing, and all-intra bound through pEncodingParameters on Windows —
the only control that MFT actually honors, as the readbacks claim success for
everything else. The file decoder can now be asked for a SPECIFIC frame rather
than only the next one, which is what frame-exact seek and bounce playback
need. Tests cover file seek and the stream round trip.

Draw shaders gain `Rgba16F` and `Rgba32F` color formats to pair with the
float render textures: blending off, whole-texel writes, meant for GPU
simulation state (particle position/velocity, fluid fields) rather than
pictures.

Windowing and dialogs:
  - `CxOsOp::SetChromelessWhenMaximized` drops the native maximized border
    strip on Windows, so a maximized window reads as a clean picture.
  - `Cx::open_select_folder_dialog` opens the native folder picker with a
    title and start location, answered by a `FileDialogAction` in the actions
    pass; cancelling is a first-class outcome, not an error.
  - Windows reports a user close the way macos.rs already did.
  - macOS swaps the titlebar container so WindowDragQuery alone decides window
    drags, and the delegates carry a panic shield.
  - `Windows::id_iter()` enumerates window slots generation-correctly.

Headless: the virtual GPU and its rasterizer are substantially rebuilt around
the shader runtime preamble, making `MAKEPAD=headless` render-to-PNG a real
test surface rather than a smoke check. `PerfMonitor::frames_painted()` lets a
scripted driver pace itself to PRESENTED frames instead of queueing passes
faster than the GPU retires them.
2026-08-23 00:43:20 +02:00
Kevin Boos
d223bf4697
Wayland: mark windows as created, fixing dpi override and pass dpi factor (#1188)
* Wayland: mark windows as created, fixing dpi override and pass dpi

Wayland was the only backend that never set `is_created = true` on the Cx
window; every other one does it in `CxOsOp::CreateWindow`.

That flag gates `Cx::dpi_override_scale()`, so every pointer event skipped
the native->layout remap and clicks missed their widgets by the UI zoom
factor. It also gates `get_delegated_dpi_factor()`, which was returning a
hardcoded 1.0 for every draw pass on Wayland, so pixel snapping and the
shader pixel size (SDF AA fringe) used the wrong scale on HiDPI screens.
`SetWindowVisuals` and `set_topmost` were dropped for the same reason.

Also seed the Cx window's geom at creation like the x11 backend does,
otherwise it sits at dpi_factor 0.0 until the first configure arrives.

* convert the seeded wayland geom to layout points and record os_dpi_factor

Seeding the raw native geom left window_geom in native units (and the
os_dpi_factor fallback unset) until the first configure arrived, which is
exactly the pre-configure window the dpi override needs to be correct in.
Do the same conversion the WindowGeomChange path already does.
2026-08-22 13:40:23 +02:00
Kevin Boos
94743e7687
Fingers: mark a second touch on an already-captured area as handled (#1187)
When an area that already captured a touch sees another touch start,
hits() returns a FingerDown for it (so the owner can handle multi-touch
gestures like pinch) but never marked the touch as handled. Widgets
behind the owner could then capture that second touch themselves: the
second finger of a pinch atop a fullscreen overlay could drag-scroll a
list behind it, or even press a button back there.

* mark such a touch as handled if it actually hit-tests within the
  area, mirroring the normal capture path below it
* only do so if nothing else has handled it yet, preserving the claim
  of a child widget that captured it earlier in the same dispatch
2026-08-21 08:49:10 +02:00
Kevin Boos
5a251fba12
Shader: keep the value of an if body's last statement (#1182)
A bare `if cond { ... }` statement whose final body statement is a
non-void expression compiled to an empty `if(cond){ }`: the expression
never reached the generated shader, so its side effects were lost. In
robrix this silently removed every border drawn as

    if self.border_size > 0.0 {
        sdf.stroke(self.border_color, self.border_size)
    }

A call is not written to the output when it is compiled, it is pushed on
the stack as a string, and it only reaches the output via POP_TO_ME.
Since e0a5a23f2 the enclosing statement's POP_TO_ME is emitted as a
standalone opcode at the if's jump target instead of being fused onto the
body's last call, and the shader compiler closes an `IfBody` as soon as
`ip >= target_ip`, so the opcode sitting exactly at the target is never
seen while the body is open. The body's value was then dropped on the
floor by the `no outer phi` arm, whose comment assumed that could not
happen.

* Emit the leftover value as a statement inside the branch when nothing
  consumes it, mirroring the void path a few lines above.
* Add `gpu_stage_4m`, which asserts the call survives into the generated
  shader. It fails without the fix.

The parser side is deliberately untouched: `last_jump_target` is load
bearing for the widget-loss fix that `on_render_emission` guards.
2026-08-19 20:06:12 +02:00
Kevin Boos
6dd0b2c133
Splash: a host-services bridge for mini-apps, plus three VM/parser fixes (#1181)
* Splash: a host-services bridge so isolates can ask for brokered capabilities

Mini-apps are sandboxed hard (fs/run/res stripped, net gated), which also
means they can't do anything real. This adds the one doorway back: a
mod.host module in every isolate whose host.request(service, args, cb)
queues {app_tag, heap_key, req_id, service, args_json} on a thread-local
the EMBEDDING HOST drains and answers (splash_host_respond re-enters the
isolate under the normal budget and calls the callback with {ok, data,
error}). No policy lives in makepad: an undrained request never resolves,
tags are host-assigned (Splash::set_host_tag) so scripts can't spoof who
they are, and host.capabilities() just echoes whatever grant list the
host last pushed (set_host_caps). Callbacks are rooted ScriptFnRefs keyed
by heap, GC'd with the isolate alongside the storage-jail roots.

Also: call_script_fn_with_strings (string args must be minted in the
callee's own heap), 'let host = mod.host' in both Splash prefixes (line
offsets documented per prefix; the net prefix was already one line off),
and mod.cx.quit is now nil'd in isolates - a mini-app could quit the
whole host process with one call.

* script: stop validation from blessing scripts that failed to parse

The parser RECOVERS from errors (dangling else, missing expression), logs
them, sets had_error - which nothing ever read - and hands back a runnable
module. Nothing enters the trap queue, so a host validating with a
captured_errors sink + take_errors() got an empty list and reported
success; three freshly-written mini-apps shipped real parse errors straight
through host_launcher's validate this way, visible only as stray [E] log
lines.

report_error now also records the formatted message on the parser
(ScriptParser::errors), and both eval paths (eval_with_source and the
streaming eval_with_append_source) drain that into bx.captured_errors when
a sink is installed. No sink = logs only, exactly as before. Regression
tests in tests/parse_error_capture.rs, including the exact fn-final
if/else shape that slipped through.

* splash_host: review fixes — is_ok result field, silent surfaces, JSON hardening

Three classes of fixes from an adversarial review of the bridge:

- The result object's success field is now is_ok. 'ok' is the script
  dialect's ok-test KEYWORD, so r.ok never parsed as a field access — every
  callback that read it silently died. A pure-VM regression test
  (fn_ref_callback.rs) now exercises the exact store-callback-then-answer
  flow the bridge uses.

- SplashHostRequest carries may_prompt, set per isolate via
  Splash::set_host_prompts: background surfaces (home-screen widget tiles)
  are marked silent so a host can fail their permission-needing requests
  instead of popping consent dialogs nobody asked for. splash_host_respond
  also reports an outcome now (Delivered / NoCallback / IsolateGone) so
  hosts can log undeliverable answers, and Splash::isolate_heap_key lets a
  host relate a request to a specific widget (IPC fan-out skips the
  sender's own isolate with it).

- heap.to_json hardening: a cyclic object graph (script-buildable, host-
  serialized on every bridge request) recursed to a stack overflow — now a
  depth cap emits null leaves; backslashes were mis-escaped as a single
  backslash (invalid JSON downstream), tab and other control chars weren't
  escaped at all, and a handle serialized as unquoted junk.

* script: a closure's captured varargs must not shadow its own parameters

A call binds positional args by INDEXING the fn object's vec, which holds
declared parameters — but also, past that, any varargs the call received
(unnamed_fn_arg pushes them with a NIL key). A closure captures the scope
it was minted in, so those leftovers ride along ahead of the closure's own
parameters.

Concretely: script timers invoke their callback with one number (the time).
Hand start_timeout a zero-arg closure and that number lands in the scope as
a NIL-keyed vararg; any closure created in that body then binds its FIRST
parameter against the leftover — first failing the typecheck ("arg 0 (nil)
type mismatch: expected number, got object"), and once that was relaxed,
binding the value under the NIL key so the real parameter stayed nil. It
cost a full debug cycle in host_launcher, where every host-service callback
created inside a boot timer silently never ran.

Both binding paths now walk the DECLARED (named) entries in order, so
captured varargs can never be mistaken for a parameter. Regression test in
tests/extra_arg_typecheck.rs reproduces the timer shape exactly.

(Pre-existing and unrelated: widget_tree's test_observe_and_find_single_node
and test_property_patch_no_structural_rebuild fail on upstream dev too.)

* script: stop parse_json silently dropping negative numbers

The tokenizer emits a leading `-` as its own Operator token, and none of
the three JSON value positions (object value, array element, root) had a
case for it. The sign was swallowed — and inside an object the KEY went
with it, because the minus consumed the value slot and the parser resynced
on the next token.

So `{"lat":37.7,"lon":-122.4}` parsed to `{"lat":37.7}`. No error, no
warning, just a missing field. That is how it was found: a mini-app asked
the host where it was, got coordinates with no longitude, and quietly fell
back to a default city. Sub-zero temperatures and negative UTC offsets
(New York is -14400) were being dropped the same way.

A pending-sign flag is applied to the next number in all three positions.
Bare scalar roots stay unsupported ("42" never parsed either) — separate
pre-existing gap, not touched here. Tests in
platform/script/tests/json_negative_numbers.rs.

* splash_storage: let the host raise a single isolate's jail quota

The jail's 16MB whole-app cap is a constant, so "this app may keep more
than the standard amount" had nowhere to live. A per-heap quota map beside
SANDBOX_ROOTS gives the host one, set through Splash::set_storage_quota
and cleared with the isolate like every other per-isolate binding. Script
still can't see or raise its own cap.

Lowering a quota never deletes anything — it just stops further growth —
so revoking the grant is safe on an app that already wrote past the
default.

host_launcher uses this for a `storage-large` permission (64MB), which is
the point: a capability the user can revoke and have it actually mean
something.
2026-08-19 20:06:01 +02:00
Jason Yau
2c49150e3a
Don’t panic on a stale Area::Rect (#1184)
Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-08-19 13:50:04 +02:00
Admin
765f4785fc Let a clean makepad checkout load and compile.
Drop the private sandbox clone from required workspace members
and Studio runnables. Finish the platform_ops VecDeque merge
(push_back / Option remove), land the mip-repeat texture API
the renderer already calls, and unbreak the Q3 importer plus
the godot example template that .gitignore had hidden.
2026-08-18 14:36:05 +02:00
Admin
2a46d2a405 Land platform, studio, and widget infra from rik2.
HTTP progress, OS file drag, RunView controllers, DropDown2, video,
remote process helpers, and the Studio runbook.
2026-08-18 14:23:57 +02: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
941a3de88f
iOS: log panics to NSLog, and don't abort when one escapes (#1178)
A Rust panic raised while handling an iOS event unwinds out of the
`extern "C"` ObjC callback that delivered it, so the process aborts with
a bare SIGABRT. Phase-2 unwinding pops every frame between the panic and
that callback, and the default panic hook writes to stderr, which goes
nowhere on a device. Nothing about the panic survives: the TestFlight
report that prompted this shows only `abort` under
`-[UIWindow _sendTouchesForEvent:]`, with no message and no panic site.

* Install an iOS panic hook in `Cx::event_loop`, mirroring the Android
  one, that logs payload, location, thread and backtrace through
  `error!` (NSLog on iOS). Hooks run at the panic site before unwinding
  starts, so this captures the location the abort would otherwise erase.
* Wrap the app callback in `IosApp::do_callback` in `catch_unwind`.
  That's the one choke point every ObjC callback funnels through, so it
  covers touches, presses, timers, draws and text input at once. It also
  has to be the place: `do_callback` `take()`s the callback and only
  restores it on the normal path, so catching any further out would
  leave the app alive but permanently inert.
* Restore `Cx::executor` in `event_loop`'s callback even when a spawned
  task panics. It's `take()`n the same way, so without this the catch
  above would turn one abort into an `unwrap` panic on every later
  event. The panic still propagates once the executor is back.

macOS and tvOS take the executor the same way, but neither catches, so
the process dies either way and there's nothing to restore it for.
2026-08-14 20:08:52 +02:00
Kevin Boos
4f7abea39e
headless: stop recompiling every shader on every start (#1175)
The headless backend compiles each shader to a cdylib with `rustc -O` and
writes it to a path keyed by the hash of the generated source — then
recompiles all of them from scratch on the next process start, ignoring
what it just wrote. For host_launcher that is 68 shaders and 38.7s of
startup, paid again by every process. A headless test suite starts one
process per test, so it was paying it 55 times.

Reuse the dylib when it's already there, falling back to a compile if it
won't load or has no version symbol (truncated by a killed run, built by
another toolchain). Compiles now land on a pid-private path and get
renamed into place, so a crash can't leave a half-written dylib behind.

Cold start goes 38.7s -> 1.2s.

Two more things that only bite headless:

- The BGRA->RGBAf32 texture conversion cache was rebuilt per frame, so
  the whole glyph atlas was re-converted on every draw: 430ms of a 840ms
  frame. It already carries a signature and honours pending updates, so
  it was always meant to outlive a frame — park it on CxOs. 430ms -> 29ms.

- Headless dpi was pinned at 2.0, i.e. 4x the pixels through a SOFTWARE
  rasteriser. Still 2.0 by default, since a screenshot should match what a
  display shows, but MAKEPAD_HEADLESS_DPI=1 lets a suite that only asserts
  logical geometry do a quarter of the work. Raster 385ms -> 96ms.

Together a steady frame goes 840ms -> 127ms. Also reports texture time in
the MAKEPAD_HEADLESS_PROFILE line, which is how the atlas cost showed up.
2026-08-13 10:03:01 +02:00
Jason Yau
42a61ce7d7
Windows: overlapped main window (#1168)
* Android: silence unused VA/OpenXR warnings

Exclude desktop-only va_dmabuf_modifier from Android/OHOS builds, cfg-gate
gpu_texture pool imports, and fix OpenXR repaint locals unused without Vulkan.

* Move D3D11 texture COM calls into os/windows helpers for windows_strip

* regenerate windows-rs by windows-strip

* Windows: use overlapped custom chrome with extended client area

* Windows: fix overlapped chrome init sizing and avoid DWM work on every NCCALCSIZE

---------

Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-08-13 10:01:52 +02:00
Kevin Boos
457e75a7ef
Area: don't panic on a stale Area::Rect (#1171)
`Area::Rect` holds a `rect_id` into its draw list's `rect_areas`, plus the
`redraw_id` of the draw that created it. A widget that hands out its area and
is then redrawn with fewer rect areas leaves that id past the end, so
`clipped_rect()` panicked with an out-of-bounds index (seen from robrix as
"the len is 105 but the index is 173" while hit-testing a message's children).

`rect()` already guarded this with `redraw_id`; `clipped_rect()`, `abs_to_rel()`
and `set_rect()` did not. All four now check the generation first and use
`get`/`get_mut` instead of indexing, falling back to the same values they
already return for an unknown area.
2026-08-12 21:20:37 +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
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
Kevin Boos
5f74ffec15
macos: fix present-gate watchdog race that could block the runloop (#1167)
* macos: fix present-gate watchdog race that could re-wedge the runloop

The watchdog's `store(0)` assumed overdue presented-handlers were lost,
but they are usually just late: each one that fired after the reset
stole a decrement belonging to a newer frame, so `in_flight_presents`
could transiently read lower than the true number of outstanding
drawables. A deficit coinciding with a compositor consumption stall let
the gate open onto an exhausted pool, and with
`allowsNextDrawableTimeout:NO` the `nextDrawable` call blocked the
single runloop forever (paints, input, and the watchdog all share it),
until an external window resize freed a drawable.

* pack a reset generation into the high 32 bits of
  `in_flight_presents` (count stays in the low 32)
* the watchdog reset now bumps the generation as it zeroes the count
* each presented-handler captures its generation at arm time and
  no-ops if a reset happened since, so late handlers can no longer
  steal decrements and the gate closes exactly when the pool is full

* macOS: gate the present pacer at 3 in-flight presents, not 2

The drawable pool holds three, so acquiring one with two still outstanding
can never block. Gating at 2 made the pipeline effectively single-buffered:
when a GPU frame ran longer than one beat it locked into a submit-wait-submit
resonance (~20fps at 0.6ms of CPU work), and it tripped the 250ms stuck-gate
watchdog a full frame earlier than it needed to.

`macos_freeze_android_gc` has gated at 3 since `ee96a23a3`; this brings the
branch that robrix pins in line, using a named `PRESENT_GATE_IN_FLIGHT` const
so both skip sites stay together.

* macOS: stop a stale `occlusionState` from freezing the UI forever

`handle_repaint` skips the beat while the window reports itself hidden, and
that skip was the one path with no timeout: its only exit was the flag
flipping back to visible. macOS can leave the visible bit clear on a window
that is really on screen (it comes back after a resize, which is why resizing
a wedged window has always cured it), and when that happens we skip every
beat forever. The runloop stays alive and input keeps working, so it looks
exactly like a frozen UI rather than a hung app.

* Bound the skip. After `OCCLUSION_PROBE_INTERVAL` (2s) of skipping we fall
  through and present anyway. If the flag was stale we recover on our own; if
  the window really is hidden we spend one frame per 2s to find that out.

* Make the pool rebuild actually rebuild. The watchdog nudged `drawableSize`
  to `h + 1` and back to `h` in a single implicit CATransaction, so the layer
  only ever observed the net change of nothing and could keep its stuck
  drawables. `rebuild_drawable_pool()` now commits and flushes each size
  separately, the way a real resize does. This also has to be right before
  the occlusion probe is safe, since the probe deliberately presents a frame
  the compositor may not consume.

* Log the abnormal paths. Every recurrence so far left no evidence of which
  path fired, so the stuck gate now says so once, staying quiet while the
  window is hidden (the probe trips that path by design every 2s).

Verified by running robrix against this branch: the probe path fires and
recovers with no panic, the UI renders normally, and a healthy foreground
run logs nothing.

* macOS: give back the in-flight count when a pass bails before presenting

`handle_repaint` acquires the drawable, bumps `in_flight_presents` and arms the
presented handler, and only then calls `draw_pass`. `draw_pass` has three early
returns (no draw list, nil MTKView descriptor, degenerate pass rect) that all
bail before `presentDrawable`, so the handler never fires and the count is over
by one forever. Two of those and the gate is permanently closed, leaving the
250ms watchdog holding the app up at a few frames a second.

`draw_pass` now returns whether it reached the present, and the caller undoes
its accounting when it didn't, guarded on the same generation as the handler so
a late handler still can't double-decrement. The other callers (iOS, tvOS,
stdin) ignore the result, which is fine since they don't count presents.

* macOS: fix two window delegate selectors that never fired

`windowChangedScreen:` and `windowChangedBackingProperties:` aren't
NSWindowDelegate selectors; the real names are `windowDidChangeScreen:` and
`windowDidChangeBackingProperties:`. AppKit never called either, so
`window_did_change_screen` and `window_did_change_backing_properties` have been
dead code, and moving a window between displays or changing its backing scale
never produced a geom event. Every other selector in that block already uses
the correct `windowDid` spelling.
2026-08-11 20:19:16 +02:00
Jason Yau
259b84ed23
fix: resize swap chain on DPI change (#1169)
Co-authored-by: jasonqiu <jasonqiuchen@outlook.com>
2026-08-11 19:33:44 +02:00
Admin
ff604e53a3 splash: string-compare early-out in deep_eq, thread stack prealloc, bench additions
- deep_eq returns false immediately when both sides are string-like and
  the bit-compare failed (strings are interned, so bit-equality IS string
  equality) — trims the type-check chain on the hot failed-compare path
  of string-tag dispatch (a.kind == "...")
- pre-reserve thread stacks (value stack, scopes, calls, mes, loops)
- splash_bench: string_cmp workload, arith_hostmode (instruction limit +
  run budget installed, the game-host configuration; measured: the two
  per-instruction counters cost ~nothing, branch prediction hides them),
  BENCH_ONLY profiler filter

Tried and rejected: batching consecutive value-pushes in run_core into
one dispatch iteration — the scan overhead outweighed the per-value
limit/budget checks it skipped (they were already free); reverted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:59:05 +02:00
Admin
0cd882f793 splash optimisation work
Interpreter perf pass on platform/script, semantics-preserving:

- ValueMap hybrid small-map: object maps linear-scan a vec below 16
  entries and spill to a HashMap above (scopes/call-args/small objects
  never hash)
- compound scope assigns (+=, -=, ...) locate their slot with ONE
  proto-chain walk instead of a read walk plus a write walk
- for-loop iterations clear and reuse the iteration scope in place when
  nothing captured it (closure capture -> REFFED -> fresh scope); loop
  scopes no longer copy the parent scope vec
- 'for i in a..b' captures end/step at loop entry when the range object
  is un-REFFED; stored (mutable) ranges keep live per-iteration lookups
- trap error-queue + trap.on polled via one Cell<u8> bitfield per
  instruction instead of a RefCell borrow + Option<enum> Cell read
- ADD checks the number fast path before the string check

Measured (splash_bench, medians of 3 A/B runs): array_iter -27%,
method_call -21%, array_index -21%, for-range loops -19%, object_churn
and field r/w -8..9%, fib -7%, sandbox-style composite tick -6%.

Adds platform/script/test/src/bin/splash_bench.rs (run with
cargo run -p makepad-script-test --bin splash_bench --release,
BENCH_ONLY=<name> loops one workload for profilers) and parity asserts
for the touched edges: mid-loop range end/step mutation stays live on
stored ranges, closures pin their iteration's scope, selective capture
does not leak across reused scopes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:07:40 +02:00
Kevin Boos
50bfc8ad16
macos: fix UI freezing after the window has been fully hidden (#1166)
* 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.

* Layouter: keep word-boundary ellipsis on continuation rows

Grapheme-filling the last permitted row is wrong when that row is an
empty continuation: grapheme layout force-places a grapheme wider than
the row's remnant past the width limit with the truncation flag unset,
drawing overflowing text with no ellipsis. Finishing the row instead
truncates within bounds, so continuation rows keep the word-boundary
behavior.

* DrawText: scale the Fit max bound into layout units

The layouter works in unscaled units and row widths are multiplied by
font_scale on output, but the resolved Fit max bound was passed through
in physical units — the same mismatch max_layout_width_for_walk already
divides away. Any font_scale above 1 on a bounded label laid text out
past the clamped clip, reintroducing the sliced-tail bug the bound is
there to prevent.

* Turtle: add Base.Line, a Fit bound relative to the available line width

A static relative bound cannot express what an inline widget's inner
text actually has room for: that depends on the line the widget lands
on, its own leading geometry (icons, padding, spacing), and the
trailing insets after the text. Base.Line resolves a Fit max bound to
exactly that, with the enclosing line's flow selecting between two
measurements that are each final at the moment they are taken:

- A wrapping line can relocate the widget whole onto a fresh row, so
  the bound is what a fresh row offers. Content sized to it either
  fits where it is, or fits the row the widget is relocated to.
- A non-wrapping line — including one held non-wrapping by the
  inline-content clamp on the last permitted row — keeps the widget
  in place, so the bound is the remnant up to the line's right edge.

The measurement runs from the current turtle's content origin rather
than its pen, because a Fit max bound is evaluated twice: before the
content is laid out, and again in compute_final_size when the turtle
closes. A pen-relative measurement would collapse the closing clamp
to the leftover width and clip the content it just laid out.

* uizoo: fixtures for Base.Line-bounded pill labels

The four behaviors the line bound guarantees: a long name ellipsizing
at a narrow container's edge, a mid-line pill relocating whole to the
next row at full row width, a pill held on the last clamped row
squeezing visibly into the remnant, and a short name in a wide
container rendering untruncated.

* Turtle/DrawText: keep tiny Fit max bounds from slicing or inverting clips

A line-remnant bound can resolve to nearly zero when the last permitted
row is already full where an inline widget's text begins. Two guards
keep that degenerate range within the whole-glyph truncation contract:

- The Fit max clamp in compute_final_size floors at zero, so a bound
  smaller than the walk's margins cannot produce a negative width and
  an inverted clip (a pill rendering as bare chrome with no title and
  no ellipsis anywhere).
- DrawText treats a bound too narrow for the truncation ellipsis
  itself as 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, letting an enclosing flow's
  inline-content clamp hide the widget and draw the ellipsis itself.

* macos: fix UI freezing after the window has been fully hidden

The present-gated frame pacing from commit `c0b851a57`
introduce a bug here, which basically causes vsync or other paint
events to never reach to UI after the macOS window is re-shown.
(moved to the foreground after having been bkgd'd for a while)

Fixes for this:
* Skip presenting  while the window's `occlusionState` lacks
  `NSWindowOcclusionStateVisible`, keeping the pass dirty so the first
  visible beat repaints. This also avoids `nextDrawable` hard-blocking
  the main thread (we set `allowsNextDrawableTimeout: NO`) on a pool
  exhausted by presents the compositor will never consume.
* Add a 250ms watchdog to force the event handlers to start again
  if they locked up somehow.
* On a watchdog timer, nudge `drawableSize` to rebuild the drawable pool
  the same way a manual resize does, reclaiming stuck drawables so the
  recovery present can't block on an exhausted pool.

* android: run script-VM gc after paint, like every other backend

commit `77cacb846` added the post-paint `gc()` sweep to the iOS, Windows, X11,
and Wayland event loops to match macOS, but Android never got one.

Now we do it on Android too.
2026-08-10 12:30:20 +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