Compare commits
7 commits
493d23a763
...
4b25a1bf1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b25a1bf1e |
||
|
|
14fe611e66 |
||
|
|
a13034d85d |
||
|
|
c5fb78ba09 |
||
|
|
4383a13832 |
||
|
|
ab7e2230d8 |
||
|
|
6d71eda34f |
37 changed files with 3395 additions and 395 deletions
|
|
@ -140,6 +140,21 @@ fn derive_script_impl_inner(
|
|||
.iter()
|
||||
.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!(")
|
||||
.ident(&field.name)
|
||||
.add(").into(), apply);");
|
||||
|
|
@ -159,6 +174,9 @@ fn derive_script_impl_inner(
|
|||
.add(",vm, apply, scope, v);");
|
||||
tb.add("}");
|
||||
tb.add("}");
|
||||
if imperative {
|
||||
tb.add("}");
|
||||
}
|
||||
}
|
||||
if field
|
||||
.attrs
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub fn script_err_gen(input: TokenStream) -> TokenStream {
|
|||
source,
|
||||
new,
|
||||
live,
|
||||
imperative,
|
||||
rust,
|
||||
pick,
|
||||
splat,
|
||||
|
|
|
|||
|
|
@ -132,6 +132,21 @@ pub enum Apply {
|
|||
/// is the new source of truth and template values should override
|
||||
/// any prior runtime state.
|
||||
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()`
|
||||
/// (e.g. preference change, safe-area inset change). The template has
|
||||
/// NOT changed — the same cached `app_value` is being re-walked so
|
||||
|
|
@ -150,6 +165,7 @@ impl Apply {
|
|||
match self {
|
||||
Self::New => true,
|
||||
Self::Reload => true,
|
||||
Self::Rebake => true,
|
||||
Self::ScriptReapply => true,
|
||||
Self::Eval => true,
|
||||
_ => false,
|
||||
|
|
@ -161,11 +177,13 @@ impl Apply {
|
|||
/// creates temporary objects that would become dangling after GC.
|
||||
/// Excludes ScriptReapply because the template hasn't changed —
|
||||
/// 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 {
|
||||
match self {
|
||||
Self::New => true,
|
||||
Self::Reload => true,
|
||||
Self::Rebake => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -192,13 +210,15 @@ impl Apply {
|
|||
pub fn is_reload(&self) -> bool {
|
||||
match self {
|
||||
Self::Reload => true,
|
||||
Self::Rebake => true,
|
||||
Self::ScriptReapply => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// template source has actually changed (e.g. re-running script_mod
|
||||
/// scaffolding, invalidating template-derived caches).
|
||||
|
|
@ -210,10 +230,11 @@ impl Apply {
|
|||
}
|
||||
|
||||
/// True only for `Apply::ScriptReapply` — a `request_script_reapply`-driven
|
||||
/// re-walk where the template has NOT changed. Field impls whose value
|
||||
/// should not be clobbered by template defaults on this kind of re-walk
|
||||
/// should early-return when this is true (canonical example:
|
||||
/// `ArcStringMut::script_apply`).
|
||||
/// re-walk where the template has NOT changed.
|
||||
///
|
||||
/// Prefer `preserves_runtime_state()` when the question is "may I clobber
|
||||
/// the runtime value here?"; this predicate exists for the narrower cases
|
||||
/// that care about the specific trigger.
|
||||
pub fn is_script_reapply(&self) -> bool {
|
||||
match self {
|
||||
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 {
|
||||
match self {
|
||||
Self::Animate => true,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ impl<'a> ScriptVm<'a> {
|
|||
return;
|
||||
};
|
||||
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
|
||||
.threads
|
||||
.cur()
|
||||
|
|
|
|||
|
|
@ -340,13 +340,14 @@ script_primitive!(
|
|||
) {
|
||||
// Same rationale as `ArcStringMut::script_apply`: text-bearing fields
|
||||
// (`TextInput.text`, `TextInput.empty_text`, etc.) are canonically
|
||||
// mutated through imperative setters at runtime. A ScriptReapply
|
||||
// walk fired by `cx.request_script_reapply()` (preference broadcast,
|
||||
// safe-area inset change) would otherwise clobber that runtime value
|
||||
// with the stale DSL literal. Strings are not part of the shared-
|
||||
// heap-object propagation pipeline (which uses `Size`/numerics), so
|
||||
// bailing here is safe.
|
||||
if apply.is_script_reapply() {
|
||||
// mutated through imperative setters at runtime. A re-walk that
|
||||
// carries no authored change (`cx.request_script_reapply()` preference
|
||||
// broadcast, or the `script_mod` re-run a safe-area inset change
|
||||
// forces) would otherwise clobber that runtime value with the stale
|
||||
// DSL literal. Strings are not part of the shared-heap-object
|
||||
// propagation pipeline (which uses `Size`/numerics), so bailing here
|
||||
// is safe.
|
||||
if apply.preserves_runtime_state() {
|
||||
return;
|
||||
}
|
||||
self.clear();
|
||||
|
|
|
|||
|
|
@ -111,6 +111,9 @@ pub struct ScriptThread {
|
|||
/// Base of the CURRENT slot frame (see CallFrame::prev_slot_base).
|
||||
pub(crate) slot_base: 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(crate) last_err: ScriptValue,
|
||||
pub(crate) json_parser: JsonParserThread,
|
||||
|
|
@ -122,6 +125,7 @@ impl ScriptThread {
|
|||
Self {
|
||||
thread_id,
|
||||
is_paused: false,
|
||||
root_end_scope: None,
|
||||
//last_err: NIL,
|
||||
// pre-reserve the hot stacks so steady-state execution never
|
||||
// pays Vec growth in the interpreter loop
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ pub trait ScriptHook {
|
|||
// Widgets that need to differentiate can branch on
|
||||
// `apply.is_live_edit_reload()` or `apply.is_script_reapply()`
|
||||
// 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 {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ pub struct ScriptBody {
|
|||
pub parser: ScriptParser,
|
||||
pub scope: 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 source_len: usize,
|
||||
}
|
||||
|
|
@ -972,7 +975,11 @@ impl<'a> ScriptVm<'a> {
|
|||
self.bx.threads.cur().trap.ip.index = 0;
|
||||
|
||||
// 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.
|
||||
|
|
@ -1305,6 +1312,7 @@ impl<'a> ScriptVm<'a> {
|
|||
parser: ScriptParser::default(),
|
||||
scope,
|
||||
me,
|
||||
end_scope: None,
|
||||
checkpoint: None,
|
||||
source_len: 0,
|
||||
};
|
||||
|
|
|
|||
53
platform/script/tests/hook_scope.rs
Normal file
53
platform/script/tests/hook_scope.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -287,6 +287,9 @@ macro_rules! _app_main_event_closure {
|
|||
if let Event::LiveEdit = event {
|
||||
let mut app_ref = app.borrow_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| {
|
||||
let value = vm.with_reload(|vm| <$app as AppMain>::script_mod(vm));
|
||||
if let Some(obj) = value.as_object() {
|
||||
|
|
@ -295,7 +298,7 @@ macro_rules! _app_main_event_closure {
|
|||
<$app as $crate::ScriptApply>::script_apply(
|
||||
app,
|
||||
vm,
|
||||
&$crate::Apply::Reload,
|
||||
&live_edit_apply,
|
||||
&mut $crate::Scope::empty(),
|
||||
value,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -92,11 +92,11 @@ impl ScriptApply for ArcStringMut {
|
|||
) {
|
||||
// Same rationale as `String::script_apply` (see `prims.rs`):
|
||||
// text-bearing fields are canonically mutated by imperative setters
|
||||
// (`Label::set_text`, `Button::set_text`, etc.), so a ScriptReapply
|
||||
// walk should not clobber the runtime value with the stale DSL
|
||||
// literal. `Apply::Reload` (LiveEdit) still applies — DSL just
|
||||
// changed, so the new template wins.
|
||||
if apply.is_script_reapply() {
|
||||
// (`Label::set_text`, `Button::set_text`, etc.), so a re-walk that
|
||||
// carries no authored change should not clobber the runtime value with
|
||||
// the stale DSL literal. Only `Apply::Reload` gets to win, because
|
||||
// there the DSL itself changed.
|
||||
if apply.preserves_runtime_state() {
|
||||
return;
|
||||
}
|
||||
// Convert to owned String using the heap's cast method
|
||||
|
|
|
|||
|
|
@ -145,16 +145,25 @@ pub struct Cx {
|
|||
pub pending_script_reapply: bool,
|
||||
|
||||
/// 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`)
|
||||
/// has changed and needs to be re-baked into widget definitions that
|
||||
/// reference it via expressions like `top: (mod.widgets.SAFE_INSET_PAD_TOP)`
|
||||
/// — those expressions are only re-evaluated when `script_mod` re-runs.
|
||||
/// `Apply::Reload` clobbers runtime widget state (animator values, etc.),
|
||||
/// so prefer `pending_script_reapply` whenever the change can be modeled
|
||||
/// as a shared-heap-object mutation instead.
|
||||
/// The re-run is still a full-tree walk, so prefer
|
||||
/// `pending_script_reapply` whenever the change can be modeled as a
|
||||
/// shared-heap-object mutation instead.
|
||||
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.
|
||||
pub(crate) pending_window_geom_changes: Vec<WindowGeomChangeEvent>,
|
||||
pub(crate) clear_hover_queued: bool,
|
||||
|
|
@ -525,6 +534,7 @@ impl Cx {
|
|||
display_context: Default::default(),
|
||||
pending_script_reapply: false,
|
||||
pending_live_edit_request: false,
|
||||
live_edit_apply: Apply::Reload,
|
||||
pending_window_geom_changes: Default::default(),
|
||||
clear_hover_queued: false,
|
||||
|
||||
|
|
|
|||
|
|
@ -576,19 +576,26 @@ impl Cx {
|
|||
/// Requests a deferred `Event::LiveEdit` on the next event-loop iteration.
|
||||
/// The handler re-runs `script_mod` (re-evaluating any expressions that
|
||||
/// 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
|
||||
/// is consumed by `script_mod!` block expressions — those expressions are
|
||||
/// not re-evaluated by `Apply::ScriptReapply`. `Apply::Reload` walks
|
||||
/// clobber runtime widget state (animator values, dynamic instance
|
||||
/// buffers, user-typed text in widgets that don't early-return on
|
||||
/// LiveEdit), so prefer `request_script_reapply` when the change can be
|
||||
/// not re-evaluated by `Apply::ScriptReapply`. The re-run is still a full
|
||||
/// tree walk, so prefer `request_script_reapply` when the change can be
|
||||
/// modeled as a shared-heap-object mutation instead.
|
||||
pub fn request_live_edit(&mut self) {
|
||||
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
|
||||
/// 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`
|
||||
|
|
|
|||
|
|
@ -487,6 +487,12 @@ impl CxFingers {
|
|||
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.
|
||||
/// Lets a raw `Event::LongPress` handler check which widget owns the press.
|
||||
pub fn touch_capture_area(&self, uid: u64) -> Option<Area> {
|
||||
|
|
|
|||
|
|
@ -245,8 +245,8 @@ pub use {
|
|||
web_socket::{WebSocket, WebSocketMessage},
|
||||
window::{
|
||||
CxWindowPool, MacosWindowChrome, MacosWindowConfig, MacosWindowKind, MacosWindowLevel,
|
||||
ScriptWindowHandle, WindowBackdrop, WindowHandle, WindowIcon, WindowIconBuffer,
|
||||
WindowId, WindowVisuals,
|
||||
ScriptWindowHandle, WaylandDecorationPreference, WindowBackdrop, WindowHandle,
|
||||
WindowIcon, WindowIconBuffer, WindowId, WindowVisuals,
|
||||
},
|
||||
xr_tsdf::{
|
||||
ChunkKey, SparseTsdGridReadSnapshot, SparseTsdReadChunk, TsdfPublishedSnapshot,
|
||||
|
|
|
|||
|
|
@ -890,6 +890,7 @@ impl Cx {
|
|||
LiveEditTrigger::FileChange => {
|
||||
self.draw_shaders.reset_for_live_reload();
|
||||
self.pending_script_reapply = false;
|
||||
self.live_edit_apply = crate::makepad_script::Apply::Reload;
|
||||
self.call_event_handler(&Event::LiveEdit);
|
||||
self.redraw_all();
|
||||
if self.pending_script_reapply {
|
||||
|
|
@ -904,6 +905,10 @@ impl Cx {
|
|||
// app-level handler that re-broadcasts sets a fresh flag
|
||||
// that lands on the next tick.
|
||||
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.redraw_all();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 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) {
|
||||
|
|
@ -330,20 +355,9 @@ impl Cx {
|
|||
unsafe {
|
||||
let gl = self.os.gl();
|
||||
let opengl_cx = self.os.opengl_cx.as_ref().unwrap();
|
||||
let make_current_ok = (opengl_cx.libegl.eglMakeCurrent.unwrap())(
|
||||
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);
|
||||
if !opengl_cx.make_current_with_surface(egl_surface) {
|
||||
return false;
|
||||
}
|
||||
opengl_cx.make_current_error_logged.set(false);
|
||||
// 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
|
||||
// on resize; the call is cheap and idempotent.
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ use crate::{
|
|||
gpu_info::GpuPerformance,
|
||||
texture::TextureFormat,
|
||||
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::{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>>) {
|
||||
WaylandCx::event_loop_impl(cx);
|
||||
}
|
||||
|
|
@ -64,6 +100,7 @@ pub(crate) struct WaylandCx {
|
|||
/// callbacks the compositor withholds) from wedging the event loop. Disabled by
|
||||
/// `MAKEPAD_NO_VSYNC` for uncapped benchmarking.
|
||||
frame_pacing: bool,
|
||||
decoration_preference_override: Option<WaylandDecorationPreference>,
|
||||
}
|
||||
|
||||
impl WaylandCx {
|
||||
|
|
@ -79,6 +116,7 @@ impl WaylandCx {
|
|||
cx: cx.clone(),
|
||||
qhandle: 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 display = conn.display();
|
||||
|
|
@ -187,39 +225,31 @@ impl WaylandCx {
|
|||
// do this here because mac
|
||||
let mut cx = self.cx.borrow_mut();
|
||||
|
||||
// When drawing our own window chrome (no server-side decorations),
|
||||
// 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
|
||||
let window_index = state
|
||||
.windows
|
||||
.iter_mut()
|
||||
.find(|w| w.window_id == re.window_id)
|
||||
{
|
||||
.iter()
|
||||
.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
|
||||
let geom_changed = re.old_geom.inner_size != re.new_geom.inner_size
|
||||
|| re.old_geom.dpi_factor != re.new_geom.dpi_factor;
|
||||
let window = &mut state.windows[window_index];
|
||||
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
|
||||
// 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();
|
||||
{
|
||||
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);
|
||||
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();
|
||||
// redraw when the size or scale changed
|
||||
if geom_changed {
|
||||
|
|
@ -547,8 +587,17 @@ impl WaylandCx {
|
|||
}
|
||||
cx.call_event_handler(&Event::WindowClosed(WindowClosedEvent { window_id }));
|
||||
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_shadow = None;
|
||||
state.pointer_enter_serial = None;
|
||||
state.last_resize_edge = None;
|
||||
}
|
||||
if state.keyboard_window == Some(window_id) {
|
||||
state.keyboard_window = None;
|
||||
|
|
@ -576,8 +625,17 @@ impl WaylandCx {
|
|||
let mut cx = self.cx.borrow_mut();
|
||||
cx.call_event_handler(&Event::WindowClosed(event));
|
||||
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_shadow = None;
|
||||
state.pointer_enter_serial = None;
|
||||
state.last_resize_edge = None;
|
||||
}
|
||||
if state.keyboard_window == Some(window_id) {
|
||||
state.keyboard_window = None;
|
||||
|
|
@ -640,9 +698,13 @@ impl WaylandCx {
|
|||
} else {
|
||||
&window.create_app_id
|
||||
};
|
||||
let decoration_preference = self
|
||||
.decoration_preference_override
|
||||
.unwrap_or(window.wayland_decorations);
|
||||
let window = WaylandWindow::new(
|
||||
window_id,
|
||||
compositor,
|
||||
state.subcompositor.as_ref(),
|
||||
wm_base,
|
||||
state.decoration_manager.as_ref(),
|
||||
state.scale_manager.as_ref(),
|
||||
|
|
@ -656,6 +718,7 @@ impl WaylandCx {
|
|||
&window.create_title,
|
||||
app_id,
|
||||
window.is_fullscreen,
|
||||
decoration_preference,
|
||||
);
|
||||
if cx.windows[window_id].backdrop != crate::window::WindowBackdrop::None {
|
||||
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
|
||||
// `get_pass_rect()` produce NaN once the flag is on.
|
||||
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);
|
||||
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);
|
||||
let layout_geom = cx_window.native_window_geom_to_layout(native_geom);
|
||||
cx_window.window_geom = layout_geom;
|
||||
|
|
@ -766,25 +833,20 @@ impl WaylandCx {
|
|||
}
|
||||
}
|
||||
CxOsOp::ResizeWindow(window_id, size) => {
|
||||
// A Wayland client has no "set my size" request. Window geometry is by
|
||||
// default whatever the surface commits -- `xdg_surface.set_window_geometry`:
|
||||
// "If never set, the value is the full bounds of the surface ... This
|
||||
// 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.
|
||||
// A Wayland client has no "set my size" request. A self-resize changes the
|
||||
// next EGL buffer, viewport destination, and explicit xdg window geometry;
|
||||
// the latter excludes any CSD shadow subsurfaces from placement and snapping.
|
||||
//
|
||||
// Only a floating toplevel may choose its own size. Under xdg_toplevel's
|
||||
// `maximized` state the configured window geometry must be obeyed "or the
|
||||
// xdg_wm_base.invalid_surface_state error is raised", which disconnects the
|
||||
// client; under `fullscreen` the configured geometry is a maximum. The
|
||||
// configure handler folds both states into `is_fullscreen`, so that one
|
||||
// flag gates the operation. Popups take their extent from their positioner
|
||||
// and are deliberately not matched here.
|
||||
// client; under `fullscreen` the configured geometry is a maximum. Popups
|
||||
// take their extent from their positioner and are deliberately not matched
|
||||
// here.
|
||||
if let Some(window) =
|
||||
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!(
|
||||
"ResizeWindow ignored: a maximized or fullscreen Wayland toplevel \
|
||||
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.
|
||||
// xdg_toplevel exposes no absolute-positioning request: `move` is
|
||||
// interactive and serial-gated ("This request must be used in response to
|
||||
// some sort of user action"), and `reposition` is an xdg_popup request
|
||||
// requiring xdg_wm_base v3, which `wayland_state.rs` does not bind. This arm
|
||||
// is correct as a permanent no-op.
|
||||
// some sort of user action"). `xdg_popup.reposition` only moves a popup
|
||||
// relative to its parent using a new positioner; it cannot place a toplevel at
|
||||
// absolute screen coordinates. This arm is therefore a permanent no-op.
|
||||
CxOsOp::RepositionWindow(_window_id, _size) => {}
|
||||
CxOsOp::SetWindowTitle(window_id, title) => {
|
||||
if let Some(window) = state.windows.iter().find(|w| w.window_id == window_id) {
|
||||
|
|
@ -865,9 +927,15 @@ impl WaylandCx {
|
|||
cx.call_event_handler(&Event::DragEnd);
|
||||
}
|
||||
CxOsOp::SetCursor(cursor) => {
|
||||
if let Some(cursor_shape) = state.cursor_shape.as_ref() {
|
||||
if let Some(serial) = state.pointer_serial.as_ref() {
|
||||
cursor_shape.set_shape(*serial, cursor.into());
|
||||
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(serial) = state.pointer_enter_serial.as_ref() {
|
||||
cursor_shape.set_shape(*serial, cursor.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1176,13 +1244,26 @@ impl WaylandCx {
|
|||
continue;
|
||||
}
|
||||
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) =
|
||||
state.windows.iter_mut().find(|w| w.window_id == window_id)
|
||||
{
|
||||
if !window.configured {
|
||||
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() {
|
||||
crate::log!(
|
||||
"Wayland paint window={:?} inner=({}, {}) dpi={} pix=({}, {})",
|
||||
|
|
@ -1199,8 +1280,7 @@ impl WaylandCx {
|
|||
// `wp_viewport.set_destination` raises the `bad_value` protocol
|
||||
// error, which disconnects the client, on a zero or negative
|
||||
// extent, and a float-to-int cast turns both a negative and a NaN
|
||||
// into zero. Floor the destination the way `resize_buffers` floors
|
||||
// the EGL extent.
|
||||
// into zero. Floor the destination the same way as the EGL extent.
|
||||
viewport.set_destination(
|
||||
window.window_geom.inner_size.x.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 {
|
||||
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() {
|
||||
viewport.set_source(-1., -1., -1., -1.);
|
||||
// `wp_viewport.set_destination` raises the `bad_value` protocol
|
||||
// error, which disconnects the client, on a zero or negative
|
||||
// extent, and a float-to-int cast turns both a negative and a NaN
|
||||
// into zero. Floor the destination the way `resize_buffers` floors
|
||||
// the EGL extent.
|
||||
// into zero. Floor the destination the same way as the EGL extent.
|
||||
viewport.set_destination(
|
||||
window.window_geom.inner_size.x.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
|
|
@ -9,6 +9,7 @@ use crate::window::MacosWindowKind;
|
|||
use crate::window::MacosWindowLevel;
|
||||
use crate::window::ScriptWindowHandle;
|
||||
use crate::window::WindowBackdrop;
|
||||
use crate::window::WaylandDecorationPreference;
|
||||
use crate::*;
|
||||
|
||||
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_api!(vm, draw.MouseCursor);
|
||||
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.MacosWindowChrome);
|
||||
set_script_value_to_api!(vm, draw.MacosWindowLevel);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,21 @@ pub enum MacosWindowLevel {
|
|||
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)]
|
||||
pub struct MacosWindowConfig {
|
||||
#[live]
|
||||
|
|
@ -353,6 +368,9 @@ impl WindowHandle {
|
|||
cxwindow.popup_position = None;
|
||||
cxwindow.popup_size = None;
|
||||
cxwindow.popup_grab_keyboard = true;
|
||||
cxwindow.wayland_decorations = WaylandDecorationPreference::default();
|
||||
cxwindow.uses_client_side_decorations = false;
|
||||
cxwindow.wayland_is_fullscreen = false;
|
||||
cx.platform_ops
|
||||
.push_back(CxOsOp::CreateWindow(window.window_id()));
|
||||
window
|
||||
|
|
@ -379,6 +397,9 @@ impl WindowHandle {
|
|||
cxwindow.popup_position = Some(position);
|
||||
cxwindow.popup_size = Some(size);
|
||||
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
|
||||
};
|
||||
cx.platform_ops.push_back(CxOsOp::CreatePopupWindow {
|
||||
|
|
@ -420,6 +441,10 @@ pub struct ScriptWindowHandle {
|
|||
pub backdrop_intensity: f32,
|
||||
#[live(MacosWindowConfig::default())]
|
||||
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.
|
||||
/// * 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
|
||||
|
|
@ -470,6 +495,9 @@ impl ScriptHook for ScriptWindowHandle {
|
|||
.normalized();
|
||||
let macos = self.macos.normalized();
|
||||
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 {
|
||||
cx.windows[window_id].transparent = visuals.transparent;
|
||||
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) {
|
||||
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 {
|
||||
cx.windows[self.window_id()].get_inner_size()
|
||||
}
|
||||
|
|
@ -563,6 +604,17 @@ impl WindowHandle {
|
|||
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 {
|
||||
cx.windows[self.window_id()].window_geom.xr_is_presenting
|
||||
}
|
||||
|
|
@ -683,6 +735,11 @@ pub struct CxWindow {
|
|||
pub backdrop: WindowBackdrop,
|
||||
pub backdrop_intensity: f32,
|
||||
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 {
|
||||
|
|
@ -709,6 +766,9 @@ impl Default for CxWindow {
|
|||
backdrop: WindowBackdrop::None,
|
||||
backdrop_intensity: 1.0,
|
||||
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_eq!(cx_window.backdrop, WindowBackdrop::None);
|
||||
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]
|
||||
|
|
@ -1084,6 +1170,7 @@ mod tests {
|
|||
backdrop: WindowBackdrop::Blur,
|
||||
backdrop_intensity: 0.5,
|
||||
macos: MacosWindowConfig::floating_panel(),
|
||||
wayland_decorations: WaylandDecorationPreference::ClientSide,
|
||||
caption_bar_height_override: None,
|
||||
};
|
||||
|
||||
|
|
@ -1106,6 +1193,10 @@ mod tests {
|
|||
}
|
||||
);
|
||||
assert_eq!(cx_window.macos, MacosWindowConfig::floating_panel());
|
||||
assert_eq!(
|
||||
cx_window.wayland_decorations,
|
||||
WaylandDecorationPreference::ClientSide
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ pub fn list_profiles()->Result<(), String>{
|
|||
}
|
||||
*/
|
||||
impl PlistValues {
|
||||
fn to_plist_file(&self, os: AppleOs) -> String {
|
||||
pub(super) fn to_plist_file(&self, os: AppleOs) -> String {
|
||||
match os {
|
||||
AppleOs::Tvos => self.to_tvos_plist_file(),
|
||||
AppleOs::Ios => self.to_ios_plist_file(),
|
||||
|
|
@ -701,10 +701,35 @@ pub fn build(
|
|||
apple_target: AppleTarget,
|
||||
) -> Result<IosBuildResult, String> {
|
||||
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 =
|
||||
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_str = target_dir.to_string_lossy().to_string();
|
||||
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)?;
|
||||
|
||||
// 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 app_dir = target_dir.join(format!(
|
||||
|
|
@ -773,7 +779,7 @@ pub fn build(
|
|||
mkdir(&app_dir)?;
|
||||
|
||||
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) {
|
||||
match generate_app_icon_xcassets(&app_dir, build_crate) {
|
||||
|
|
|
|||
161
tools/cargo_makepad/src/apple/info_plist.rs
Normal file
161
tools/cargo_makepad/src/apple/info_plist.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
300
tools/cargo_makepad/src/apple/info_plist/tests.rs
Normal file
300
tools/cargo_makepad/src/apple/info_plist/tests.rs
Normal 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" & review <text>.</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 & <two></string><real>1.5</real></array>
|
||||
<key>odd: key & <tag></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");
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
mod compile;
|
||||
mod info_plist;
|
||||
mod sdk;
|
||||
use crate::utils::{get_build_crate_from_args, get_package_binary_name};
|
||||
use compile::*;
|
||||
|
|
|
|||
|
|
@ -143,6 +143,12 @@ fn show_help() {
|
|||
" --device=<DEVICE_NAME> The device name to use for signing/provisioning"
|
||||
);
|
||||
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!();
|
||||
println!(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub fn derive_widget(input: TokenStream) -> TokenStream {
|
|||
area,
|
||||
event,
|
||||
visible,
|
||||
imperative,
|
||||
action_data,
|
||||
uid,
|
||||
cast,
|
||||
|
|
|
|||
|
|
@ -254,8 +254,12 @@ struct AnimatorTrack {
|
|||
play: Play,
|
||||
/// The ease function
|
||||
ease: Ease,
|
||||
/// The target apply object (what we're animating to)
|
||||
target_apply: ScriptObject,
|
||||
/// The target apply object (what we're animating to).
|
||||
/// 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)
|
||||
/// This is a SEPARATE object from state_object - it must not be mutated during animation
|
||||
/// Uses ScriptObjectRef to prevent GC from freeing it
|
||||
|
|
@ -318,6 +322,16 @@ impl ScriptHook for Animator {
|
|||
let Some(obj) = value.as_object() else {
|
||||
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);
|
||||
// Minted from the VM we are running in, so this always resolves; the
|
||||
// fallback only exists because the lookup is fallible in general.
|
||||
|
|
@ -362,7 +376,11 @@ impl ScriptApplyDefault for Animator {
|
|||
_scope: &mut Scope,
|
||||
_value: 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;
|
||||
}
|
||||
|
||||
|
|
@ -542,7 +560,7 @@ impl Animator {
|
|||
// 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.
|
||||
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();
|
||||
|
||||
// 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
|
||||
vm.bx.heap.new_object_ref(snapshot)
|
||||
// Create ScriptObjectRefs to prevent GC from freeing either object
|
||||
// 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)
|
||||
|
|
@ -601,7 +623,7 @@ impl Animator {
|
|||
start_time: f64::NEG_INFINITY,
|
||||
play,
|
||||
ease,
|
||||
target_apply,
|
||||
target_apply: target_apply_ref,
|
||||
from_snapshot,
|
||||
redraw: target_state.redraw,
|
||||
};
|
||||
|
|
@ -852,7 +874,7 @@ impl Animator {
|
|||
vm,
|
||||
state_obj,
|
||||
track.from_snapshot.as_object(),
|
||||
track.target_apply,
|
||||
track.target_apply.as_object(),
|
||||
mix,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,10 +60,7 @@ script_mod! {
|
|||
return sdf.result
|
||||
}
|
||||
DesktopButtonType.WindowsMaxToggled => {
|
||||
let sz = 5.
|
||||
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.rect(c.x - sz, c.y - sz, 2. * sz, 2. * sz)
|
||||
sdf.stroke(color, 0.5 + 0.5 * self.draw_pass.dpi_dilate)
|
||||
return sdf.result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -420,6 +420,7 @@ pub struct DropDown {
|
|||
#[rust]
|
||||
popup_global: PopupMenuGlobal,
|
||||
|
||||
#[imperative]
|
||||
#[live]
|
||||
selected_item: usize,
|
||||
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ pub use crate::{
|
|||
WidgetSet, WidgetSetIterator, WidgetUid,
|
||||
},
|
||||
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,
|
||||
},
|
||||
widget_match_event::WidgetMatchEvent,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub struct PageFlip {
|
|||
layout: Layout,
|
||||
#[live(false)]
|
||||
lazy_init: bool,
|
||||
#[imperative]
|
||||
#[live]
|
||||
active_page: LiveId,
|
||||
#[rust]
|
||||
|
|
|
|||
|
|
@ -552,6 +552,9 @@ impl Splash {
|
|||
/// 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
|
||||
/// 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> {
|
||||
if self.vm_id == MAIN_SPLASH_VM_ID {
|
||||
return None;
|
||||
|
|
@ -560,13 +563,14 @@ impl Splash {
|
|||
let body_id = self.body_id;
|
||||
cx.with_script_vm_id(self.vm_id, |vm| {
|
||||
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)) {
|
||||
return Some(body.scope.as_object());
|
||||
return Some(ended_in(body));
|
||||
}
|
||||
bodies.iter().find_map(|body| match &body.source {
|
||||
ScriptSource::Mod(m) if m.module_path == body_key => {
|
||||
Some(body.scope.as_object())
|
||||
}
|
||||
ScriptSource::Mod(m) if m.module_path == body_key => Some(ended_in(body)),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ pub struct View {
|
|||
#[live]
|
||||
event_order: EventOrder,
|
||||
|
||||
#[imperative]
|
||||
#[live(true)]
|
||||
pub visible: bool,
|
||||
#[live(false)]
|
||||
|
|
@ -123,9 +124,15 @@ pub struct View {
|
|||
|
||||
#[live]
|
||||
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]
|
||||
script_async: ScriptAsyncCalls,
|
||||
#[rust]
|
||||
item_tap_live: bool,
|
||||
|
||||
#[rust]
|
||||
scroll_bars_obj: Option<Box<ScrollBars>>,
|
||||
|
|
@ -920,6 +927,35 @@ impl Widget for View {
|
|||
if let Some(scroll_bars) = &mut self.scroll_bars_obj {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use {
|
|||
std::any::Any,
|
||||
std::cell::RefCell,
|
||||
std::collections::{HashMap, VecDeque},
|
||||
std::sync::atomic::{AtomicU64, Ordering},
|
||||
std::sync::atomic::{AtomicU64, AtomicU8, Ordering},
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
/// 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! {
|
||||
/// 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
|
||||
|
|
@ -394,7 +418,17 @@ impl CxSplashVmExt for Cx {
|
|||
bx: Box::new(ScriptVmBase::new()),
|
||||
};
|
||||
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
|
||||
// ambient-authority modules from the isolate's namespace entirely:
|
||||
// filesystem access (`fs`), child processes (`run`), and the resource
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ script_mod! {
|
|||
draw_bg.button_type: DesktopButtonType.WindowsMin
|
||||
width: 46 height: 29
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -171,7 +171,7 @@ script_mod! {
|
|||
draw_bg.button_type: DesktopButtonType.WindowsMax
|
||||
width: 46 height: 29
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -179,7 +179,7 @@ script_mod! {
|
|||
draw_bg.button_type: DesktopButtonType.WindowsClose
|
||||
width: 46 height: 29
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -374,13 +374,17 @@ pub struct Window {
|
|||
/// Used to only emit a platform op when the resolved value actually changes.
|
||||
#[rust]
|
||||
system_bar_dark_icons: Option<bool>,
|
||||
/// Cached `(caption_bar visible, caption rect, buttons rect)` for `WindowDragQuery`. That event
|
||||
/// fires once per `WM_NCHITTEST` — i.e. on every mouse move on Windows — and resolving the
|
||||
/// views + their areas each time runs widget-tree lookups, a real source of scroll jitter when
|
||||
/// the mouse is moved during a fling. These only change on relayout, so we recompute lazily and
|
||||
/// invalidate on `WindowGeomChange`.
|
||||
/// Cached `(caption_bar visible, caption rect, buttons rect)` for `WindowDragQuery`. It is
|
||||
/// refreshed only after layout finishes, so a synchronous native hit-test between configure
|
||||
/// and redraw cannot preserve rectangles from the previous window size.
|
||||
#[rust]
|
||||
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
|
||||
/// `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
|
||||
|
|
@ -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 {
|
||||
fn new(cx: &mut Cx) -> Self {
|
||||
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);
|
||||
}
|
||||
OsType::LinuxWindow(params) => {
|
||||
// Only show the caption bar if we're drawing our own window chrome
|
||||
// (e.g. Wayland without server-side decorations). On X11 the WM
|
||||
// provides native decorations, so we hide the in-app caption bar.
|
||||
let custom_chrome = params.custom_window_chrome;
|
||||
// X11 uses WM decorations. Wayland decides per window from the
|
||||
// compositor's xdg-decoration configure event.
|
||||
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))
|
||||
.set_visible(cx, self.show_caption_bar && custom_chrome);
|
||||
if custom_chrome {
|
||||
self.view(cx, ids!(windows_buttons)).set_visible(cx, true);
|
||||
}
|
||||
.set_visible(cx, visible);
|
||||
self.view(cx, ids!(windows_buttons))
|
||||
.set_visible(cx, visible);
|
||||
}
|
||||
OsType::LinuxDirect | OsType::Android(_) => {
|
||||
//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) {
|
||||
// Explicit DSL override takes priority, then system-calculated.
|
||||
let height = self
|
||||
|
|
@ -1117,6 +1189,7 @@ impl Window {
|
|||
if self.caption_query_sig != Some(caption_sig) {
|
||||
self.caption_query_sig = Some(caption_sig);
|
||||
self.drag_query_cache = None;
|
||||
self.drag_query_layout_valid = false;
|
||||
}
|
||||
|
||||
self.sync_caption_bar_state(cx);
|
||||
|
|
@ -1313,6 +1386,14 @@ impl Window {
|
|||
|
||||
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);
|
||||
cx.end_pass(&self.pass.handle);
|
||||
}
|
||||
|
|
@ -1342,6 +1423,16 @@ impl Window {
|
|||
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 {
|
||||
self.window.handle.window_id().id()
|
||||
}
|
||||
|
|
@ -1363,6 +1454,66 @@ mod tests {
|
|||
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 {
|
||||
|
|
@ -1478,6 +1629,16 @@ impl WindowRef {
|
|||
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 {
|
||||
|
|
@ -1538,8 +1699,10 @@ impl Widget for Window {
|
|||
Event::WindowGeomChange(ev) => {
|
||||
if ev.window_id == self.window.window_id() {
|
||||
// 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_layout_valid = false;
|
||||
match cx.os_type() {
|
||||
OsType::Windows | OsType::Macos => {
|
||||
if self.hide_caption_on_fullscreen && !cx.in_makepad_studio() {
|
||||
|
|
@ -1600,42 +1763,51 @@ impl Widget for Window {
|
|||
}
|
||||
Event::WindowDragQuery(dq) => {
|
||||
if dq.window_id == self.window.window_id() {
|
||||
// Resolve the caption / buttons geometry at most once per relayout; this event
|
||||
// arrives per mouse-move (per WM_NCHITTEST) and the view lookups are not free.
|
||||
let (visible, caption_rect, buttons_rect) = match self.drag_query_cache {
|
||||
Some(c) => c,
|
||||
// A native query can arrive synchronously after configure but before redraw.
|
||||
// Use live areas for that one query, but only a completed draw may cache them.
|
||||
let cache_ready = self.drag_query_cache.is_some() || self.drag_query_layout_valid;
|
||||
let geometry = match self.drag_query_cache {
|
||||
Some(cached) => cached,
|
||||
None => {
|
||||
let visible = self.view(cx, ids!(caption_bar)).visible();
|
||||
let caption_rect = self.view(cx, ids!(caption_bar)).area().rect(cx);
|
||||
let buttons_view = self.view(cx, ids!(windows_buttons));
|
||||
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));
|
||||
let live = self.caption_drag_geometry(cx);
|
||||
if self.drag_query_layout_valid {
|
||||
self.drag_query_cache = Some(live);
|
||||
}
|
||||
(visible, caption_rect, buttons_rect)
|
||||
live
|
||||
}
|
||||
};
|
||||
if visible {
|
||||
if caption_rect.contains(dq.abs) {
|
||||
if buttons_rect.size != Vec2d::default()
|
||||
&& buttons_rect.contains(dq.abs)
|
||||
{
|
||||
dq.response.set(WindowDragQueryResponse::Client);
|
||||
} else {
|
||||
dq.response.set(WindowDragQueryResponse::Caption);
|
||||
}
|
||||
let (visible, mut caption_rect, buttons_rect) = geometry;
|
||||
if !cache_ready {
|
||||
caption_rect = configured_window_caption_rect(
|
||||
caption_rect,
|
||||
cx.windows[dq.window_id].window_geom.inner_size,
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
WindowDragQueryResponse::NoAnswer | WindowDragQueryResponse::SysMenu => {}
|
||||
}
|
||||
}
|
||||
true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue