Compare commits

...

7 commits

Author SHA1 Message Date
Kevin Boos
4b25a1bf1e
cargo-makepad: support custom iOS and tvOS Info.plist entries (#1221)
* cargo-makepad: declare native speech recognition metadata

* cargo-makepad: make Apple plist customization opt-in

* cargo-makepad: use macOS tools for plist overlays
2026-09-10 07:53:29 +02:00
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
ab7e2230d8
Splash: let the host pick the theme its isolates boot with (#1211)
* an isolate's widget prelude snapshots `mod.theme` at boot, which was always the default dark theme even under a light host, so default labels and pressed buttons went light-on-light
* `set_splash_theme(SplashTheme)` names the theme applied between `theme_mod` and `widgets_mod` for every new isolate
2026-09-03 22:03:44 +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
37 changed files with 3395 additions and 395 deletions

View file

@ -140,6 +140,21 @@ fn derive_script_impl_inner(
.iter() .iter()
.any(|a| a.name == "live" || a.name == "apply_default") .any(|a| a.name == "live" || a.name == "apply_default")
{ {
// A field whose canonical mutation path is an imperative
// setter (`#[visible]` → `set_visible`, `#[imperative]` for
// the rest) shares its storage with the DSL value, so a
// re-walk that carries no authored change must leave it alone.
// Otherwise `script_mod` re-runs that exist only to re-bake
// heap primitives — every safe-area inset change, i.e. every
// Android system-bar hide and every rotation — silently put
// the DSL default back over the runtime state.
let imperative = field
.attrs
.iter()
.any(|a| a.name == "imperative" || a.name == "visible");
if imperative {
tb.add("if !apply.preserves_runtime_state() {");
}
tb.add("{ let mut __field_value = vm.bx.heap.value_for_apply(value, id!(") tb.add("{ let mut __field_value = vm.bx.heap.value_for_apply(value, id!(")
.ident(&field.name) .ident(&field.name)
.add(").into(), apply);"); .add(").into(), apply);");
@ -159,6 +174,9 @@ fn derive_script_impl_inner(
.add(",vm, apply, scope, v);"); .add(",vm, apply, scope, v);");
tb.add("}"); tb.add("}");
tb.add("}"); tb.add("}");
if imperative {
tb.add("}");
}
} }
if field if field
.attrs .attrs

View file

@ -36,6 +36,7 @@ pub fn script_err_gen(input: TokenStream) -> TokenStream {
source, source,
new, new,
live, live,
imperative,
rust, rust,
pick, pick,
splat, splat,

View file

@ -132,6 +132,21 @@ pub enum Apply {
/// is the new source of truth and template values should override /// is the new source of truth and template values should override
/// any prior runtime state. /// any prior runtime state.
Reload, Reload,
/// `script_mod` was re-run and the fresh template re-walked, but the DSL
/// source did NOT change — the re-run exists only to re-bake heap
/// primitives that are evaluated at `script_mod` time and are therefore
/// unreachable to `ScriptReapply` (canonical case:
/// `mod.widgets.SAFE_INSET_PAD_*` after a safe-area inset change, which
/// `cx.request_live_edit()` triggers on every Android system-bar hide and
/// every rotation).
///
/// Structurally this is a reload — children lists are rebuilt and the
/// `#[source]` object is re-bound, because the template really is a new
/// object. Semantically it is a `ScriptReapply` — nothing the developer
/// wrote changed, so imperative runtime state must survive. Routing it
/// through `Reload` is what used to wipe every `set_text`, every
/// `set_visible`, and every animator state on each inset change.
Rebake,
/// Heap-mutation broadcast triggered by `cx.request_script_reapply()` /// Heap-mutation broadcast triggered by `cx.request_script_reapply()`
/// (e.g. preference change, safe-area inset change). The template has /// (e.g. preference change, safe-area inset change). The template has
/// NOT changed — the same cached `app_value` is being re-walked so /// NOT changed — the same cached `app_value` is being re-walked so
@ -150,6 +165,7 @@ impl Apply {
match self { match self {
Self::New => true, Self::New => true,
Self::Reload => true, Self::Reload => true,
Self::Rebake => true,
Self::ScriptReapply => true, Self::ScriptReapply => true,
Self::Eval => true, Self::Eval => true,
_ => false, _ => false,
@ -161,11 +177,13 @@ impl Apply {
/// creates temporary objects that would become dangling after GC. /// creates temporary objects that would become dangling after GC.
/// Excludes ScriptReapply because the template hasn't changed — /// Excludes ScriptReapply because the template hasn't changed —
/// the same source object is being re-walked, so re-binding it is /// the same source object is being re-walked, so re-binding it is
/// unnecessary work. /// unnecessary work. Includes `Rebake`: `script_mod` re-ran, so the
/// source object really is new even though the DSL text is unchanged.
pub fn is_template_apply(&self) -> bool { pub fn is_template_apply(&self) -> bool {
match self { match self {
Self::New => true, Self::New => true,
Self::Reload => true, Self::Reload => true,
Self::Rebake => true,
_ => false, _ => false,
} }
} }
@ -192,13 +210,15 @@ impl Apply {
pub fn is_reload(&self) -> bool { pub fn is_reload(&self) -> bool {
match self { match self {
Self::Reload => true, Self::Reload => true,
Self::Rebake => true,
Self::ScriptReapply => true, Self::ScriptReapply => true,
_ => false, _ => false,
} }
} }
/// True only for `Apply::Reload` — a LiveEdit-driven hot-reload where /// True only for `Apply::Reload` — a LiveEdit-driven hot-reload where
/// the DSL itself changed. Excludes `Apply::ScriptReapply`. Use this /// the DSL itself changed. Excludes `Apply::ScriptReapply` and
/// `Apply::Rebake` (a `script_mod` re-run with unchanged source). Use this
/// (rather than `is_reload`) when behavior should fire only when the /// (rather than `is_reload`) when behavior should fire only when the
/// template source has actually changed (e.g. re-running script_mod /// template source has actually changed (e.g. re-running script_mod
/// scaffolding, invalidating template-derived caches). /// scaffolding, invalidating template-derived caches).
@ -210,10 +230,11 @@ impl Apply {
} }
/// True only for `Apply::ScriptReapply` — a `request_script_reapply`-driven /// True only for `Apply::ScriptReapply` — a `request_script_reapply`-driven
/// re-walk where the template has NOT changed. Field impls whose value /// re-walk where the template has NOT changed.
/// should not be clobbered by template defaults on this kind of re-walk ///
/// should early-return when this is true (canonical example: /// Prefer `preserves_runtime_state()` when the question is "may I clobber
/// `ArcStringMut::script_apply`). /// the runtime value here?"; this predicate exists for the narrower cases
/// that care about the specific trigger.
pub fn is_script_reapply(&self) -> bool { pub fn is_script_reapply(&self) -> bool {
match self { match self {
Self::ScriptReapply => true, Self::ScriptReapply => true,
@ -221,6 +242,40 @@ impl Apply {
} }
} }
/// True for the walks that follow a `script_mod` re-run (`Reload`,
/// `Rebake`). The re-run builds a fresh object heap, so any script object
/// reference a widget cached during an earlier walk is now dangling —
/// `Animator`'s cached state objects are the canonical example. Code
/// holding such references must re-resolve them from the incoming value
/// rather than reuse what it stored.
///
/// `ScriptReapply` is excluded: it re-walks the same cached `app_value`,
/// so cached references stay alive.
pub fn follows_script_rerun(&self) -> bool {
match self {
Self::Reload => true,
Self::Rebake => true,
_ => false,
}
}
/// True for the re-apply walks where nothing the developer wrote changed,
/// so template values must NOT overwrite state that was set through an
/// imperative setter (`Label::set_text`, `set_visible`, animator state).
/// Field impls whose canonical mutation path is such a setter should
/// early-return when this is true (canonical example:
/// `ArcStringMut::script_apply`).
///
/// Excludes `Apply::Reload`, where the DSL genuinely changed and the new
/// template is meant to win.
pub fn preserves_runtime_state(&self) -> bool {
match self {
Self::ScriptReapply => true,
Self::Rebake => true,
_ => false,
}
}
pub fn is_animate(&self) -> bool { pub fn is_animate(&self) -> bool {
match self { match self {
Self::Animate => true, Self::Animate => true,

View file

@ -44,6 +44,12 @@ impl<'a> ScriptVm<'a> {
return; return;
}; };
self.bx.threads.cur().slot_base = call.prev_slot_base; self.bx.threads.cur().slot_base = call.prev_slot_base;
// A root frame's last scope holds everything the body defined after
// a shadowing let; keep it for host->script lookups.
if call.return_ip.is_none() {
let end = self.bx.threads.cur_ref().scopes.last().copied();
self.bx.threads.cur().root_end_scope = end.map(|s| self.bx.heap.new_object_ref(s));
}
self.bx self.bx
.threads .threads
.cur() .cur()

View file

@ -340,13 +340,14 @@ script_primitive!(
) { ) {
// Same rationale as `ArcStringMut::script_apply`: text-bearing fields // Same rationale as `ArcStringMut::script_apply`: text-bearing fields
// (`TextInput.text`, `TextInput.empty_text`, etc.) are canonically // (`TextInput.text`, `TextInput.empty_text`, etc.) are canonically
// mutated through imperative setters at runtime. A ScriptReapply // mutated through imperative setters at runtime. A re-walk that
// walk fired by `cx.request_script_reapply()` (preference broadcast, // carries no authored change (`cx.request_script_reapply()` preference
// safe-area inset change) would otherwise clobber that runtime value // broadcast, or the `script_mod` re-run a safe-area inset change
// with the stale DSL literal. Strings are not part of the shared- // forces) would otherwise clobber that runtime value with the stale
// heap-object propagation pipeline (which uses `Size`/numerics), so // DSL literal. Strings are not part of the shared-heap-object
// bailing here is safe. // propagation pipeline (which uses `Size`/numerics), so bailing here
if apply.is_script_reapply() { // is safe.
if apply.preserves_runtime_state() {
return; return;
} }
self.clear(); self.clear();

View file

@ -111,6 +111,9 @@ pub struct ScriptThread {
/// Base of the CURRENT slot frame (see CallFrame::prev_slot_base). /// Base of the CURRENT slot frame (see CallFrame::prev_slot_base).
pub(crate) slot_base: usize, pub(crate) slot_base: usize,
pub(crate) instruction_limit_remaining: Option<usize>, pub(crate) instruction_limit_remaining: Option<usize>,
/// The innermost scope when the root frame returned, kept alive so
/// the body can hand it to host->script calls.
pub(crate) root_end_scope: Option<ScriptObjectRef>,
pub trap: ScriptTrapInner, pub trap: ScriptTrapInner,
//pub(crate) last_err: ScriptValue, //pub(crate) last_err: ScriptValue,
pub(crate) json_parser: JsonParserThread, pub(crate) json_parser: JsonParserThread,
@ -122,6 +125,7 @@ impl ScriptThread {
Self { Self {
thread_id, thread_id,
is_paused: false, is_paused: false,
root_end_scope: None,
//last_err: NIL, //last_err: NIL,
// pre-reserve the hot stacks so steady-state execution never // pre-reserve the hot stacks so steady-state execution never
// pays Vec growth in the interpreter loop // pays Vec growth in the interpreter loop

View file

@ -39,7 +39,9 @@ pub trait ScriptHook {
// Widgets that need to differentiate can branch on // Widgets that need to differentiate can branch on
// `apply.is_live_edit_reload()` or `apply.is_script_reapply()` // `apply.is_live_edit_reload()` or `apply.is_script_reapply()`
// inside the hook. // inside the hook.
Apply::Reload | Apply::ScriptReapply => self.on_before_reload_scoped(vm, scope), Apply::Reload | Apply::Rebake | Apply::ScriptReapply => {
self.on_before_reload_scoped(vm, scope)
}
_ => (), _ => (),
} }
} }
@ -62,7 +64,9 @@ pub trait ScriptHook {
) { ) {
match apply { match apply {
Apply::New => self.on_after_new_scoped(vm, scope), Apply::New => self.on_after_new_scoped(vm, scope),
Apply::Reload | Apply::ScriptReapply => self.on_after_reload_scoped(vm, scope), Apply::Reload | Apply::Rebake | Apply::ScriptReapply => {
self.on_after_reload_scoped(vm, scope)
}
_ => (), _ => (),
} }
self.on_alive() self.on_alive()

View file

@ -64,6 +64,9 @@ pub struct ScriptBody {
pub parser: ScriptParser, pub parser: ScriptParser,
pub scope: ScriptObjectRef, pub scope: ScriptObjectRef,
pub me: ScriptObjectRef, pub me: ScriptObjectRef,
/// The scope the body ended in, once it has run: `let`/`fn` that
/// shadow a name open child scopes below `scope`.
pub end_scope: Option<ScriptObjectRef>,
pub checkpoint: Option<ParserCheckpoint>, pub checkpoint: Option<ParserCheckpoint>,
pub source_len: usize, pub source_len: usize,
} }
@ -972,7 +975,11 @@ impl<'a> ScriptVm<'a> {
self.bx.threads.cur().trap.ip.index = 0; self.bx.threads.cur().trap.ip.index = 0;
// the main interpreter loop // the main interpreter loop
self.run_core() let value = self.run_core();
if let Some(end) = self.bx.threads.cur().root_end_scope.take() {
self.bx.code.bodies.borrow_mut()[body_id as usize].end_scope = Some(end);
}
value
} }
/// Checks if the value has an apply transform and calls it, returning the transformed value. /// Checks if the value has an apply transform and calls it, returning the transformed value.
@ -1305,6 +1312,7 @@ impl<'a> ScriptVm<'a> {
parser: ScriptParser::default(), parser: ScriptParser::default(),
scope, scope,
me, me,
end_scope: None,
checkpoint: None, checkpoint: None,
source_len: 0, source_len: 0,
}; };

View file

@ -0,0 +1,53 @@
//! Host->script hook lookups against a Splash-shaped body: the script is
//! wrapped in an auto-closed object literal, and a `let`/`fn` that shadows
//! an existing name opens a child scope the module scope cannot see into.
//! The body remembers the scope it ended in for exactly that.
use makepad_script::*;
fn test_vm() -> ScriptVm<'static> {
let host = Box::leak(Box::new(0i32));
let std = Box::leak(Box::new(0i32));
ScriptVm { host, std, bx: Box::new(ScriptVmBase::new()) }
}
fn scopes(vm: &mut ScriptVm, file: &str) -> (ScriptObject, Option<ScriptObject>) {
let bodies = vm.bx.code.bodies.borrow();
bodies.iter().find_map(|body| match &body.source {
ScriptSource::Mod(m) if m.file == file => {
Some((body.scope.as_object(), body.end_scope.as_ref().map(|s| s.as_object())))
}
_ => None,
}).expect("body")
}
#[test]
fn hooks_resolve_in_the_scope_the_body_ended_in() {
let mut vm = test_vm();
vm.bx.captured_errors = Some(Vec::new());
// `fs` is defined twice: the second `let` shadows and opens a child scope.
let code = "let fs = 1\n{height: 1, let fs = 2\nfn before(){ 7 }\nfn on_x(){ 42 }\n{}\n";
vm.with_instruction_limit(500_000, |vm| {
vm.eval(ScriptMod {
cargo_manifest_path: String::new(),
module_path: String::new(),
file: "hook_scope".to_string(),
line: 0, column: 0,
code: code.to_string(),
values: vec![],
})
});
let errors = vm.take_errors();
assert!(errors.is_empty(), "eval errored: {errors:?}");
let (module, end) = scopes(&mut vm, "hook_scope");
let end = end.expect("the body recorded the scope it ended in");
let from_module = vm.bx.heap.scope_value(module, id!(on_x), NoTrap);
assert!(from_module.is_nil() || from_module.is_err(), "the module scope should not see past the shadowing let");
let on_x = vm.bx.heap.scope_value(end, id!(on_x), NoTrap);
assert!(!on_x.is_nil() && !on_x.is_err(), "on_x not found in the end scope");
let result = vm.with_instruction_limit(500_000, |vm| vm.call(on_x, &[]));
assert_eq!(format!("{result:?}"), "42", "on_x returned {result:?}");
let before = vm.bx.heap.scope_value(end, id!(before), NoTrap);
assert!(!before.is_nil() && !before.is_err(), "before not found in the end scope");
}

View file

@ -287,6 +287,9 @@ macro_rules! _app_main_event_closure {
if let Event::LiveEdit = event { if let Event::LiveEdit = event {
let mut app_ref = app.borrow_mut(); let mut app_ref = app.borrow_mut();
if let Some(app) = app_ref.as_mut() { if let Some(app) = app_ref.as_mut() {
// `Reload` when the DSL changed on disk, `Rebake` when
// `script_mod` only re-ran to pick up new heap primitives.
let live_edit_apply = cx.live_edit_apply();
cx.with_vm(|vm| { cx.with_vm(|vm| {
let value = vm.with_reload(|vm| <$app as AppMain>::script_mod(vm)); let value = vm.with_reload(|vm| <$app as AppMain>::script_mod(vm));
if let Some(obj) = value.as_object() { if let Some(obj) = value.as_object() {
@ -295,7 +298,7 @@ macro_rules! _app_main_event_closure {
<$app as $crate::ScriptApply>::script_apply( <$app as $crate::ScriptApply>::script_apply(
app, app,
vm, vm,
&$crate::Apply::Reload, &live_edit_apply,
&mut $crate::Scope::empty(), &mut $crate::Scope::empty(),
value, value,
); );

View file

@ -92,11 +92,11 @@ impl ScriptApply for ArcStringMut {
) { ) {
// Same rationale as `String::script_apply` (see `prims.rs`): // Same rationale as `String::script_apply` (see `prims.rs`):
// text-bearing fields are canonically mutated by imperative setters // text-bearing fields are canonically mutated by imperative setters
// (`Label::set_text`, `Button::set_text`, etc.), so a ScriptReapply // (`Label::set_text`, `Button::set_text`, etc.), so a re-walk that
// walk should not clobber the runtime value with the stale DSL // carries no authored change should not clobber the runtime value with
// literal. `Apply::Reload` (LiveEdit) still applies — DSL just // the stale DSL literal. Only `Apply::Reload` gets to win, because
// changed, so the new template wins. // there the DSL itself changed.
if apply.is_script_reapply() { if apply.preserves_runtime_state() {
return; return;
} }
// Convert to owned String using the heap's cast method // Convert to owned String using the heap's cast method

View file

@ -145,16 +145,25 @@ pub struct Cx {
pub pending_script_reapply: bool, pub pending_script_reapply: bool,
/// When true, the next event-loop iteration will fire `Event::LiveEdit`, /// When true, the next event-loop iteration will fire `Event::LiveEdit`,
/// which re-runs `script_mod` and re-applies with `Apply::Reload`. Use /// which re-runs `script_mod` and re-applies with `Apply::Rebake`. Use
/// this when a primitive heap value (e.g. `mod.widgets.SAFE_INSET_PAD_TOP`) /// this when a primitive heap value (e.g. `mod.widgets.SAFE_INSET_PAD_TOP`)
/// has changed and needs to be re-baked into widget definitions that /// has changed and needs to be re-baked into widget definitions that
/// reference it via expressions like `top: (mod.widgets.SAFE_INSET_PAD_TOP)` /// reference it via expressions like `top: (mod.widgets.SAFE_INSET_PAD_TOP)`
/// — those expressions are only re-evaluated when `script_mod` re-runs. /// — those expressions are only re-evaluated when `script_mod` re-runs.
/// `Apply::Reload` clobbers runtime widget state (animator values, etc.), /// The re-run is still a full-tree walk, so prefer
/// so prefer `pending_script_reapply` whenever the change can be modeled /// `pending_script_reapply` whenever the change can be modeled as a
/// as a shared-heap-object mutation instead. /// shared-heap-object mutation instead.
pub pending_live_edit_request: bool, pub pending_live_edit_request: bool,
/// Which `Apply` variant the pending `Event::LiveEdit` should re-apply
/// the freshly re-run `script_mod` value with. A file-change hot reload
/// means the DSL actually changed, so the new template wins
/// (`Apply::Reload`). A `request_live_edit()` re-bake did not change the
/// DSL, so imperative runtime state must survive (`Apply::Rebake`) —
/// otherwise every safe-area inset change wipes each `set_text`,
/// `set_visible` and animator state in the tree.
pub(crate) live_edit_apply: Apply,
/// `WindowGeomChange` events queued up during an event dispatch. /// `WindowGeomChange` events queued up during an event dispatch.
pub(crate) pending_window_geom_changes: Vec<WindowGeomChangeEvent>, pub(crate) pending_window_geom_changes: Vec<WindowGeomChangeEvent>,
pub(crate) clear_hover_queued: bool, pub(crate) clear_hover_queued: bool,
@ -525,6 +534,7 @@ impl Cx {
display_context: Default::default(), display_context: Default::default(),
pending_script_reapply: false, pending_script_reapply: false,
pending_live_edit_request: false, pending_live_edit_request: false,
live_edit_apply: Apply::Reload,
pending_window_geom_changes: Default::default(), pending_window_geom_changes: Default::default(),
clear_hover_queued: false, clear_hover_queued: false,

View file

@ -576,19 +576,26 @@ impl Cx {
/// Requests a deferred `Event::LiveEdit` on the next event-loop iteration. /// Requests a deferred `Event::LiveEdit` on the next event-loop iteration.
/// The handler re-runs `script_mod` (re-evaluating any expressions that /// The handler re-runs `script_mod` (re-evaluating any expressions that
/// reference primitive heap values like `mod.widgets.SAFE_INSET_PAD_TOP`) /// reference primitive heap values like `mod.widgets.SAFE_INSET_PAD_TOP`)
/// and then re-applies the widget tree with `Apply::Reload`. /// and then re-applies the widget tree with `Apply::Rebake`, which
/// preserves imperative runtime state since the DSL itself is unchanged.
/// ///
/// Use this only when a primitive heap value has changed and that value /// Use this only when a primitive heap value has changed and that value
/// is consumed by `script_mod!` block expressions — those expressions are /// is consumed by `script_mod!` block expressions — those expressions are
/// not re-evaluated by `Apply::ScriptReapply`. `Apply::Reload` walks /// not re-evaluated by `Apply::ScriptReapply`. The re-run is still a full
/// clobber runtime widget state (animator values, dynamic instance /// tree walk, so prefer `request_script_reapply` when the change can be
/// buffers, user-typed text in widgets that don't early-return on
/// LiveEdit), so prefer `request_script_reapply` when the change can be
/// modeled as a shared-heap-object mutation instead. /// modeled as a shared-heap-object mutation instead.
pub fn request_live_edit(&mut self) { pub fn request_live_edit(&mut self) {
self.pending_live_edit_request = true; self.pending_live_edit_request = true;
} }
/// The `Apply` variant the currently dispatching `Event::LiveEdit` should
/// be re-applied with — `Reload` for a file-change hot reload, `Rebake`
/// for a `request_live_edit()` re-bake. `app_main!` reads this; app code
/// has no reason to.
pub fn live_edit_apply(&self) -> crate::makepad_script::Apply {
self.live_edit_apply.clone()
}
/// Remap an absolute coordinate from the OS-reported logical-point space /// Remap an absolute coordinate from the OS-reported logical-point space
/// into the layout's logical-point space when a `dpi_override` is active /// into the layout's logical-point space when a `dpi_override` is active
/// on the given window. No-op if no override is set or `os_dpi_factor` /// on the given window. No-op if no override is set or `os_dpi_factor`

View file

@ -487,6 +487,12 @@ impl CxFingers {
self.captures.iter().find(|v| v.area == area).is_some() self.captures.iter().find(|v| v.area == area).is_some()
} }
/// Whether `digit_id` is held by an area other than `area`, so a
/// capture-overload hit can tell a press a child already owns.
pub fn is_digit_captured_elsewhere(&self, digit_id: DigitId, area: Area) -> bool {
self.captures.iter().any(|v| v.digit_id == digit_id && v.area != area)
}
/// The area that captured the touch with the given uid, if any. /// The area that captured the touch with the given uid, if any.
/// Lets a raw `Event::LongPress` handler check which widget owns the press. /// Lets a raw `Event::LongPress` handler check which widget owns the press.
pub fn touch_capture_area(&self, uid: u64) -> Option<Area> { pub fn touch_capture_area(&self, uid: u64) -> Option<Area> {

View file

@ -245,8 +245,8 @@ pub use {
web_socket::{WebSocket, WebSocketMessage}, web_socket::{WebSocket, WebSocketMessage},
window::{ window::{
CxWindowPool, MacosWindowChrome, MacosWindowConfig, MacosWindowKind, MacosWindowLevel, CxWindowPool, MacosWindowChrome, MacosWindowConfig, MacosWindowKind, MacosWindowLevel,
ScriptWindowHandle, WindowBackdrop, WindowHandle, WindowIcon, WindowIconBuffer, ScriptWindowHandle, WaylandDecorationPreference, WindowBackdrop, WindowHandle,
WindowId, WindowVisuals, WindowIcon, WindowIconBuffer, WindowId, WindowVisuals,
}, },
xr_tsdf::{ xr_tsdf::{
ChunkKey, SparseTsdGridReadSnapshot, SparseTsdReadChunk, TsdfPublishedSnapshot, ChunkKey, SparseTsdGridReadSnapshot, SparseTsdReadChunk, TsdfPublishedSnapshot,

View file

@ -890,6 +890,7 @@ impl Cx {
LiveEditTrigger::FileChange => { LiveEditTrigger::FileChange => {
self.draw_shaders.reset_for_live_reload(); self.draw_shaders.reset_for_live_reload();
self.pending_script_reapply = false; self.pending_script_reapply = false;
self.live_edit_apply = crate::makepad_script::Apply::Reload;
self.call_event_handler(&Event::LiveEdit); self.call_event_handler(&Event::LiveEdit);
self.redraw_all(); self.redraw_all();
if self.pending_script_reapply { if self.pending_script_reapply {
@ -904,6 +905,10 @@ impl Cx {
// app-level handler that re-broadcasts sets a fresh flag // app-level handler that re-broadcasts sets a fresh flag
// that lands on the next tick. // that lands on the next tick.
self.pending_script_reapply = false; self.pending_script_reapply = false;
// The DSL did not change, so re-apply with `Rebake`: the
// re-run only exists to pick up new `SAFE_INSET_PAD_*`
// values, and imperative runtime state must survive it.
self.live_edit_apply = crate::makepad_script::Apply::Rebake;
self.call_event_handler(&Event::LiveEdit); self.call_event_handler(&Event::LiveEdit);
self.redraw_all(); self.redraw_all();
} }

View file

@ -281,6 +281,31 @@ impl OpenglCx {
} }
} }
/// Makes this context current on `egl_surface` for both drawing and reading.
///
/// Wayland must bind the target surface before resizing its `wl_egl_window`:
/// some EGL implementations defer a resize of a non-current surface until
/// the next swap, which would render one frame with mismatched buffer geometry.
pub(crate) fn make_current_with_surface(&self, egl_surface: egl_sys::EGLSurface) -> bool {
unsafe {
let ok = (self.libegl.eglMakeCurrent.unwrap())(
self.egl_display,
egl_surface,
egl_surface,
self.egl_context,
);
if ok == 0 {
// `eglGetError` is called outside the latch: it clears EGL's per-thread
// error, and skipping it would leak a stale code into the next report.
let egl_error = (self.libegl.eglGetError.unwrap())();
self.report_egl_error("eglMakeCurrent(window surface)", egl_error);
return false;
}
self.make_current_error_logged.set(false);
true
}
}
/// Logs an `eglMakeCurrent` failure once per outage, naming a lost context explicitly /// Logs an `eglMakeCurrent` failure once per outage, naming a lost context explicitly
/// so a report of "the window went black" arrives with its cause attached. /// so a report of "the window went black" arrives with its cause attached.
fn report_egl_error(&self, what: &str, egl_error: egl_sys::EGLint) { fn report_egl_error(&self, what: &str, egl_error: egl_sys::EGLint) {
@ -330,20 +355,9 @@ impl Cx {
unsafe { unsafe {
let gl = self.os.gl(); let gl = self.os.gl();
let opengl_cx = self.os.opengl_cx.as_ref().unwrap(); let opengl_cx = self.os.opengl_cx.as_ref().unwrap();
let make_current_ok = (opengl_cx.libegl.eglMakeCurrent.unwrap())( if !opengl_cx.make_current_with_surface(egl_surface) {
opengl_cx.egl_display,
egl_surface,
egl_surface,
opengl_cx.egl_context,
);
if make_current_ok == 0 {
// `eglGetError` is called outside the latch: it clears EGL's per-thread
// error, and skipping it would leak a stale code into the next report.
let egl_error = (opengl_cx.libegl.eglGetError.unwrap())();
opengl_cx.report_egl_error("eglMakeCurrent", egl_error);
return false; return false;
} }
opengl_cx.make_current_error_logged.set(false);
// Apply the configured swap interval (vsync) on the now-current window surface. // Apply the configured swap interval (vsync) on the now-current window surface.
// Re-applied per frame because it is surface-scoped and surfaces are recreated // Re-applied per frame because it is surface-scoped and surfaces are recreated
// on resize; the call is cheap and idempotent. // on resize; the call is cheap and idempotent.

View file

@ -37,7 +37,8 @@ use crate::{
gpu_info::GpuPerformance, gpu_info::GpuPerformance,
texture::TextureFormat, texture::TextureFormat,
Area, Cx, CxDrawPassParent, CxOsOp, CxWindowPool, Event, KeyModifiers, MouseButton, Area, Cx, CxDrawPassParent, CxOsOp, CxWindowPool, Event, KeyModifiers, MouseButton,
MouseMoveEvent, MouseUpEvent, SignalToUI, WindowClosedEvent, WindowGeomChangeEvent, MouseMoveEvent, MouseUpEvent, SignalToUI, WaylandDecorationPreference, WindowClosedEvent,
WindowGeomChangeEvent,
}; };
use wayland_client::protocol::{wl_keyboard, wl_pointer}; use wayland_client::protocol::{wl_keyboard, wl_pointer};
use wayland_client::{Connection, Proxy}; use wayland_client::{Connection, Proxy};
@ -50,6 +51,41 @@ fn log_linux_backdrop_unsupported_once() {
}); });
} }
fn parse_decoration_preference(value: &str) -> Option<WaylandDecorationPreference> {
match value {
"server" | "server-side" => Some(WaylandDecorationPreference::ServerSide),
"client" | "client-side" => Some(WaylandDecorationPreference::ClientSide),
_ => None,
}
}
fn decoration_preference_override() -> Option<WaylandDecorationPreference> {
std::env::args_os()
.find_map(|arg| {
arg.to_str()
.and_then(|arg| arg.strip_prefix("--wayland-decoration="))
.and_then(parse_decoration_preference)
})
.or_else(|| {
std::env::var("MAKEPAD_WAYLAND_DECORATION")
.ok()
.as_deref()
.and_then(parse_decoration_preference)
})
}
/// Whether a window's surface may be promised opaque to the compositor.
///
/// This is the same decision the macOS backend makes from the same two fields for its
/// layer's `opaque` flag, and the one the Windows backend makes before enabling
/// composition, so a window that is opaque on one platform is opaque on all of them.
/// `transparent` is the documented opt-in for a see-through window; a backdrop effect
/// needs the surface translucent to sample what is behind it, so it forfeits the
/// promise as well, whether or not this platform implements it yet.
fn window_is_opaque(transparent: bool, backdrop: crate::window::WindowBackdrop) -> bool {
!transparent && backdrop == crate::window::WindowBackdrop::None
}
pub fn wayland_event_loop(cx: Rc<RefCell<Cx>>) { pub fn wayland_event_loop(cx: Rc<RefCell<Cx>>) {
WaylandCx::event_loop_impl(cx); WaylandCx::event_loop_impl(cx);
} }
@ -64,6 +100,7 @@ pub(crate) struct WaylandCx {
/// callbacks the compositor withholds) from wedging the event loop. Disabled by /// callbacks the compositor withholds) from wedging the event loop. Disabled by
/// `MAKEPAD_NO_VSYNC` for uncapped benchmarking. /// `MAKEPAD_NO_VSYNC` for uncapped benchmarking.
frame_pacing: bool, frame_pacing: bool,
decoration_preference_override: Option<WaylandDecorationPreference>,
} }
impl WaylandCx { impl WaylandCx {
@ -79,6 +116,7 @@ impl WaylandCx {
cx: cx.clone(), cx: cx.clone(),
qhandle: None, qhandle: None,
frame_pacing: std::env::var_os("MAKEPAD_NO_VSYNC").is_none(), frame_pacing: std::env::var_os("MAKEPAD_NO_VSYNC").is_none(),
decoration_preference_override: decoration_preference_override(),
})); }));
let conn = Connection::connect_to_env().unwrap(); let conn = Connection::connect_to_env().unwrap();
let display = conn.display(); let display = conn.display();
@ -187,39 +225,31 @@ impl WaylandCx {
// do this here because mac // do this here because mac
let mut cx = self.cx.borrow_mut(); let mut cx = self.cx.borrow_mut();
// When drawing our own window chrome (no server-side decorations), let window_index = state
// populate the chrome buttons bounding box: three buttons right-aligned
// at the top of the caption bar, matching the Makepad widget layout.
if matches!(
cx.os_type(),
OsType::LinuxWindow(LinuxWindowParams {
custom_window_chrome: true,
..
})
) {
const BUTTONS_W: f64 = 46.0 * 3.0;
const BUTTONS_H: f64 = 29.0;
let w = re.new_geom.inner_size.x;
re.new_geom.window_chrome_buttons = Rect {
pos: Vec2d {
x: w - BUTTONS_W,
y: 0.0,
},
size: Vec2d {
x: BUTTONS_W,
y: BUTTONS_H,
},
};
}
if let Some(window) = state
.windows .windows
.iter_mut() .iter()
.find(|w| w.window_id == re.window_id) .position(|window| window.window_id == re.window_id);
{ // Wayland's native surface state does not know the geometry of
// Makepad-drawn buttons. Populate it after DPI conversion below.
re.new_geom.window_chrome_buttons = Rect::default();
if let Some(window_index) = window_index {
// compare in native units, before new_geom is converted below // compare in native units, before new_geom is converted below
let geom_changed = re.old_geom.inner_size != re.new_geom.inner_size let window = &mut state.windows[window_index];
|| re.old_geom.dpi_factor != re.new_geom.dpi_factor; let uses_csd = window.uses_client_side_decorations;
let is_fullscreen = window.is_fullscreen;
let old_chrome_buttons =
cx.windows[re.window_id].window_geom.window_chrome_buttons;
let decoration_changed = {
let cx_window = &cx.windows[re.window_id];
cx_window.uses_client_side_decorations != uses_csd
|| cx_window.wayland_is_fullscreen != is_fullscreen
};
let geom_changed = decoration_changed
|| window.csd_shadow_needs_update()
|| re.old_geom.inner_size != re.new_geom.inner_size
|| re.old_geom.dpi_factor != re.new_geom.dpi_factor
|| re.old_geom.is_fullscreen != re.new_geom.is_fullscreen;
// Keep the wayland geom native (buffer/viewport size + the next resize's dpi come // Keep the wayland geom native (buffer/viewport size + the next resize's dpi come
// from it). Store the zoomed geom here and the next resize reads its dpi back as // from it). Store the zoomed geom here and the next resize reads its dpi back as
@ -228,9 +258,19 @@ impl WaylandCx {
window.window_geom = re.new_geom.clone(); window.window_geom = re.new_geom.clone();
{ {
let cx_window = &mut cx.windows[re.window_id]; let cx_window = &mut cx.windows[re.window_id];
cx_window.uses_client_side_decorations = uses_csd;
cx_window.wayland_is_fullscreen = is_fullscreen;
cx_window.os_dpi_factor = Some(re.new_geom.dpi_factor); cx_window.os_dpi_factor = Some(re.new_geom.dpi_factor);
re.new_geom = cx_window.native_window_geom_to_layout(re.new_geom); re.new_geom = cx_window.native_window_geom_to_layout(re.new_geom);
} }
if uses_csd && !is_fullscreen {
const BUTTONS_SIZE: Vec2d = Vec2d { x: 138.0, y: 29.0 };
re.new_geom.window_chrome_buttons = Rect {
pos: dvec2(re.new_geom.inner_size.x - BUTTONS_SIZE.x, 0.0),
size: BUTTONS_SIZE,
};
}
re.old_geom.window_chrome_buttons = old_chrome_buttons;
cx.windows[re.window_id].window_geom = re.new_geom.clone(); cx.windows[re.window_id].window_geom = re.new_geom.clone();
// redraw when the size or scale changed // redraw when the size or scale changed
if geom_changed { if geom_changed {
@ -547,8 +587,17 @@ impl WaylandCx {
} }
cx.call_event_handler(&Event::WindowClosed(WindowClosedEvent { window_id })); cx.call_event_handler(&Event::WindowClosed(WindowClosedEvent { window_id }));
cx.windows[window_id].is_created = false; cx.windows[window_id].is_created = false;
if state.pointer_window == Some(window_id) { // The pointer may have been over the window's shadow gutter rather than the
// window, which leaves no `pointer_window` to match on.
if state.pointer_window == Some(window_id)
|| state
.pointer_shadow
.is_some_and(|(shadow_window, _)| shadow_window == window_id)
{
state.pointer_window = None; state.pointer_window = None;
state.pointer_shadow = None;
state.pointer_enter_serial = None;
state.last_resize_edge = None;
} }
if state.keyboard_window == Some(window_id) { if state.keyboard_window == Some(window_id) {
state.keyboard_window = None; state.keyboard_window = None;
@ -576,8 +625,17 @@ impl WaylandCx {
let mut cx = self.cx.borrow_mut(); let mut cx = self.cx.borrow_mut();
cx.call_event_handler(&Event::WindowClosed(event)); cx.call_event_handler(&Event::WindowClosed(event));
cx.windows[window_id].is_created = false; cx.windows[window_id].is_created = false;
if state.pointer_window == Some(window_id) { // The pointer may have been over the window's shadow gutter rather than the
// window, which leaves no `pointer_window` to match on.
if state.pointer_window == Some(window_id)
|| state
.pointer_shadow
.is_some_and(|(shadow_window, _)| shadow_window == window_id)
{
state.pointer_window = None; state.pointer_window = None;
state.pointer_shadow = None;
state.pointer_enter_serial = None;
state.last_resize_edge = None;
} }
if state.keyboard_window == Some(window_id) { if state.keyboard_window == Some(window_id) {
state.keyboard_window = None; state.keyboard_window = None;
@ -640,9 +698,13 @@ impl WaylandCx {
} else { } else {
&window.create_app_id &window.create_app_id
}; };
let decoration_preference = self
.decoration_preference_override
.unwrap_or(window.wayland_decorations);
let window = WaylandWindow::new( let window = WaylandWindow::new(
window_id, window_id,
compositor, compositor,
state.subcompositor.as_ref(),
wm_base, wm_base,
state.decoration_manager.as_ref(), state.decoration_manager.as_ref(),
state.scale_manager.as_ref(), state.scale_manager.as_ref(),
@ -656,6 +718,7 @@ impl WaylandCx {
&window.create_title, &window.create_title,
app_id, app_id,
window.is_fullscreen, window.is_fullscreen,
decoration_preference,
); );
if cx.windows[window_id].backdrop != crate::window::WindowBackdrop::None { if cx.windows[window_id].backdrop != crate::window::WindowBackdrop::None {
log_linux_backdrop_unsupported_once(); log_linux_backdrop_unsupported_once();
@ -668,8 +731,12 @@ impl WaylandCx {
// Seed the geom too: the default `dpi_factor` is 0.0, which would make // Seed the geom too: the default `dpi_factor` is 0.0, which would make
// `get_pass_rect()` produce NaN once the flag is on. // `get_pass_rect()` produce NaN once the flag is on.
let native_geom = window.window_geom.clone(); let native_geom = window.window_geom.clone();
let uses_client_side_decorations = window.uses_client_side_decorations;
let is_fullscreen = window.is_fullscreen;
state.windows.push(window); state.windows.push(window);
let cx_window = &mut cx.windows[window_id]; let cx_window = &mut cx.windows[window_id];
cx_window.uses_client_side_decorations = uses_client_side_decorations;
cx_window.wayland_is_fullscreen = is_fullscreen;
cx_window.os_dpi_factor = Some(native_geom.dpi_factor); cx_window.os_dpi_factor = Some(native_geom.dpi_factor);
let layout_geom = cx_window.native_window_geom_to_layout(native_geom); let layout_geom = cx_window.native_window_geom_to_layout(native_geom);
cx_window.window_geom = layout_geom; cx_window.window_geom = layout_geom;
@ -766,25 +833,20 @@ impl WaylandCx {
} }
} }
CxOsOp::ResizeWindow(window_id, size) => { CxOsOp::ResizeWindow(window_id, size) => {
// A Wayland client has no "set my size" request. Window geometry is by // A Wayland client has no "set my size" request. A self-resize changes the
// default whatever the surface commits -- `xdg_surface.set_window_geometry`: // next EGL buffer, viewport destination, and explicit xdg window geometry;
// "If never set, the value is the full bounds of the surface ... This // the latter excludes any CSD shadow subsurfaces from placement and snapping.
// updates dynamically on every commit" -- and this backend never sets it,
// so a self-resize is just the next frame committed at a different extent.
// The paint path derives the EGL extent and the viewport destination from
// `window_geom.inner_size`, so writing it here is the whole operation.
// //
// Only a floating toplevel may choose its own size. Under xdg_toplevel's // Only a floating toplevel may choose its own size. Under xdg_toplevel's
// `maximized` state the configured window geometry must be obeyed "or the // `maximized` state the configured window geometry must be obeyed "or the
// xdg_wm_base.invalid_surface_state error is raised", which disconnects the // xdg_wm_base.invalid_surface_state error is raised", which disconnects the
// client; under `fullscreen` the configured geometry is a maximum. The // client; under `fullscreen` the configured geometry is a maximum. Popups
// configure handler folds both states into `is_fullscreen`, so that one // take their extent from their positioner and are deliberately not matched
// flag gates the operation. Popups take their extent from their positioner // here.
// and are deliberately not matched here.
if let Some(window) = if let Some(window) =
state.windows.iter_mut().find(|w| w.window_id == window_id) state.windows.iter_mut().find(|w| w.window_id == window_id)
{ {
if window.window_geom.is_fullscreen { if window.is_maximized || window.is_fullscreen {
crate::error!( crate::error!(
"ResizeWindow ignored: a maximized or fullscreen Wayland toplevel \ "ResizeWindow ignored: a maximized or fullscreen Wayland toplevel \
must keep the size the compositor configured." must keep the size the compositor configured."
@ -818,9 +880,9 @@ impl WaylandCx {
// by a restored position the way it can be on Windows, macOS and X11. // by a restored position the way it can be on Windows, macOS and X11.
// xdg_toplevel exposes no absolute-positioning request: `move` is // xdg_toplevel exposes no absolute-positioning request: `move` is
// interactive and serial-gated ("This request must be used in response to // interactive and serial-gated ("This request must be used in response to
// some sort of user action"), and `reposition` is an xdg_popup request // some sort of user action"). `xdg_popup.reposition` only moves a popup
// requiring xdg_wm_base v3, which `wayland_state.rs` does not bind. This arm // relative to its parent using a new positioner; it cannot place a toplevel at
// is correct as a permanent no-op. // absolute screen coordinates. This arm is therefore a permanent no-op.
CxOsOp::RepositionWindow(_window_id, _size) => {} CxOsOp::RepositionWindow(_window_id, _size) => {}
CxOsOp::SetWindowTitle(window_id, title) => { CxOsOp::SetWindowTitle(window_id, title) => {
if let Some(window) = state.windows.iter().find(|w| w.window_id == window_id) { if let Some(window) = state.windows.iter().find(|w| w.window_id == window_id) {
@ -865,12 +927,18 @@ impl WaylandCx {
cx.call_event_handler(&Event::DragEnd); cx.call_event_handler(&Event::DragEnd);
} }
CxOsOp::SetCursor(cursor) => { CxOsOp::SetCursor(cursor) => {
state.requested_cursor = cursor;
// Native CSD resize hit-testing owns the cursor at an edge, and in the
// shadow gutter the pointer is outside the window entirely, so the app
// has no say over it there either.
if state.last_resize_edge.is_none() && state.pointer_shadow.is_none() {
if let Some(cursor_shape) = state.cursor_shape.as_ref() { if let Some(cursor_shape) = state.cursor_shape.as_ref() {
if let Some(serial) = state.pointer_serial.as_ref() { if let Some(serial) = state.pointer_enter_serial.as_ref() {
cursor_shape.set_shape(*serial, cursor.into()); cursor_shape.set_shape(*serial, cursor.into());
} }
} }
} }
}
CxOsOp::StartTimer { CxOsOp::StartTimer {
timer_id, timer_id,
interval, interval,
@ -1176,13 +1244,26 @@ impl WaylandCx {
continue; continue;
} }
let mut presented = false; let mut presented = false;
let opaque = {
let cx_window = &cx.windows[window_id];
window_is_opaque(cx_window.transparent, cx_window.backdrop)
};
let compositor = state.compositor.clone();
if let Some(window) = if let Some(window) =
state.windows.iter_mut().find(|w| w.window_id == window_id) state.windows.iter_mut().find(|w| w.window_id == window_id)
{ {
if !window.configured { if !window.configured {
continue; continue;
} }
window.resize_buffers(); if !window.prepare_buffer_size(cx.os.opengl_cx.as_ref().unwrap()) {
continue;
}
window.prepare_csd_shadow();
if let (Some(compositor), Some(qhandle)) =
(compositor.as_ref(), self.qhandle.as_ref())
{
window.sync_opaque_region(compositor, qhandle, opaque);
}
if std::env::var_os("MAKEPAD_WAYLAND_TRACE").is_some() { if std::env::var_os("MAKEPAD_WAYLAND_TRACE").is_some() {
crate::log!( crate::log!(
"Wayland paint window={:?} inner=({}, {}) dpi={} pix=({}, {})", "Wayland paint window={:?} inner=({}, {}) dpi={} pix=({}, {})",
@ -1199,8 +1280,7 @@ impl WaylandCx {
// `wp_viewport.set_destination` raises the `bad_value` protocol // `wp_viewport.set_destination` raises the `bad_value` protocol
// error, which disconnects the client, on a zero or negative // error, which disconnects the client, on a zero or negative
// extent, and a float-to-int cast turns both a negative and a NaN // extent, and a float-to-int cast turns both a negative and a NaN
// into zero. Floor the destination the way `resize_buffers` floors // into zero. Floor the destination the same way as the EGL extent.
// the EGL extent.
viewport.set_destination( viewport.set_destination(
window.window_geom.inner_size.x.max(1.0) as i32, window.window_geom.inner_size.x.max(1.0) as i32,
window.window_geom.inner_size.y.max(1.0) as i32, window.window_geom.inner_size.y.max(1.0) as i32,
@ -1230,14 +1310,15 @@ impl WaylandCx {
if !window.configured { if !window.configured {
continue; continue;
} }
window.resize_buffers(); if !window.prepare_buffer_size(cx.os.opengl_cx.as_ref().unwrap()) {
continue;
}
if let Some(viewport) = window.viewport.as_ref() { if let Some(viewport) = window.viewport.as_ref() {
viewport.set_source(-1., -1., -1., -1.); viewport.set_source(-1., -1., -1., -1.);
// `wp_viewport.set_destination` raises the `bad_value` protocol // `wp_viewport.set_destination` raises the `bad_value` protocol
// error, which disconnects the client, on a zero or negative // error, which disconnects the client, on a zero or negative
// extent, and a float-to-int cast turns both a negative and a NaN // extent, and a float-to-int cast turns both a negative and a NaN
// into zero. Floor the destination the way `resize_buffers` floors // into zero. Floor the destination the same way as the EGL extent.
// the EGL extent.
viewport.set_destination( viewport.set_destination(
window.window_geom.inner_size.x.max(1.0) as i32, window.window_geom.inner_size.x.max(1.0) as i32,
window.window_geom.inner_size.y.max(1.0) as i32, window.window_geom.inner_size.y.max(1.0) as i32,
@ -1276,3 +1357,33 @@ impl WaylandCx {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_window_that_asked_for_translucency_gives_up_the_opaque_promise() {
use crate::window::WindowBackdrop;
assert!(window_is_opaque(false, WindowBackdrop::None));
// Promising opacity for a window that wanted to be see-through would leave
// whatever is behind it on screen, so both opt-ins must veto it.
assert!(!window_is_opaque(true, WindowBackdrop::None));
assert!(!window_is_opaque(false, WindowBackdrop::Blur));
assert!(!window_is_opaque(false, WindowBackdrop::Acrylic));
assert!(!window_is_opaque(true, WindowBackdrop::Blur));
}
#[test]
fn parses_wayland_decoration_override_values() {
assert_eq!(
parse_decoration_preference("server"),
Some(WaylandDecorationPreference::ServerSide)
);
assert_eq!(
parse_decoration_preference("client-side"),
Some(WaylandDecorationPreference::ClientSide)
);
assert_eq!(parse_decoration_preference("invalid"), None);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ use crate::window::MacosWindowKind;
use crate::window::MacosWindowLevel; use crate::window::MacosWindowLevel;
use crate::window::ScriptWindowHandle; use crate::window::ScriptWindowHandle;
use crate::window::WindowBackdrop; use crate::window::WindowBackdrop;
use crate::window::WaylandDecorationPreference;
use crate::*; use crate::*;
pub fn script_mod(vm: &mut ScriptVm) -> ScriptValue { pub fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
@ -20,6 +21,7 @@ pub fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
set_script_value_to_pod!(vm, draw.DrawPassUniforms); set_script_value_to_pod!(vm, draw.DrawPassUniforms);
set_script_value_to_api!(vm, draw.MouseCursor); set_script_value_to_api!(vm, draw.MouseCursor);
set_script_value_to_api!(vm, draw.WindowBackdrop); set_script_value_to_api!(vm, draw.WindowBackdrop);
set_script_value_to_api!(vm, draw.WaylandDecorationPreference);
set_script_value_to_api!(vm, draw.MacosWindowKind); set_script_value_to_api!(vm, draw.MacosWindowKind);
set_script_value_to_api!(vm, draw.MacosWindowChrome); set_script_value_to_api!(vm, draw.MacosWindowChrome);
set_script_value_to_api!(vm, draw.MacosWindowLevel); set_script_value_to_api!(vm, draw.MacosWindowLevel);

View file

@ -50,6 +50,21 @@ pub enum MacosWindowLevel {
StatusBar, StatusBar,
} }
/// The decoration mode a Wayland toplevel asks the compositor to use.
///
/// `ServerSide` requests compositor decorations, but the compositor may select
/// client-side mode instead. `ClientSide` explicitly uses Makepad's frame. If
/// xdg-decoration is unavailable, Makepad always uses client-side decorations.
/// `MAKEPAD_WAYLAND_DECORATION=client|server` can override it for all windows
/// in a process. `--wayland-decoration=client|server` is also recognized when
/// the application's own argument parser accepts framework arguments.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Script, ScriptHook, Default)]
pub enum WaylandDecorationPreference {
#[default]
ServerSide,
ClientSide,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Script)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Script)]
pub struct MacosWindowConfig { pub struct MacosWindowConfig {
#[live] #[live]
@ -353,6 +368,9 @@ impl WindowHandle {
cxwindow.popup_position = None; cxwindow.popup_position = None;
cxwindow.popup_size = None; cxwindow.popup_size = None;
cxwindow.popup_grab_keyboard = true; cxwindow.popup_grab_keyboard = true;
cxwindow.wayland_decorations = WaylandDecorationPreference::default();
cxwindow.uses_client_side_decorations = false;
cxwindow.wayland_is_fullscreen = false;
cx.platform_ops cx.platform_ops
.push_back(CxOsOp::CreateWindow(window.window_id())); .push_back(CxOsOp::CreateWindow(window.window_id()));
window window
@ -379,6 +397,9 @@ impl WindowHandle {
cxwindow.popup_position = Some(position); cxwindow.popup_position = Some(position);
cxwindow.popup_size = Some(size); cxwindow.popup_size = Some(size);
cxwindow.popup_grab_keyboard = true; cxwindow.popup_grab_keyboard = true;
cxwindow.wayland_decorations = WaylandDecorationPreference::default();
cxwindow.uses_client_side_decorations = false;
cxwindow.wayland_is_fullscreen = false;
cxwindow.popup_grab_keyboard cxwindow.popup_grab_keyboard
}; };
cx.platform_ops.push_back(CxOsOp::CreatePopupWindow { cx.platform_ops.push_back(CxOsOp::CreatePopupWindow {
@ -420,6 +441,10 @@ pub struct ScriptWindowHandle {
pub backdrop_intensity: f32, pub backdrop_intensity: f32,
#[live(MacosWindowConfig::default())] #[live(MacosWindowConfig::default())]
pub macos: MacosWindowConfig, pub macos: MacosWindowConfig,
/// Wayland only. Server-side decorations are requested by default; set
/// this to `ClientSide` to explicitly use Makepad's window frame.
#[live(WaylandDecorationPreference::ServerSide)]
pub wayland_decorations: WaylandDecorationPreference,
/// Optionally override the caption bar height. /// Optionally override the caption bar height.
/// * If `None` (the default), the caption bar's height is based on a system-calculated height /// * If `None` (the default), the caption bar's height is based on a system-calculated height
/// derived from window chrome button geometry, which will make the window chrome buttons /// derived from window chrome button geometry, which will make the window chrome buttons
@ -470,6 +495,9 @@ impl ScriptHook for ScriptWindowHandle {
.normalized(); .normalized();
let macos = self.macos.normalized(); let macos = self.macos.normalized();
cx.windows[window_id].macos = macos; cx.windows[window_id].macos = macos;
if !cx.windows[window_id].is_created {
cx.windows[window_id].wayland_decorations = self.wayland_decorations;
}
if cx.windows[window_id].window_visuals() != visuals { if cx.windows[window_id].window_visuals() != visuals {
cx.windows[window_id].transparent = visuals.transparent; cx.windows[window_id].transparent = visuals.transparent;
cx.windows[window_id].backdrop = visuals.backdrop; cx.windows[window_id].backdrop = visuals.backdrop;
@ -521,6 +549,19 @@ impl WindowHandle {
pub fn configure_macos_window(&mut self, cx: &mut Cx, config: MacosWindowConfig) { pub fn configure_macos_window(&mut self, cx: &mut Cx, config: MacosWindowConfig) {
cx.windows[self.window_id()].macos = config.normalized(); cx.windows[self.window_id()].macos = config.normalized();
} }
/// Selects the decoration mode requested when a Wayland window is created.
/// This has no effect after the native window has been created.
pub fn configure_wayland_decorations(
&mut self,
cx: &mut Cx,
preference: WaylandDecorationPreference,
) {
let window = &mut cx.windows[self.window_id()];
if !window.is_created {
window.wayland_decorations = preference;
}
}
pub fn get_inner_size(&self, cx: &Cx) -> Vec2d { pub fn get_inner_size(&self, cx: &Cx) -> Vec2d {
cx.windows[self.window_id()].get_inner_size() cx.windows[self.window_id()].get_inner_size()
} }
@ -563,6 +604,17 @@ impl WindowHandle {
cx.windows[self.window_id()].window_geom.is_fullscreen cx.windows[self.window_id()].window_geom.is_fullscreen
} }
/// Whether this Wayland window is using Makepad-drawn decorations.
pub fn uses_wayland_client_side_decorations(&self, cx: &Cx) -> bool {
cx.windows[self.window_id()].uses_client_side_decorations
}
/// Whether this Wayland window is in true compositor fullscreen, as
/// distinct from the legacy `is_fullscreen` maximize-or-fullscreen flag.
pub fn is_wayland_fullscreen(&self, cx: &Cx) -> bool {
cx.windows[self.window_id()].wayland_is_fullscreen
}
pub fn xr_is_presenting(&mut self, cx: &mut Cx) -> bool { pub fn xr_is_presenting(&mut self, cx: &mut Cx) -> bool {
cx.windows[self.window_id()].window_geom.xr_is_presenting cx.windows[self.window_id()].window_geom.xr_is_presenting
} }
@ -683,6 +735,11 @@ pub struct CxWindow {
pub backdrop: WindowBackdrop, pub backdrop: WindowBackdrop,
pub backdrop_intensity: f32, pub backdrop_intensity: f32,
pub macos: MacosWindowConfig, pub macos: MacosWindowConfig,
pub wayland_decorations: WaylandDecorationPreference,
/// Effective Wayland decoration mode selected by the compositor.
pub(crate) uses_client_side_decorations: bool,
/// True compositor fullscreen, kept separate so CSD remains visible when maximized.
pub(crate) wayland_is_fullscreen: bool,
} }
impl Default for CxWindow { impl Default for CxWindow {
@ -709,6 +766,9 @@ impl Default for CxWindow {
backdrop: WindowBackdrop::None, backdrop: WindowBackdrop::None,
backdrop_intensity: 1.0, backdrop_intensity: 1.0,
macos: MacosWindowConfig::default(), macos: MacosWindowConfig::default(),
wayland_decorations: WaylandDecorationPreference::default(),
uses_client_side_decorations: false,
wayland_is_fullscreen: false,
} }
} }
} }
@ -924,6 +984,32 @@ mod tests {
assert!(!cx_window.transparent); assert!(!cx_window.transparent);
assert_eq!(cx_window.backdrop, WindowBackdrop::None); assert_eq!(cx_window.backdrop, WindowBackdrop::None);
assert_eq!(cx_window.backdrop_intensity, 1.0); assert_eq!(cx_window.backdrop_intensity, 1.0);
assert_eq!(
cx_window.wayland_decorations,
WaylandDecorationPreference::ServerSide
);
}
#[test]
fn reused_window_slot_resets_wayland_decoration_state() {
let mut cx = test_cx();
let first = WindowHandle::new(&mut cx);
let first_id = first.window_id();
cx.windows[first_id].wayland_decorations = WaylandDecorationPreference::ClientSide;
cx.windows[first_id].uses_client_side_decorations = true;
cx.windows[first_id].wayland_is_fullscreen = true;
drop(first);
let second = WindowHandle::new(&mut cx);
let second_id = second.window_id();
assert_eq!(second_id.0, first_id.0);
assert_ne!(second_id.1, first_id.1);
assert_eq!(
cx.windows[second_id].wayland_decorations,
WaylandDecorationPreference::ServerSide
);
assert!(!cx.windows[second_id].uses_client_side_decorations);
assert!(!cx.windows[second_id].wayland_is_fullscreen);
} }
#[test] #[test]
@ -1084,6 +1170,7 @@ mod tests {
backdrop: WindowBackdrop::Blur, backdrop: WindowBackdrop::Blur,
backdrop_intensity: 0.5, backdrop_intensity: 0.5,
macos: MacosWindowConfig::floating_panel(), macos: MacosWindowConfig::floating_panel(),
wayland_decorations: WaylandDecorationPreference::ClientSide,
caption_bar_height_override: None, caption_bar_height_override: None,
}; };
@ -1106,6 +1193,10 @@ mod tests {
} }
); );
assert_eq!(cx_window.macos, MacosWindowConfig::floating_panel()); assert_eq!(cx_window.macos, MacosWindowConfig::floating_panel());
assert_eq!(
cx_window.wayland_decorations,
WaylandDecorationPreference::ClientSide
);
} }
#[test] #[test]

View file

@ -236,7 +236,7 @@ pub fn list_profiles()->Result<(), String>{
} }
*/ */
impl PlistValues { impl PlistValues {
fn to_plist_file(&self, os: AppleOs) -> String { pub(super) fn to_plist_file(&self, os: AppleOs) -> String {
match os { match os {
AppleOs::Tvos => self.to_tvos_plist_file(), AppleOs::Tvos => self.to_tvos_plist_file(),
AppleOs::Ios => self.to_ios_plist_file(), AppleOs::Ios => self.to_ios_plist_file(),
@ -701,10 +701,35 @@ pub fn build(
apple_target: AppleTarget, apple_target: AppleTarget,
) -> Result<IosBuildResult, String> { ) -> Result<IosBuildResult, String> {
let build_crate = get_build_crate_from_args(args)?; let build_crate = get_build_crate_from_args(args)?;
let cwd = std::env::current_dir().unwrap();
let info_plist = crate::apple::info_plist::load(&cwd, build_crate, apple_target.os())?;
let binary_name = let binary_name =
get_package_binary_name(build_crate).unwrap_or_else(|| build_crate.to_string()); get_package_binary_name(build_crate).unwrap_or_else(|| build_crate.to_string());
let cwd = std::env::current_dir().unwrap(); // Capitalize the first letter for the user-visible name (CFBundleDisplayName /
// CFBundleName) so the iOS home-screen icon doesn't show a lowercased crate name,
// while keeping the bundle identifier lowercase so existing provisioning profiles
// still match.
let display_name = {
let mut chars = product.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
};
let plist = PlistValues {
identifier: format!("{org}.{product}").to_string(),
display_name: display_name.clone(),
name: display_name,
executable: binary_name.clone(),
version: "1.0.0".to_string(),
};
let generated_plist = plist.to_plist_file(apple_target.os());
let plist_contents = match info_plist {
Some(overrides) => overrides.merge(&generated_plist)?,
None => generated_plist,
};
let target_dir = cargo_target_dir(&cwd); let target_dir = cargo_target_dir(&cwd);
let target_dir_str = target_dir.to_string_lossy().to_string(); let target_dir_str = target_dir.to_string_lossy().to_string();
let target_dir_arg = format!("--target-dir={target_dir_str}"); let target_dir_arg = format!("--target-dir={target_dir_str}");
@ -745,25 +770,6 @@ pub fn build(
} }
shell_env(&rust_env, &cwd, "rustup", &args_out)?; shell_env(&rust_env, &cwd, "rustup", &args_out)?;
// alright lets make the .app file with manifest
// Capitalize the first letter for the user-visible name (CFBundleDisplayName /
// CFBundleName) so the iOS home-screen icon doesn't show a lowercased crate name,
// while keeping the bundle identifier lowercase so existing provisioning profiles
// still match.
let display_name = {
let mut chars = product.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
};
let plist = PlistValues {
identifier: format!("{org}.{product}").to_string(),
display_name: display_name.clone(),
name: display_name,
executable: binary_name.clone(),
version: "1.0.0".to_string(),
};
let profile = get_profile_from_args(args); let profile = get_profile_from_args(args);
let app_dir = target_dir.join(format!( let app_dir = target_dir.join(format!(
@ -773,7 +779,7 @@ pub fn build(
mkdir(&app_dir)?; mkdir(&app_dir)?;
let plist_file = app_dir.join("Info.plist"); let plist_file = app_dir.join("Info.plist");
write_text(&plist_file, &plist.to_plist_file(apple_target.os()))?; write_text(&plist_file, &plist_contents)?;
if matches!(apple_target.os(), AppleOs::Ios) { if matches!(apple_target.os(), AppleOs::Ios) {
match generate_app_icon_xcassets(&app_dir, build_crate) { match generate_app_icon_xcassets(&app_dir, build_crate) {

View file

@ -0,0 +1,161 @@
use super::AppleOs;
use makepad_micro_serde::{DeJson, DeJsonErr, DeJsonState, JsonValue};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(test)]
mod tests;
pub(super) struct InfoPlist {
path: PathBuf,
xml: String,
}
#[derive(DeJson)]
struct CargoMetadata {
packages: Vec<CargoPackage>,
}
#[derive(DeJson)]
struct CargoPackage {
name: String,
version: String,
id: String,
manifest_path: String,
metadata: JsonValue,
}
/// Load an optional app-owned plist relative to the selected Cargo package,
/// never the invoking workspace directory. iOS and tvOS configure it separately.
pub(super) fn load(cwd: &Path, build_crate: &str, os: AppleOs) -> Result<Option<InfoPlist>, String> {
// Let Cargo parse the manifest and resolve the selected workspace member.
// A file URL from `cargo pkgid` is not a filesystem path (e.g. `%20`).
let output = Command::new("cargo")
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(cwd)
.output()
.map_err(|e| format!("Cannot read Cargo metadata for {}: {e}", cwd.join("Cargo.toml").display()))?;
if !output.status.success() {
return Err(format!("Cannot read Cargo metadata for {}: {}",
cwd.join("Cargo.toml").display(), String::from_utf8_lossy(&output.stderr)));
}
let json = std::str::from_utf8(&output.stdout)
.map_err(|e| format!("Cargo metadata is not UTF-8: {e}"))?;
let metadata = CargoMetadata::deserialize_json_lenient(json)
.map_err(|e| format!("Cannot parse Cargo metadata: {e:?}"))?;
let package = metadata.packages.iter().find(|package| {
package.name == build_crate || package.id == build_crate
|| format!("{}@{}", package.name, package.version) == build_crate
}).ok_or_else(|| format!("Cargo metadata does not contain package {build_crate}"))?;
let manifest = Path::new(&package.manifest_path);
let crate_dir = manifest.parent()
.ok_or_else(|| format!("Cargo returned an invalid manifest path: {}", manifest.display()))?;
let platform = match os {
AppleOs::Ios => "ios",
AppleOs::Tvos => "tvos",
};
let key = format!("package.metadata.makepad.{platform}.info_plist");
let configured = package.metadata.key("makepad")
.and_then(|value| value.key(platform))
.and_then(|value| value.key("info_plist"));
let path = match configured {
None => return Ok(None),
Some(JsonValue::String(path)) if !path.is_empty() => crate_dir.join(path),
_ => return Err(format!("{key} in {} must be a nonempty path string", manifest.display())),
};
let xml = read_dictionary(&path)
.map_err(|e| format!("Cannot read custom Info.plist {}: {e}", path.display()))?;
Ok(Some(InfoPlist { path, xml }))
}
impl InfoPlist {
/// Replace whole top-level values, preserving types and unspecified defaults.
/// Bundle identity and executable stay controlled by the build's CLI inputs:
/// changing them here would disagree with signing and simulator launch paths.
pub(super) fn merge(&self, generated: &str) -> Result<String, String> {
self.merge_inner(generated)
.map_err(|e| format!("Cannot merge custom Info.plist {}: {e}", self.path.display()))
}
fn merge_inner(&self, generated: &str) -> Result<String, String> {
let temporary = TemporaryDirectory::new()?;
let defaults = temporary.0.join("defaults.plist");
let merged = temporary.0.join("merged.plist");
fs::write(&defaults, generated.trim_start())
.map_err(|e| format!("Cannot write generated Info.plist: {e}"))?;
read_dictionary(&defaults)?;
fs::write(&merged, &self.xml)
.map_err(|e| format!("Cannot write temporary Info.plist: {e}"))?;
// PlistBuddy's Merge adds only missing top-level keys. Starting with the
// app's dictionary therefore preserves whole custom values, including
// nested dictionaries and arrays. Capture its duplicate-key notices.
// Fixed filenames in a private directory avoid interpolating app paths
// into PlistBuddy's command language; the source file is never modified.
run(Command::new("/usr/libexec/PlistBuddy")
.current_dir(&temporary.0)
.args(["-c", "Merge defaults.plist", "merged.plist"]))?;
for key in ["CFBundleIdentifier", "CFBundleExecutable"] {
// Compare typed, canonical XML so e.g. a boolean cannot pass as a
// string. Check after merging so absent custom keys need no special
// handling, and all command failures remain errors.
let extract = |path: &Path| run(Command::new("/usr/bin/plutil")
.args(["-extract", key, "xml1", "-o", "-", "--"])
.arg(path));
if extract(&merged)? != extract(&defaults)? {
return Err(format!(
"cannot change {key}; use the Apple build options and Cargo binary target instead",
));
}
}
read_dictionary(&merged)
}
}
/// Apple builds already require macOS and its plist utilities. Let plutil parse
/// both XML and binary input; only inspect its normalized XML to check the root
/// type, without implementing a general XML or binary plist parser.
fn read_dictionary(path: &Path) -> Result<String, String> {
let xml = run(Command::new("/usr/bin/plutil")
.args(["-convert", "xml1", "-o", "-", "--"])
.arg(path))?;
let root = xml.split_once("<plist")
.and_then(|(_, rest)| rest.split_once('>'))
.map(|(_, rest)| rest.trim_start())
.ok_or_else(|| "plutil did not return a plist document".to_string())?;
if !root.starts_with("<dict>") && !root.starts_with("<dict/>") {
return Err("Info.plist must contain a dictionary".to_string());
}
Ok(xml)
}
fn run(command: &mut Command) -> Result<String, String> {
let output = command.output()
.map_err(|e| format!("Cannot run {}: {e}", command.get_program().to_string_lossy()))?;
if !output.status.success() {
return Err(format!("{} failed ({}): {}{}",
command.get_program().to_string_lossy(), output.status,
String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)));
}
String::from_utf8(output.stdout).map_err(|e| format!("Tool output is not UTF-8: {e}"))
}
struct TemporaryDirectory(PathBuf);
impl TemporaryDirectory {
fn new() -> Result<Self, String> {
// mktemp creates a private directory atomically, including for parallel
// builds. Drop cleans it up on both successful and failed merges.
let path = run(Command::new("/usr/bin/mktemp")
.args(["-d", "-t", "makepad-info-plist"]))?;
Ok(Self(PathBuf::from(path.trim_end_matches('\n'))))
}
}
impl Drop for TemporaryDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}

View file

@ -0,0 +1,300 @@
#![cfg(target_os = "macos")]
use super::AppleOs;
use super::super::compile::PlistValues;
use std::{
fs,
path::{Path, PathBuf},
process::{Command, Output},
sync::atomic::{AtomicU64, Ordering},
};
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
struct Package {
root: PathBuf,
path: PathBuf,
}
impl Package {
fn new(metadata: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"makepad-info-plist-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(),
NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir_all(root.join("workspace/member package/src")).unwrap();
let root = fs::canonicalize(root).unwrap();
let package = Self { path: root.join("workspace/member package"), root };
fs::write(package.path.join("src/main.rs"), "fn main() {}\n").unwrap();
package.manifest(metadata);
package
}
fn manifest(&self, metadata: &str) {
fs::write(self.path.join("Cargo.toml"), format!(
"[package]\nname = \"plist-test\"\nversion = \"0.1.0\"\n{metadata}\n"
)).unwrap();
}
fn plist(&self, relative: &str, xml: &str, binary: bool) -> PathBuf {
let path = self.path.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, xml.trim_start()).unwrap();
if binary {
checked_plutil(&path, &["-convert", "binary1"]);
}
path
}
}
impl Drop for Package {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn load(path: &Path, os: AppleOs) -> Result<Option<super::InfoPlist>, String> {
super::load(path, "plist-test", os)
}
fn generated(os: AppleOs) -> String {
PlistValues {
identifier: "org.example.plist-test".into(),
display_name: "Plist Test".into(),
name: "Plist Test".into(),
executable: "plist-test".into(),
version: "1.2.3".into(),
}.to_plist_file(os)
}
fn plist_xml(root: &str) -> String {
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">{root}</plist>\n")
}
fn one_entry(key: &str, value_xml: &str) -> String {
plist_xml(&format!("<dict><key>{key}</key>{value_xml}</dict>"))
}
fn plutil(path: &Path, args: &[&str]) -> Output {
Command::new("/usr/bin/plutil").args(args).arg("--").arg(path).output().unwrap()
}
fn checked_plutil(path: &Path, args: &[&str]) -> String {
let output = plutil(path, args);
assert!(output.status.success(), "plutil {args:?} {} failed: {}{}",
path.display(), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
String::from_utf8(output.stdout).unwrap()
}
fn raw(path: &Path, key: &str, expected_type: &str) -> String {
checked_plutil(path, &["-extract", key, "raw", "-expect", expected_type, "-o", "-", "-n"])
}
fn extracted_xml(path: &Path, key: &str) -> String {
checked_plutil(path, &["-extract", key, "xml1", "-o", "-"])
}
fn assert_missing(path: &Path, key: &str) {
assert!(!plutil(path, &["-extract", key, "raw", "-o", "-"]).status.success(), "unexpected key {key}");
}
fn assert_path(error: &str, path: &Path) {
assert!(error.contains(path.to_str().unwrap()), "missing path {}: {error}", path.display());
}
#[test]
fn no_opt_in_preserves_defaults_without_adding_speech_permission() {
let package = Package::new("");
for os in [AppleOs::Ios, AppleOs::Tvos] {
assert!(load(&package.path, os).unwrap().is_none());
let defaults = package.plist("Generated.plist", &generated(os), false);
checked_plutil(&defaults, &["-lint"]);
assert_missing(&defaults, "NSSpeechRecognitionUsageDescription");
assert_eq!(raw(&defaults, "CFBundleIdentifier", "string"), "org.example.plist-test");
}
}
#[test]
fn unrelated_hex_and_exponent_metadata_works_with_and_without_overlay() {
let unrelated = "[package.metadata.unrelated]\nhex_value = 0xdead_beef\nscale = 1.5e+4\n";
let package = Package::new(unrelated);
assert!(load(&package.path, AppleOs::Ios).unwrap().is_none());
package.manifest(&format!(
"{unrelated}\n[package.metadata.makepad.ios]\ninfo_plist = \"App Info.plist\""
));
package.plist("App Info.plist", &one_entry("CFBundleDisplayName", "<string>Custom App</string>"), false);
let overlay = load(&package.path, AppleOs::Ios).unwrap().unwrap();
let merged = package.plist("Merged.plist", &overlay.merge(&generated(AppleOs::Ios)).unwrap(), false);
assert_eq!(raw(&merged, "CFBundleDisplayName", "string"), "Custom App");
}
#[test]
fn selects_each_platform_relative_to_package_with_spaces_in_paths() {
let package = Package::new(
r#"[package.metadata.makepad.ios]
info_plist = '''app metadata/iOS "Bob's".plist'''
[package.metadata.makepad.tvos]
info_plist = '''app metadata/tvOS "Bob's".plist'''"#
);
for (os, filename, label) in [
(AppleOs::Ios, "app metadata/iOS \"Bob's\".plist", "Phone App"),
(AppleOs::Tvos, "app metadata/tvOS \"Bob's\".plist", "TV App"),
] {
let path = package.plist(filename, &one_entry("CFBundleDisplayName", &format!("<string>{label}</string>")), false);
let custom = load(&package.path, os).unwrap().unwrap();
assert_eq!(custom.path, path);
let merged = package.plist("Merged.plist", &custom.merge(&generated(os)).unwrap(), false);
assert_eq!(raw(&merged, "CFBundleDisplayName", "string"), label);
}
}
#[test]
fn configuring_one_platform_does_not_opt_in_the_other() {
let package = Package::new(
"[package.metadata.makepad.ios]\ninfo_plist = \"missing-ios.plist\""
);
assert!(load(&package.path, AppleOs::Tvos).unwrap().is_none());
assert!(load(&package.path, AppleOs::Ios).is_err());
package.manifest("[package.metadata.makepad.tvos]\ninfo_plist = \"missing-tvos.plist\"");
assert!(load(&package.path, AppleOs::Ios).unwrap().is_none());
assert!(load(&package.path, AppleOs::Tvos).is_err());
}
#[test]
fn xml_and_binary_overlays_preserve_types_and_unspecified_defaults() {
let package = Package::new(
"[package.metadata.makepad.ios]\ninfo_plist = \"metadata/Info.plist\""
);
let custom = plist_xml(r#"<dict>
<key>NSMicrophoneUsageDescription</key><string>Record the user's "voice" &amp; review &lt;text&gt;.</string>
<key>NSSpeechRecognitionUsageDescription</key><string>Transcribe a message.</string>
<key>LSEnvironment</key><dict><key>APP_SETTING</key><string>custom</string></dict>
<key>AppConfiguration</key><dict>
<key>Enabled</key><false/>
<key>SignedCount</key><integer>-42</integer>
<key>UnsignedCount</key><integer>18446744073709551615</integer>
<key>Payload</key><data>AAF/gP8=</data>
<key>Created</key><date>2026-09-09T12:34:56Z</date>
<key>Items</key><array><string>one &amp; &lt;two&gt;</string><real>1.5</real></array>
<key>odd: key &amp; &lt;tag&gt;</key><string>unusual key</string>
</dict>
</dict>"#);
let source = generated(AppleOs::Ios);
let defaults = package.plist("Generated.plist", &source, false);
assert_eq!(raw(&defaults, "LSEnvironment.RUST_BACKTRACE", "string"), "1");
for binary in [false, true] {
let path = package.plist("metadata/Info.plist", &custom, binary);
let original = fs::read(&path).unwrap();
let overlay = load(&package.path, AppleOs::Ios).unwrap().unwrap();
let merged = package.plist("Merged.plist", &overlay.merge(&source).unwrap(), false);
assert_eq!(fs::read(&path).unwrap(), original, "successful merge modified the source plist, binary={binary}");
checked_plutil(&merged, &["-lint"]);
for (key, value_type, expected) in [
("NSMicrophoneUsageDescription", "string", "Record the user's \"voice\" & review <text>."),
("NSSpeechRecognitionUsageDescription", "string", "Transcribe a message."),
("LSEnvironment.APP_SETTING", "string", "custom"),
("AppConfiguration.Enabled", "bool", "false"),
("AppConfiguration.SignedCount", "integer", "-42"),
("AppConfiguration.UnsignedCount", "integer", "18446744073709551615"),
("AppConfiguration.Payload", "data", "AAF/gP8="),
("AppConfiguration.Created", "date", "2026-09-09T12:34:56Z"),
("AppConfiguration.Items", "array", "2"),
("AppConfiguration.Items.0", "string", "one & <two>"),
("AppConfiguration.odd: key & <tag>", "string", "unusual key"),
] {
assert_eq!(raw(&merged, key, value_type), expected, "{key}, binary={binary}");
}
assert_eq!(raw(&merged, "AppConfiguration.Items.1", "float").parse::<f64>().unwrap(), 1.5);
for key in ["CFBundleIdentifier", "CFBundleName", "CFBundleExecutable", "UIDeviceFamily",
"UISupportedInterfaceOrientations~ipad", "UIRequiredDeviceCapabilities", "MinimumOSVersion",
"NSLocationWhenInUseUsageDescription"] {
assert_eq!(extracted_xml(&merged, key), extracted_xml(&defaults, key), "default changed: {key}, binary={binary}");
}
assert_missing(&merged, "LSEnvironment.RUST_BACKTRACE");
}
}
#[test]
fn missing_or_malformed_custom_file_reports_its_path() {
let package = Package::new(
"[package.metadata.makepad.ios]\ninfo_plist = \"missing Info.plist\""
);
let path = package.path.join("missing Info.plist");
let missing = load(&package.path, AppleOs::Ios).err().expect("missing plist must fail");
assert_path(&missing, &path);
fs::write(&path, b"<plist><dict><key>Broken</key>").unwrap();
let malformed = load(&package.path, AppleOs::Ios).err().expect("malformed plist must fail");
assert_path(&malformed, &path);
}
#[test]
fn non_dictionary_custom_root_is_rejected_in_xml_and_binary() {
let package = Package::new(
"[package.metadata.makepad.ios]\ninfo_plist = \"Info.plist\""
);
for binary in [false, true] {
let path = package.plist("Info.plist", &plist_xml("<array><string>not a dictionary</string></array>"), binary);
let error = load(&package.path, AppleOs::Ios).err().expect("array root must fail");
assert_path(&error, &path);
assert!(error.contains("dictionary"), "{error}");
}
}
#[test]
fn invalid_metadata_reports_manifest_and_key() {
let package = Package::new("");
for invalid in ["\"\"", "false", "42", "[]"] {
package.manifest(&format!("[package.metadata.makepad.ios]\ninfo_plist = {invalid}"));
let error = load(&package.path, AppleOs::Ios).err().expect("invalid path metadata must fail");
assert_path(&error, &package.path.join("Cargo.toml"));
assert!(error.contains("package.metadata.makepad.ios.info_plist"), "{error}");
}
}
#[test]
fn missing_or_malformed_manifest_reports_its_path() {
let package = Package::new("");
let path = package.path.join("Cargo.toml");
fs::remove_file(&path).unwrap();
let missing = load(&package.path, AppleOs::Ios).err().expect("missing manifest must fail");
assert_path(&missing, &path);
fs::write(&path, "[package\n").unwrap();
let malformed = load(&package.path, AppleOs::Ios).err().expect("malformed manifest must fail");
assert_path(&malformed, &path);
}
#[test]
fn rejects_changed_bundle_identity_or_executable_with_path_context() {
let package = Package::new(
"[package.metadata.makepad.ios]\ninfo_plist = \"Info.plist\""
);
for key in ["CFBundleIdentifier", "CFBundleExecutable"] {
for replacement in ["<string>different</string>", "<false/>"] {
let path = package.plist("Info.plist", &one_entry(key, replacement), false);
let original = fs::read(&path).unwrap();
let overlay = load(&package.path, AppleOs::Ios).unwrap().unwrap();
let error = overlay.merge(&generated(AppleOs::Ios)).unwrap_err();
assert_eq!(fs::read(&path).unwrap(), original, "rejected merge modified the source plist");
assert_path(&error, &path);
assert!(error.contains(key), "{error}");
}
}
}
#[test]
fn accepts_unchanged_bundle_identity_and_executable() {
let package = Package::new(
"[package.metadata.makepad.ios]\ninfo_plist = \"Info.plist\""
);
let custom = plist_xml("<dict><key>CFBundleIdentifier</key><string>org.example.plist-test</string>\
<key>CFBundleExecutable</key><string>plist-test</string>\
<key>CFBundleDisplayName</key><string>Custom App</string></dict>");
package.plist("Info.plist", &custom, false);
let overlay = load(&package.path, AppleOs::Ios).unwrap().unwrap();
let merged = package.plist("Merged.plist", &overlay.merge(&generated(AppleOs::Ios)).unwrap(), false);
assert_eq!(raw(&merged, "CFBundleIdentifier", "string"), "org.example.plist-test");
assert_eq!(raw(&merged, "CFBundleExecutable", "string"), "plist-test");
assert_eq!(raw(&merged, "CFBundleDisplayName", "string"), "Custom App");
}

View file

@ -1,4 +1,5 @@
mod compile; mod compile;
mod info_plist;
mod sdk; mod sdk;
use crate::utils::{get_build_crate_from_args, get_package_binary_name}; use crate::utils::{get_build_crate_from_args, get_package_binary_name};
use compile::*; use compile::*;

View file

@ -143,6 +143,12 @@ fn show_help() {
" --device=<DEVICE_NAME> The device name to use for signing/provisioning" " --device=<DEVICE_NAME> The device name to use for signing/provisioning"
); );
println!(); println!();
println!(" [package.metadata.makepad.ios] (or .tvos) in Cargo.toml:");
println!(" info_plist = \"packaging/ios/Info.plist\"");
println!(" Optional XML/binary plist dictionary, relative to the package directory.");
println!(" Its top-level keys replace generated defaults before icons and signing.");
println!(" CFBundleIdentifier and CFBundleExecutable must match the generated values.");
println!();
println!("Android commands:"); println!("Android commands:");
println!(); println!();
println!( println!(

View file

@ -23,6 +23,7 @@ pub fn derive_widget(input: TokenStream) -> TokenStream {
area, area,
event, event,
visible, visible,
imperative,
action_data, action_data,
uid, uid,
cast, cast,

View file

@ -254,8 +254,12 @@ struct AnimatorTrack {
play: Play, play: Play,
/// The ease function /// The ease function
ease: Ease, ease: Ease,
/// The target apply object (what we're animating to) /// The target apply object (what we're animating to).
target_apply: ScriptObject, /// Held as a `ScriptObjectRef` for the same reason as `from_snapshot`: a
/// bare `ScriptObject` into the template dangles as soon as `script_mod`
/// re-runs (safe-area inset change, hot reload), and `interpolate_object`
/// walks it every frame.
target_apply: ScriptObjectRef,
/// The starting values SNAPSHOT (captured/copied when animation begins) /// The starting values SNAPSHOT (captured/copied when animation begins)
/// This is a SEPARATE object from state_object - it must not be mutated during animation /// This is a SEPARATE object from state_object - it must not be mutated during animation
/// Uses ScriptObjectRef to prevent GC from freeing it /// Uses ScriptObjectRef to prevent GC from freeing it
@ -318,6 +322,16 @@ impl ScriptHook for Animator {
let Some(obj) = value.as_object() else { let Some(obj) = value.as_object() else {
return false; return false;
}; };
// A `script_mod` re-run replaces every template object an in-flight
// track is animating against. The track's refs keep those objects
// alive, so walking them is safe, but they now describe the previous
// template — and a `Play::Loop` track would pin them for good. Drop
// the tracks instead. `current_states` is kept: the logical state is
// still meaningful, and the next `cut`/`play` re-resolves its objects
// from the new heap.
if apply.follows_script_rerun() {
self.tracks.clear();
}
let obj_ref = vm.bx.heap.new_object_ref(obj); let obj_ref = vm.bx.heap.new_object_ref(obj);
// Minted from the VM we are running in, so this always resolves; the // Minted from the VM we are running in, so this always resolves; the
// fallback only exists because the lookup is fallible in general. // fallback only exists because the lookup is fallible in general.
@ -362,7 +376,11 @@ impl ScriptApplyDefault for Animator {
_scope: &mut Scope, _scope: &mut Scope,
_value: ScriptValue, _value: ScriptValue,
) -> Option<ScriptValue> { ) -> Option<ScriptValue> {
if apply.is_live_edit_reload() || apply.is_animate() || apply.is_eval() { // `follows_script_rerun` rather than `is_live_edit_reload`: what makes
// injecting the current state unsafe is not that the DSL changed, it
// is that `script_mod` re-ran and freed the objects `state_object` and
// `groups` point at. Both `Reload` and `Rebake` re-run it.
if apply.follows_script_rerun() || apply.is_animate() || apply.is_eval() {
return None; return None;
} }
@ -542,7 +560,7 @@ impl Animator {
// The snapshot must be a separate object that won't be mutated during animation. // The snapshot must be a separate object that won't be mutated during animation.
// We sample from state_object (current animated values) or fall back to static state apply. // We sample from state_object (current animated values) or fall back to static state apply.
let vm_id = self.vm_id; let vm_id = self.vm_id;
let from_snapshot = cx.with_script_vm_id(vm_id, |vm| { let (from_snapshot, target_apply_ref) = cx.with_script_vm_id(vm_id, |vm| {
let snapshot = vm.bx.heap.new_object(); let snapshot = vm.bx.heap.new_object();
// Get the default state's apply for fallback values // Get the default state's apply for fallback values
@ -586,8 +604,12 @@ impl Animator {
}, },
); );
// Create a ScriptObjectRef to prevent GC from freeing the snapshot // Create ScriptObjectRefs to prevent GC from freeing either object
vm.bx.heap.new_object_ref(snapshot) // out from under the running animation.
(
vm.bx.heap.new_object_ref(snapshot),
vm.bx.heap.new_object_ref(target_apply),
)
}); });
// Get the object before moving into track (for return value) // Get the object before moving into track (for return value)
@ -601,7 +623,7 @@ impl Animator {
start_time: f64::NEG_INFINITY, start_time: f64::NEG_INFINITY,
play, play,
ease, ease,
target_apply, target_apply: target_apply_ref,
from_snapshot, from_snapshot,
redraw: target_state.redraw, redraw: target_state.redraw,
}; };
@ -852,7 +874,7 @@ impl Animator {
vm, vm,
state_obj, state_obj,
track.from_snapshot.as_object(), track.from_snapshot.as_object(),
track.target_apply, track.target_apply.as_object(),
mix, mix,
); );
} }

View file

@ -60,10 +60,7 @@ script_mod! {
return sdf.result return sdf.result
} }
DesktopButtonType.WindowsMaxToggled => { DesktopButtonType.WindowsMaxToggled => {
let sz = 5. sdf.rect(c.x - sz, c.y - sz, 2. * sz, 2. * sz)
sdf.rect(c.x - sz + 1., c.y - sz - 1., 2. * sz, 2. * sz)
sdf.stroke(#f, 0.5 + 0.5 * self.draw_pass.dpi_dilate)
sdf.rect(c.x - sz - 1., c.y - sz + 1., 2. * sz, 2. * sz)
sdf.stroke(color, 0.5 + 0.5 * self.draw_pass.dpi_dilate) sdf.stroke(color, 0.5 + 0.5 * self.draw_pass.dpi_dilate)
return sdf.result return sdf.result
} }

View file

@ -420,6 +420,7 @@ pub struct DropDown {
#[rust] #[rust]
popup_global: PopupMenuGlobal, popup_global: PopupMenuGlobal,
#[imperative]
#[live] #[live]
selected_item: usize, selected_item: usize,

View file

@ -221,7 +221,7 @@ pub use crate::{
WidgetSet, WidgetSetIterator, WidgetUid, WidgetSet, WidgetSetIterator, WidgetUid,
}, },
widget_async::{ widget_async::{
set_widget_async_trace, CxSplashVmExt, CxWidgetToScriptCallExt, ScriptAsyncCalls, set_splash_theme, set_widget_async_trace, CxSplashVmExt, CxWidgetToScriptCallExt, ScriptAsyncCalls, SplashTheme,
ScriptAsyncId, ScriptAsyncResult, SplashVmId, MAIN_SPLASH_VM_ID, ScriptAsyncId, ScriptAsyncResult, SplashVmId, MAIN_SPLASH_VM_ID,
}, },
widget_match_event::WidgetMatchEvent, widget_match_event::WidgetMatchEvent,

View file

@ -22,6 +22,7 @@ pub struct PageFlip {
layout: Layout, layout: Layout,
#[live(false)] #[live(false)]
lazy_init: bool, lazy_init: bool,
#[imperative]
#[live] #[live]
active_page: LiveId, active_page: LiveId,
#[rust] #[rust]

View file

@ -552,6 +552,9 @@ impl Splash {
/// The scope object holding this Splash body's top-level definitions, via /// The scope object holding this Splash body's top-level definitions, via
/// the body id cached at eval time (with a pointer-identity fallback for /// the body id cached at eval time (with a pointer-identity fallback for
/// robustness). /// robustness).
/// The scope the script's top-level names live in: the one its body
/// ended in, since a `let`/`fn` that shadows a prelude name opens a
/// child scope the module scope cannot see into.
fn body_scope(&mut self, cx: &mut Cx) -> Option<ScriptObject> { fn body_scope(&mut self, cx: &mut Cx) -> Option<ScriptObject> {
if self.vm_id == MAIN_SPLASH_VM_ID { if self.vm_id == MAIN_SPLASH_VM_ID {
return None; return None;
@ -560,13 +563,14 @@ impl Splash {
let body_id = self.body_id; let body_id = self.body_id;
cx.with_script_vm_id(self.vm_id, |vm| { cx.with_script_vm_id(self.vm_id, |vm| {
let bodies = vm.bx.code.bodies.borrow(); let bodies = vm.bx.code.bodies.borrow();
let ended_in = |body: &ScriptBody| {
body.end_scope.as_ref().unwrap_or(&body.scope).as_object()
};
if let Some(body) = body_id.and_then(|i| bodies.get(i as usize)) { if let Some(body) = body_id.and_then(|i| bodies.get(i as usize)) {
return Some(body.scope.as_object()); return Some(ended_in(body));
} }
bodies.iter().find_map(|body| match &body.source { bodies.iter().find_map(|body| match &body.source {
ScriptSource::Mod(m) if m.module_path == body_key => { ScriptSource::Mod(m) if m.module_path == body_key => Some(ended_in(body)),
Some(body.scope.as_object())
}
_ => None, _ => None,
}) })
}) })

View file

@ -99,6 +99,7 @@ pub struct View {
#[live] #[live]
event_order: EventOrder, event_order: EventOrder,
#[imperative]
#[live(true)] #[live(true)]
pub visible: bool, pub visible: bool,
#[live(false)] #[live(false)]
@ -123,9 +124,15 @@ pub struct View {
#[live] #[live]
on_render: ScriptFnRef, on_render: ScriptFnRef,
/// `|index|` of the direct child a tap landed on. Lives on the container
/// so `on_render` rows stay closure-free and drag scrolling keeps working.
#[live]
on_item_tap: ScriptFnRef,
#[rust] #[rust]
script_async: ScriptAsyncCalls, script_async: ScriptAsyncCalls,
#[rust]
item_tap_live: bool,
#[rust] #[rust]
scroll_bars_obj: Option<Box<ScrollBars>>, scroll_bars_obj: Option<Box<ScrollBars>>,
@ -920,6 +927,35 @@ impl Widget for View {
if let Some(scroll_bars) = &mut self.scroll_bars_obj { if let Some(scroll_bars) = &mut self.scroll_bars_obj {
scroll_bars.handle_scroll_event(cx, event, scope, &mut Vec::new()); scroll_bars.handle_scroll_event(cx, event, scope, &mut Vec::new());
} }
// After the scroll bars so their drag capture comes first; the overload
// lets this view capture the same press alongside it.
if fling_caught {
self.item_tap_live = false;
} else if self.visible && self.on_item_tap.as_object() != ScriptObject::ZERO {
match event.hits_with_capture_overload(cx, self.area(), true) {
Hit::FingerDown(e) => {
// A child that already owns this press (a Button) gets the tap.
self.item_tap_live = !cx.fingers.is_digit_captured_elsewhere(e.digit_id, self.area());
}
Hit::FingerUp(e) if e.was_tap() && self.item_tap_live => {
self.item_tap_live = false;
// clipped_rect: scrolled and clipped, like the hit test itself.
let index = self.children.iter()
.position(|(_, child)| child.area().clipped_rect(cx).contains(e.abs));
if let Some(index) = index {
cx.widget_to_script_call(
uid,
NIL,
self.source.clone(),
self.on_item_tap.clone(),
&[(index as f64).into()],
);
}
}
_ => (),
}
}
} }
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {

View file

@ -8,7 +8,7 @@ use {
std::any::Any, std::any::Any,
std::cell::RefCell, std::cell::RefCell,
std::collections::{HashMap, VecDeque}, std::collections::{HashMap, VecDeque},
std::sync::atomic::{AtomicU64, Ordering}, std::sync::atomic::{AtomicU64, AtomicU8, Ordering},
}; };
static SCRIPT_ASYNC_COUNTER: AtomicU64 = AtomicU64::new(1); static SCRIPT_ASYNC_COUNTER: AtomicU64 = AtomicU64::new(1);
@ -32,6 +32,30 @@ pub struct SplashVmId(pub u64);
pub const MAIN_SPLASH_VM_ID: SplashVmId = SplashVmId(0); pub const MAIN_SPLASH_VM_ID: SplashVmId = SplashVmId(0);
/// The widget theme a Splash isolate boots with. A host picks its own theme
/// after `theme_mod`, which an isolate's prelude never sees, so it says here.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum SplashTheme {
#[default]
Dark,
Light,
Skeleton,
}
static SPLASH_THEME: AtomicU8 = AtomicU8::new(0);
pub fn set_splash_theme(theme: SplashTheme) {
SPLASH_THEME.store(theme as u8, Ordering::Relaxed);
}
fn splash_theme() -> SplashTheme {
match SPLASH_THEME.load(Ordering::Relaxed) {
1 => SplashTheme::Light,
2 => SplashTheme::Skeleton,
_ => SplashTheme::Dark,
}
}
thread_local! { thread_local! {
/// Splash isolate VMs whose owning `Splash` widget has been dropped, awaiting /// Splash isolate VMs whose owning `Splash` widget has been dropped, awaiting
/// reclamation. `Drop` can't reach `Cx`, so it only records the id here; the /// reclamation. `Drop` can't reach `Cx`, so it only records the id here; the
@ -394,7 +418,17 @@ impl CxSplashVmExt for Cx {
bx: Box::new(ScriptVmBase::new()), bx: Box::new(ScriptVmBase::new()),
}; };
crate::makepad_draw::makepad_platform::script::script_mod(&mut vm); crate::makepad_draw::makepad_platform::script::script_mod(&mut vm);
crate::script_mod(&mut vm); crate::theme_mod(&mut vm);
match splash_theme() {
SplashTheme::Light => {
vm.eval(crate::makepad_script::script! { mod.theme = mod.themes.light });
}
SplashTheme::Skeleton => {
vm.eval(crate::makepad_script::script! { mod.theme = mod.themes.skeleton });
}
SplashTheme::Dark => {}
}
crate::widgets_mod(&mut vm);
// Splash isolates run untrusted-ish mini-app script; strip the // Splash isolates run untrusted-ish mini-app script; strip the
// ambient-authority modules from the isolate's namespace entirely: // ambient-authority modules from the isolate's namespace entirely:
// filesystem access (`fs`), child processes (`run`), and the resource // filesystem access (`fs`), child processes (`run`), and the resource

View file

@ -163,7 +163,7 @@ script_mod! {
draw_bg.button_type: DesktopButtonType.WindowsMin draw_bg.button_type: DesktopButtonType.WindowsMin
width: 46 height: 29 width: 46 height: 29
draw_bg +: { draw_bg +: {
color: #000, color_hover: #000, color_down: #000 color: theme.color_label_inner, color_hover: #000, color_down: #000
bg_color_hover: #E9E9E9, bg_color_down: #CCCCCC bg_color_hover: #E9E9E9, bg_color_down: #CCCCCC
} }
} }
@ -171,7 +171,7 @@ script_mod! {
draw_bg.button_type: DesktopButtonType.WindowsMax draw_bg.button_type: DesktopButtonType.WindowsMax
width: 46 height: 29 width: 46 height: 29
draw_bg +: { draw_bg +: {
color: #000, color_hover: #000, color_down: #000 color: theme.color_label_inner, color_hover: #000, color_down: #000
bg_color_hover: #E9E9E9, bg_color_down: #CCCCCC bg_color_hover: #E9E9E9, bg_color_down: #CCCCCC
} }
} }
@ -179,7 +179,7 @@ script_mod! {
draw_bg.button_type: DesktopButtonType.WindowsClose draw_bg.button_type: DesktopButtonType.WindowsClose
width: 46 height: 29 width: 46 height: 29
draw_bg +: { draw_bg +: {
color: #000, color_hover: #FFF, color_down: #FFF color: theme.color_label_inner, color_hover: #FFF, color_down: #FFF
bg_color_hover: #E81123, bg_color_down: #F1707A bg_color_hover: #E81123, bg_color_down: #F1707A
} }
} }
@ -374,13 +374,17 @@ pub struct Window {
/// Used to only emit a platform op when the resolved value actually changes. /// Used to only emit a platform op when the resolved value actually changes.
#[rust] #[rust]
system_bar_dark_icons: Option<bool>, system_bar_dark_icons: Option<bool>,
/// Cached `(caption_bar visible, caption rect, buttons rect)` for `WindowDragQuery`. That event /// Cached `(caption_bar visible, caption rect, buttons rect)` for `WindowDragQuery`. It is
/// fires once per `WM_NCHITTEST` — i.e. on every mouse move on Windows — and resolving the /// refreshed only after layout finishes, so a synchronous native hit-test between configure
/// views + their areas each time runs widget-tree lookups, a real source of scroll jitter when /// and redraw cannot preserve rectangles from the previous window size.
/// the mouse is moved during a fling. These only change on relayout, so we recompute lazily and
/// invalidate on `WindowGeomChange`.
#[rust] #[rust]
drag_query_cache: Option<(bool, Rect, Rect)>, drag_query_cache: Option<(bool, Rect, Rect)>,
/// Whether a completed draw has made this frame's areas authoritative, so a geometry
/// computed now may be cached. Between a configure and the redraw that answers it the
/// areas still describe the previous size, and a query in that window is answered live
/// without being stored.
#[rust]
drag_query_layout_valid: bool,
/// The caption-layout inputs (show_caption_bar, height override, system caption height) that /// The caption-layout inputs (show_caption_bar, height override, system caption height) that
/// `drag_query_cache` was last computed against. When they change without a platform /// `drag_query_cache` was last computed against. When they change without a platform
/// `WindowGeomChange` (e.g. a live/DSL reload toggling the caption), we drop the cache in /// `WindowGeomChange` (e.g. a live/DSL reload toggling the caption), we drop the cache in
@ -506,6 +510,54 @@ fn gauss_render_texture_y_flip_for_os(os_type: &OsType) -> f32 {
} }
} }
fn classify_window_drag_query(
visible: bool,
caption_rect: Rect,
buttons_rect: Rect,
transitional_buttons_rect: Rect,
point: Vec2d,
) -> WindowDragQueryResponse {
if !visible {
return WindowDragQueryResponse::NoAnswer;
}
let hits_buttons = (buttons_rect.size != Vec2d::default() && buttons_rect.contains(point))
|| (transitional_buttons_rect.size != Vec2d::default()
&& transitional_buttons_rect.contains(point));
if hits_buttons {
WindowDragQueryResponse::Client
} else if caption_rect.contains(point) {
WindowDragQueryResponse::Caption
} else {
WindowDragQueryResponse::NoAnswer
}
}
fn configured_window_buttons_rect(buttons_rect: Rect, configured_rect: Rect) -> Rect {
if buttons_rect.size == Vec2d::default() || configured_rect.size == Vec2d::default() {
return configured_rect;
}
Rect {
pos: dvec2(
configured_rect.pos.x + configured_rect.size.x - buttons_rect.size.x,
buttons_rect.pos.y,
),
size: buttons_rect.size,
}
}
fn configured_window_caption_rect(caption_rect: Rect, configured_size: Vec2d) -> Rect {
if caption_rect.size == Vec2d::default() || configured_size == Vec2d::default() {
return caption_rect;
}
Rect {
pos: caption_rect.pos,
size: dvec2(
(configured_size.x - caption_rect.pos.x).max(0.0),
caption_rect.size.y,
),
}
}
impl GaussStack { impl GaussStack {
fn new(cx: &mut Cx) -> Self { fn new(cx: &mut Cx) -> Self {
let scene_pass = DrawPass::new_with_name(cx, "gauss_scene"); let scene_pass = DrawPass::new_with_name(cx, "gauss_scene");
@ -984,15 +1036,20 @@ impl Window {
.set_visible(cx, self.show_caption_bar && !is_fullscreen); .set_visible(cx, self.show_caption_bar && !is_fullscreen);
} }
OsType::LinuxWindow(params) => { OsType::LinuxWindow(params) => {
// Only show the caption bar if we're drawing our own window chrome // X11 uses WM decorations. Wayland decides per window from the
// (e.g. Wayland without server-side decorations). On X11 the WM // compositor's xdg-decoration configure event.
// provides native decorations, so we hide the in-app caption bar. let custom_chrome = params.custom_window_chrome
let custom_chrome = params.custom_window_chrome; && self
.window
.handle
.uses_wayland_client_side_decorations(cx);
let visible = self.show_caption_bar
&& custom_chrome
&& !self.window.handle.is_wayland_fullscreen(cx);
self.view(cx, ids!(caption_bar)) self.view(cx, ids!(caption_bar))
.set_visible(cx, self.show_caption_bar && custom_chrome); .set_visible(cx, visible);
if custom_chrome { self.view(cx, ids!(windows_buttons))
self.view(cx, ids!(windows_buttons)).set_visible(cx, true); .set_visible(cx, visible);
}
} }
OsType::LinuxDirect | OsType::Android(_) => { OsType::LinuxDirect | OsType::Android(_) => {
//self.frame.get_view(ids!(caption_bar)).set_visible(false); //self.frame.get_view(ids!(caption_bar)).set_visible(false);
@ -1004,6 +1061,21 @@ impl Window {
} }
} }
fn caption_drag_geometry(&self, cx: &mut Cx) -> (bool, Rect, Rect) {
// Each `self.view` is a widget-tree walk, so the caption bar is resolved once
// rather than once per field read.
let caption = self.view(cx, ids!(caption_bar));
let visible = caption.visible();
let caption_rect = caption.area().rect(cx);
let buttons = self.view(cx, ids!(windows_buttons));
let buttons_rect = if buttons.visible() {
buttons.area().rect(cx)
} else {
Rect::default()
};
(visible, caption_rect, buttons_rect)
}
fn sync_caption_bar_height(&mut self, cx: &mut Cx) { fn sync_caption_bar_height(&mut self, cx: &mut Cx) {
// Explicit DSL override takes priority, then system-calculated. // Explicit DSL override takes priority, then system-calculated.
let height = self let height = self
@ -1117,6 +1189,7 @@ impl Window {
if self.caption_query_sig != Some(caption_sig) { if self.caption_query_sig != Some(caption_sig) {
self.caption_query_sig = Some(caption_sig); self.caption_query_sig = Some(caption_sig);
self.drag_query_cache = None; self.drag_query_cache = None;
self.drag_query_layout_valid = false;
} }
self.sync_caption_bar_state(cx); self.sync_caption_bar_state(cx);
@ -1313,6 +1386,14 @@ impl Window {
cx.end_pass_sized_turtle(); cx.end_pass_sized_turtle();
// Areas are authoritative only after this frame's layout has completed, so this is
// where a cached answer becomes allowed. Computing it here instead would charge
// every frame for three widget-tree walks that only a drag query ever reads, and
// most frames never see one. Dropping last frame's answer rather than keeping it
// means the first query after any relayout still recomputes, whether or not the
// relayout was one of the two that invalidate explicitly.
self.drag_query_cache = None;
self.drag_query_layout_valid = true;
self.main_draw_list.end(cx); self.main_draw_list.end(cx);
cx.end_pass(&self.pass.handle); cx.end_pass(&self.pass.handle);
} }
@ -1342,6 +1423,16 @@ impl Window {
self.window.handle.configure_macos_window(cx, config); self.window.handle.configure_macos_window(cx, config);
} }
pub fn configure_wayland_decorations(
&mut self,
cx: &mut Cx,
preference: WaylandDecorationPreference,
) {
self.window
.handle
.configure_wayland_decorations(cx, preference);
}
pub fn window_index(&self) -> usize { pub fn window_index(&self) -> usize {
self.window.handle.window_id().id() self.window.handle.window_id().id()
} }
@ -1363,6 +1454,66 @@ mod tests {
1.0 1.0
); );
} }
#[test]
fn native_button_geometry_wins_during_configure_to_draw_transition() {
let stale_caption = Rect {
pos: dvec2(0.0, 0.0),
size: dvec2(800.0, 29.0),
};
let stale_buttons = Rect {
pos: dvec2(662.0, 0.0),
size: dvec2(138.0, 29.0),
};
let configured_buttons = Rect {
pos: dvec2(1782.0, 0.0),
size: dvec2(138.0, 29.0),
};
assert!(matches!(
classify_window_drag_query(
true,
stale_caption,
stale_buttons,
configured_buttons,
dvec2(1851.0, 14.0),
),
WindowDragQueryResponse::Client
));
assert!(matches!(
classify_window_drag_query(
true,
stale_caption,
stale_buttons,
Rect::default(),
dvec2(400.0, 14.0),
),
WindowDragQueryResponse::Caption
));
let zoomed_buttons = Rect {
pos: dvec2(708.0, 0.0),
size: dvec2(92.0, 19.0),
};
assert_eq!(
configured_window_buttons_rect(zoomed_buttons, configured_buttons),
Rect {
pos: dvec2(1828.0, 0.0),
size: dvec2(92.0, 19.0),
}
);
let configured_caption =
configured_window_caption_rect(stale_caption, dvec2(1920.0, 1080.0));
assert!(matches!(
classify_window_drag_query(
true,
configured_caption,
stale_buttons,
configured_buttons,
dvec2(1200.0, 14.0),
),
WindowDragQueryResponse::Caption
));
}
} }
impl WindowRef { impl WindowRef {
@ -1478,6 +1629,16 @@ impl WindowRef {
inner.configure_macos_window(cx, config); inner.configure_macos_window(cx, config);
} }
} }
pub fn configure_wayland_decorations(
&self,
cx: &mut Cx,
preference: WaylandDecorationPreference,
) {
if let Some(mut inner) = self.borrow_mut() {
inner.configure_wayland_decorations(cx, preference);
}
}
} }
impl Widget for Window { impl Widget for Window {
@ -1538,8 +1699,10 @@ impl Widget for Window {
Event::WindowGeomChange(ev) => { Event::WindowGeomChange(ev) => {
if ev.window_id == self.window.window_id() { if ev.window_id == self.window.window_id() {
// The caption / buttons may have been re-laid-out; drop the WindowDragQuery // The caption / buttons may have been re-laid-out; drop the WindowDragQuery
// geometry cache so it is recomputed on the next hit-test. // geometry cache so it is recomputed on the next hit-test, and mark the
// areas non-authoritative until the redraw that answers this configure.
self.drag_query_cache = None; self.drag_query_cache = None;
self.drag_query_layout_valid = false;
match cx.os_type() { match cx.os_type() {
OsType::Windows | OsType::Macos => { OsType::Windows | OsType::Macos => {
if self.hide_caption_on_fullscreen && !cx.in_makepad_studio() { if self.hide_caption_on_fullscreen && !cx.in_makepad_studio() {
@ -1600,42 +1763,51 @@ impl Widget for Window {
} }
Event::WindowDragQuery(dq) => { Event::WindowDragQuery(dq) => {
if dq.window_id == self.window.window_id() { if dq.window_id == self.window.window_id() {
// Resolve the caption / buttons geometry at most once per relayout; this event // A native query can arrive synchronously after configure but before redraw.
// arrives per mouse-move (per WM_NCHITTEST) and the view lookups are not free. // Use live areas for that one query, but only a completed draw may cache them.
let (visible, caption_rect, buttons_rect) = match self.drag_query_cache { let cache_ready = self.drag_query_cache.is_some() || self.drag_query_layout_valid;
Some(c) => c, let geometry = match self.drag_query_cache {
Some(cached) => cached,
None => { None => {
let visible = self.view(cx, ids!(caption_bar)).visible(); let live = self.caption_drag_geometry(cx);
let caption_rect = self.view(cx, ids!(caption_bar)).area().rect(cx); if self.drag_query_layout_valid {
let buttons_view = self.view(cx, ids!(windows_buttons)); self.drag_query_cache = Some(live);
let buttons_visible = buttons_view.visible();
let buttons_rect = buttons_view.area().rect(cx);
// Only cache once the caption bar AND its (visible) buttons have actually
// been laid out, so an early query doesn't pin a stale rect. Pinning a
// zero buttons_rect while the buttons are visible-but-not-yet-laid-out
// would make the min/max/close strip respond as draggable Caption (a
// click on Close would drag the window) until the next geometry change. A
// window with no (hidden) buttons keeps a zero buttons_rect, which is fine.
let caption_ready = caption_rect.size != Vec2d::default();
let buttons_ready =
!buttons_visible || buttons_rect.size != Vec2d::default();
if !visible || (caption_ready && buttons_ready) {
self.drag_query_cache = Some((visible, caption_rect, buttons_rect));
} }
(visible, caption_rect, buttons_rect) live
} }
}; };
if visible { let (visible, mut caption_rect, buttons_rect) = geometry;
if caption_rect.contains(dq.abs) { if !cache_ready {
if buttons_rect.size != Vec2d::default() caption_rect = configured_window_caption_rect(
&& buttons_rect.contains(dq.abs) caption_rect,
{ cx.windows[dq.window_id].window_geom.inner_size,
dq.response.set(WindowDragQueryResponse::Client); );
} else {
dq.response.set(WindowDragQueryResponse::Caption);
} }
let transitional_buttons = if cache_ready {
Rect::default()
} else {
configured_window_buttons_rect(
buttons_rect,
cx.windows[dq.window_id].window_geom.window_chrome_buttons,
)
};
match classify_window_drag_query(
visible,
caption_rect,
buttons_rect,
transitional_buttons,
dq.abs,
) {
WindowDragQueryResponse::Client => {
// Button geometry wins even if the stale caption rect still has the
// previous width, and therefore also blocks native top-edge resize.
dq.response.set(WindowDragQueryResponse::Client);
}
WindowDragQueryResponse::Caption => {
dq.response.set(WindowDragQueryResponse::Caption);
cx.set_cursor(MouseCursor::Default); cx.set_cursor(MouseCursor::Default);
} }
WindowDragQueryResponse::NoAnswer | WindowDragQueryResponse::SysMenu => {}
} }
} }
true true