diff --git a/platform/script/derive/src/derive_scriptable.rs b/platform/script/derive/src/derive_scriptable.rs index 40e4fcd16..e92adae4f 100644 --- a/platform/script/derive/src/derive_scriptable.rs +++ b/platform/script/derive/src/derive_scriptable.rs @@ -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 diff --git a/platform/script/derive/src/lib.rs b/platform/script/derive/src/lib.rs index 808e0ef6f..67c090605 100644 --- a/platform/script/derive/src/lib.rs +++ b/platform/script/derive/src/lib.rs @@ -36,6 +36,7 @@ pub fn script_err_gen(input: TokenStream) -> TokenStream { source, new, live, + imperative, rust, pick, splat, diff --git a/platform/script/src/apply.rs b/platform/script/src/apply.rs index a39e18425..8375d8d07 100644 --- a/platform/script/src/apply.rs +++ b/platform/script/src/apply.rs @@ -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, diff --git a/platform/script/src/opcodes_control.rs b/platform/script/src/opcodes_control.rs index 41bd0925a..97fac4f03 100644 --- a/platform/script/src/opcodes_control.rs +++ b/platform/script/src/opcodes_control.rs @@ -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() diff --git a/platform/script/src/prims.rs b/platform/script/src/prims.rs index 2bdb90108..649f1dc6c 100644 --- a/platform/script/src/prims.rs +++ b/platform/script/src/prims.rs @@ -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(); diff --git a/platform/script/src/thread.rs b/platform/script/src/thread.rs index afe9479b8..466049725 100644 --- a/platform/script/src/thread.rs +++ b/platform/script/src/thread.rs @@ -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, + /// 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, 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 diff --git a/platform/script/src/traits.rs b/platform/script/src/traits.rs index cce1f9b54..6d4f1621b 100644 --- a/platform/script/src/traits.rs +++ b/platform/script/src/traits.rs @@ -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() diff --git a/platform/script/src/vm.rs b/platform/script/src/vm.rs index 5628070d9..78efcf838 100644 --- a/platform/script/src/vm.rs +++ b/platform/script/src/vm.rs @@ -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, pub checkpoint: Option, 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, }; diff --git a/platform/script/tests/hook_scope.rs b/platform/script/tests/hook_scope.rs new file mode 100644 index 000000000..76d5cc578 --- /dev/null +++ b/platform/script/tests/hook_scope.rs @@ -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) { + 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"); +} diff --git a/platform/src/app_main.rs b/platform/src/app_main.rs index 064ed5d28..8743718bc 100644 --- a/platform/src/app_main.rs +++ b/platform/src/app_main.rs @@ -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, ); diff --git a/platform/src/arc_string_mut.rs b/platform/src/arc_string_mut.rs index 52825eb55..1b8beafce 100644 --- a/platform/src/arc_string_mut.rs +++ b/platform/src/arc_string_mut.rs @@ -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 diff --git a/platform/src/cx.rs b/platform/src/cx.rs index 16c25404c..a98f9067e 100644 --- a/platform/src/cx.rs +++ b/platform/src/cx.rs @@ -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, 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, diff --git a/platform/src/cx_api.rs b/platform/src/cx_api.rs index 2e8c6936b..55480621b 100644 --- a/platform/src/cx_api.rs +++ b/platform/src/cx_api.rs @@ -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` diff --git a/platform/src/event/finger.rs b/platform/src/event/finger.rs index c4c4099e7..0fb1eabaf 100644 --- a/platform/src/event/finger.rs +++ b/platform/src/event/finger.rs @@ -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 { diff --git a/platform/src/lib.rs b/platform/src/lib.rs index 43762968d..dcbb9ab8a 100644 --- a/platform/src/lib.rs +++ b/platform/src/lib.rs @@ -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, diff --git a/platform/src/os/cx_shared.rs b/platform/src/os/cx_shared.rs index e65406649..a87bb6d46 100644 --- a/platform/src/os/cx_shared.rs +++ b/platform/src/os/cx_shared.rs @@ -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(); } diff --git a/platform/src/os/linux/opengl_cx.rs b/platform/src/os/linux/opengl_cx.rs index 030098712..ece36edb4 100644 --- a/platform/src/os/linux/opengl_cx.rs +++ b/platform/src/os/linux/opengl_cx.rs @@ -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. diff --git a/platform/src/os/linux/wayland/linux_wayland.rs b/platform/src/os/linux/wayland/linux_wayland.rs index e20132132..cc44846bd 100644 --- a/platform/src/os/linux/wayland/linux_wayland.rs +++ b/platform/src/os/linux/wayland/linux_wayland.rs @@ -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 { + match value { + "server" | "server-side" => Some(WaylandDecorationPreference::ServerSide), + "client" | "client-side" => Some(WaylandDecorationPreference::ClientSide), + _ => None, + } +} + +fn decoration_preference_override() -> Option { + 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>) { 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, } 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); + } +} diff --git a/platform/src/os/linux/wayland/opengl_wayland.rs b/platform/src/os/linux/wayland/opengl_wayland.rs index b9c76fb76..0bbd62509 100644 --- a/platform/src/os/linux/wayland/opengl_wayland.rs +++ b/platform/src/os/linux/wayland/opengl_wayland.rs @@ -1,16 +1,21 @@ #![allow(unused_imports)] use std::fs::File; use std::os::fd::{AsFd, AsRawFd, FromRawFd}; +use std::sync::Once; use crate::egl_sys::{EGLNativeWindowType, EGLSurface, NativeWindowType}; use crate::makepad_math::Vec2d; use wayland_client::protocol::__interfaces::WL_OUTPUT_INTERFACE; -use wayland_client::protocol::{wl_buffer, wl_compositor, wl_shm, wl_shm_pool, wl_surface}; +use wayland_client::protocol::{ + wl_buffer, wl_compositor, wl_region, wl_shm, wl_shm_pool, wl_subcompositor, wl_subsurface, + wl_surface, +}; use wayland_client::{Proxy, QueueHandle}; use wayland_egl::WlEglSurface; use wayland_protocols::wp::fractional_scale::v1::client::{ wp_fractional_scale_manager_v1, wp_fractional_scale_v1, }; +use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1; use wayland_protocols::wp::viewporter::client::{wp_viewport, wp_viewporter}; use wayland_protocols::xdg::decoration::zv1::client::{ zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1, @@ -26,7 +31,9 @@ use wayland_protocols::xdg::toplevel_icon::v1::client::{ use crate::opengl_cx::OpenglCx; use crate::screen::DEFAULT_WINDOW_SIZE; use crate::wayland::wayland_state::WaylandState; -use crate::{egl_sys, event::WindowGeom, WindowId}; +use crate::{ + egl_sys, event::WindowGeom, WaylandDecorationPreference, WindowId, +}; /// Wraps a `wl_egl_window` in an `EGLSurface`, or returns null when the driver refuses it — /// which it does for an extent it cannot allocate a buffer for, however willingly @@ -42,11 +49,540 @@ fn create_egl_window_surface(opengl_cx: &OpenglCx, wl_egl_surface: &WlEglSurface } } +fn initially_uses_client_side_decorations( + decoration_manager_available: bool, + preference: WaylandDecorationPreference, +) -> bool { + !decoration_manager_available || preference == WaylandDecorationPreference::ClientSide +} + +// libadwaita 1.9's light-theme CSD profile. The 25 px margin is also what +// native GNOME applications include around their xdg window geometry. +const CSD_SHADOW_MARGIN: i32 = 25; +// How far a corner tile reaches along each edge before the straight strips take +// over. A square corner's influence dies out where the widest layer's coverage +// reaches 1 within half a quantization step: 0.15 * (1 - phi((5 + k) / 7)) < 1/510 +// at k = 10.5, so 16 px leaves better than five px of margin on the seam. +const CSD_SHADOW_CORNER_INSET: i32 = 16; +const CSD_SHADOW_CORNER_SIZE: i32 = CSD_SHADOW_MARGIN + CSD_SHADOW_CORNER_INSET; +// How far the resize grab reaches outward from the window edge into the gutter. +// libadwaita sets its toplevel input region to the window rect grown by exactly +// this much; the rest of the gutter stays click-through so a window's shadow +// never steals a click from whatever is behind it. +const CSD_SHADOW_GRAB: i32 = 12; +static CSD_SHADOW_UNAVAILABLE_LOGGED: Once = Once::new(); + +/// One CSS `box-shadow` layer: the window rectangle grown by `spread`, blurred by a +/// Gaussian of standard deviation `sigma` (half the CSS blur radius), painted at +/// `alpha`. A zero `sigma` is an unblurred hard edge. +struct CsdShadowLayer { + sigma: f64, + spread: f64, + alpha: f64, +} + +/// libadwaita 1.9 `window.csd`, the profile every GNOME 50 app on a stock desktop casts: +/// `box-shadow: 0 0 14px 5px rgb(0 0 0/15%), 0 0 5px 2px rgb(0 0 0/10%), 0 0 0 1px rgb(0 0 0/5%)` +const CSD_SHADOW_ACTIVE_LAYERS: &[CsdShadowLayer] = &[ + CsdShadowLayer { sigma: 7.0, spread: 5.0, alpha: 0.15 }, + CsdShadowLayer { sigma: 2.5, spread: 2.0, alpha: 0.10 }, + CsdShadowLayer { sigma: 0.0, spread: 1.0, alpha: 0.05 }, +]; + +/// libadwaita 1.9 `window.csd:backdrop`. Its first term is `0 0 14px 5px transparent`, +/// which exists only to keep the shadow's extent identical to the focused profile, so +/// losing focus never changes any geometry. Being transparent it is omitted here. +const CSD_SHADOW_INACTIVE_LAYERS: &[CsdShadowLayer] = &[ + CsdShadowLayer { sigma: 5.0, spread: 5.0, alpha: 0.08 }, + CsdShadowLayer { sigma: 0.0, spread: 1.0, alpha: 0.05 }, +]; + +/// Abramowitz & Stegun 7.1.26, whose 1.5e-7 worst-case error is three orders of +/// magnitude below the 1/255 the result is quantized to. +fn csd_erf(x: f64) -> f64 { + const P: f64 = 0.3275911; + const A: [f64; 5] = [ + 0.254829592, + -0.284496736, + 1.421413741, + -1.453152027, + 1.061405429, + ]; + let sign = if x < 0.0 { -1.0 } else { 1.0 }; + let x = x.abs(); + let t = 1.0 / (1.0 + P * x); + let poly = A.iter().rev().fold(0.0, |acc, a| (acc + a) * t); + sign * (1.0 - poly * (-x * x).exp()) +} + +/// The fraction of one blurred half-plane covering a point `t` px outside its edge. +/// `t` is signed, so a negative value is inside the shadow rectangle. +fn csd_shadow_coverage(layer: &CsdShadowLayer, t: f64) -> f64 { + if layer.sigma <= 0.0 { + return if t < layer.spread { 1.0 } else { 0.0 }; + } + let z = (layer.spread - t) / (layer.sigma * std::f64::consts::SQRT_2); + 0.5 * (1.0 + csd_erf(z)) +} + +/// Composited shadow alpha at a point `tx` px outside the window's nearer vertical +/// edge and `ty` px outside its nearer horizontal edge, both signed. A rectangle's +/// Gaussian shadow is separable, so each layer's 2-D coverage is the product of its +/// two 1-D coverages; `f64::NEG_INFINITY` means "far enough inside that this axis +/// contributes full coverage", which is what an edge strip passes for the axis it +/// is constant along. +fn csd_shadow_alpha(layers: &[CsdShadowLayer], tx: f64, ty: f64) -> u32 { + let transmission = layers.iter().fold(1.0, |acc, layer| { + let coverage = csd_shadow_coverage(layer, tx) * csd_shadow_coverage(layer, ty); + acc * (1.0 - layer.alpha * coverage) + }); + ((1.0 - transmission) * 255.0).round() as u32 +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CsdShadowPieceKind { + TopLeft, + Top, + TopRight, + Left, + Right, + BottomLeft, + Bottom, + BottomRight, +} + +const CSD_SHADOW_PIECES: [CsdShadowPieceKind; 8] = [ + CsdShadowPieceKind::TopLeft, + CsdShadowPieceKind::Top, + CsdShadowPieceKind::TopRight, + CsdShadowPieceKind::Left, + CsdShadowPieceKind::Right, + CsdShadowPieceKind::BottomLeft, + CsdShadowPieceKind::Bottom, + CsdShadowPieceKind::BottomRight, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CsdShadowRect { + x: i32, + y: i32, + width: i32, + height: i32, +} + +fn csd_shadow_rect(kind: CsdShadowPieceKind, width: i32, height: i32) -> CsdShadowRect { + let m = CSD_SHADOW_MARGIN; + let i = CSD_SHADOW_CORNER_INSET; + let c = CSD_SHADOW_CORNER_SIZE; + match kind { + CsdShadowPieceKind::TopLeft => CsdShadowRect { + x: -m, + y: -m, + width: c, + height: c, + }, + CsdShadowPieceKind::Top => CsdShadowRect { + x: i, + y: -m, + width: width - 2 * i, + height: m, + }, + CsdShadowPieceKind::TopRight => CsdShadowRect { + x: width - i, + y: -m, + width: c, + height: c, + }, + CsdShadowPieceKind::Left => CsdShadowRect { + x: -m, + y: i, + width: m, + height: height - 2 * i, + }, + CsdShadowPieceKind::Right => CsdShadowRect { + x: width, + y: i, + width: m, + height: height - 2 * i, + }, + CsdShadowPieceKind::BottomLeft => CsdShadowRect { + x: -m, + y: height - i, + width: c, + height: c, + }, + CsdShadowPieceKind::Bottom => CsdShadowRect { + x: i, + y: height, + width: width - 2 * i, + height: m, + }, + CsdShadowPieceKind::BottomRight => CsdShadowRect { + x: width - i, + y: height - i, + width: c, + height: c, + }, + } +} + +fn csd_shadow_buffer_size(kind: CsdShadowPieceKind) -> (i32, i32) { + match kind { + CsdShadowPieceKind::TopLeft + | CsdShadowPieceKind::TopRight + | CsdShadowPieceKind::BottomLeft + | CsdShadowPieceKind::BottomRight => (CSD_SHADOW_CORNER_SIZE, CSD_SHADOW_CORNER_SIZE), + CsdShadowPieceKind::Top | CsdShadowPieceKind::Bottom => (1, CSD_SHADOW_MARGIN), + CsdShadowPieceKind::Left | CsdShadowPieceKind::Right => (CSD_SHADOW_MARGIN, 1), + } +} + +/// Where buffer pixel `(x, y)` of a piece sits relative to the window, as the signed +/// distance outside the nearer vertical edge and the nearer horizontal edge. Sampling +/// at pixel centres keeps the tiles seam-free against the strips they abut. +/// +/// Makepad's window is a plain rectangle, so both distances are measured from a square +/// corner. Sampling a rounded window's shadow here instead would leave the corners +/// visibly washed out, because a rounded corner's geometry recedes from the square +/// corner the shadow has to hug. +fn csd_shadow_offsets(kind: CsdShadowPieceKind, x: i32, y: i32) -> (f64, f64) { + const INSIDE: f64 = f64::NEG_INFINITY; + let margin = CSD_SHADOW_MARGIN as f64; + let inset = CSD_SHADOW_CORNER_INSET as f64; + let x = x as f64 + 0.5; + let y = y as f64 + 0.5; + // Left-hand and top pieces start `margin` px before the window; right-hand and + // bottom corner pieces start `inset` px inside it, and their strips start on it. + let past_left = margin - x; + let past_top = margin - y; + let past_right_corner = x - inset; + let past_bottom_corner = y - inset; + match kind { + CsdShadowPieceKind::TopLeft => (past_left, past_top), + CsdShadowPieceKind::Top => (INSIDE, past_top), + CsdShadowPieceKind::TopRight => (past_right_corner, past_top), + CsdShadowPieceKind::Left => (past_left, INSIDE), + CsdShadowPieceKind::Right => (x, INSIDE), + CsdShadowPieceKind::BottomLeft => (past_left, past_bottom_corner), + CsdShadowPieceKind::Bottom => (INSIDE, y), + CsdShadowPieceKind::BottomRight => (past_right_corner, past_bottom_corner), + } +} + +fn csd_shadow_pixel(kind: CsdShadowPieceKind, x: i32, y: i32, active: bool) -> u32 { + let layers = if active { + CSD_SHADOW_ACTIVE_LAYERS + } else { + CSD_SHADOW_INACTIVE_LAYERS + }; + let (tx, ty) = csd_shadow_offsets(kind, x, y); + // Premultiplied ARGB8888 black: with all three colour channels at zero the alpha + // is already the premultiplied value wl_shm requires. + csd_shadow_alpha(layers, tx, ty) << 24 +} + +/// The part of a piece that grabs the pointer for a resize, in its own surface-local +/// coordinates. The union of these across the eight pieces is the window rectangle +/// grown by [`CSD_SHADOW_GRAB`], which is exactly the input region libadwaita gives +/// its toplevels, minus the window itself (which the parent surface covers anyway). +/// +/// Each piece maps to one resize edge, so where the pointer landed is enough to know +/// which edge it grabbed and no coordinate hit-testing is needed. +fn csd_shadow_grab_rect(kind: CsdShadowPieceKind) -> CsdShadowRect { + // `wl_surface.set_input_region` ignores whatever falls outside the surface, so a + // span longer than any window keeps the stretched axis correct without ever + // needing to be re-sent on resize. + const SPAN: i32 = 1 << 20; + let outer = CSD_SHADOW_MARGIN - CSD_SHADOW_GRAB; + let inner = CSD_SHADOW_CORNER_INSET + CSD_SHADOW_GRAB; + let grab = CSD_SHADOW_GRAB; + let rect = |x, y, width, height| CsdShadowRect { x, y, width, height }; + match kind { + CsdShadowPieceKind::TopLeft => rect(outer, outer, SPAN, SPAN), + CsdShadowPieceKind::Top => rect(0, outer, SPAN, grab), + CsdShadowPieceKind::TopRight => rect(0, outer, inner, SPAN), + CsdShadowPieceKind::Left => rect(outer, 0, grab, SPAN), + CsdShadowPieceKind::Right => rect(0, 0, grab, SPAN), + CsdShadowPieceKind::BottomLeft => rect(outer, 0, SPAN, inner), + CsdShadowPieceKind::Bottom => rect(0, 0, SPAN, grab), + CsdShadowPieceKind::BottomRight => rect(0, 0, inner, inner), + } +} + +/// The edge a grab on this piece resizes. +fn csd_shadow_resize_edge(kind: CsdShadowPieceKind) -> xdg_toplevel::ResizeEdge { + use xdg_toplevel::ResizeEdge; + match kind { + CsdShadowPieceKind::TopLeft => ResizeEdge::TopLeft, + CsdShadowPieceKind::Top => ResizeEdge::Top, + CsdShadowPieceKind::TopRight => ResizeEdge::TopRight, + CsdShadowPieceKind::Left => ResizeEdge::Left, + CsdShadowPieceKind::Right => ResizeEdge::Right, + CsdShadowPieceKind::BottomLeft => ResizeEdge::BottomLeft, + CsdShadowPieceKind::Bottom => ResizeEdge::Bottom, + CsdShadowPieceKind::BottomRight => ResizeEdge::BottomRight, + } +} + +struct CsdShadowPiece { + kind: CsdShadowPieceKind, + surface: wl_surface::WlSurface, + subsurface: wl_subsurface::WlSubsurface, + viewport: wp_viewport::WpViewport, + active_buffer: wl_buffer::WlBuffer, + inactive_buffer: wl_buffer::WlBuffer, +} + +struct WaylandCsdShadow { + pieces: Vec, + state: Option<(i32, i32, bool, bool)>, +} + +fn csd_shadow_visible_at_size(visible: bool, width: i32, height: i32) -> bool { + visible + && width > 2 * CSD_SHADOW_CORNER_INSET + && height > 2 * CSD_SHADOW_CORNER_INSET +} + +impl WaylandCsdShadow { + fn new( + compositor: &wl_compositor::WlCompositor, + subcompositor: Option<&wl_subcompositor::WlSubcompositor>, + shm: Option<&wl_shm::WlShm>, + viewporter: Option<&wp_viewporter::WpViewporter>, + parent: &wl_surface::WlSurface, + qhandle: &QueueHandle, + ) -> Option { + let (Some(subcompositor), Some(shm), Some(viewporter)) = + (subcompositor, shm, viewporter) + else { + CSD_SHADOW_UNAVAILABLE_LOGGED.call_once(|| { + crate::warning!( + "Wayland client-side shadow unavailable: wl_subcompositor, wl_shm, and \ + wp_viewporter are required; continuing with client-side window controls" + ); + }); + return None; + }; + let Some(buffers) = Self::create_buffers(shm, qhandle) else { + CSD_SHADOW_UNAVAILABLE_LOGGED.call_once(|| { + crate::warning!( + "Wayland client-side shadow unavailable: could not allocate shared-memory \ + buffers; continuing with client-side window controls" + ); + }); + return None; + }; + let mut pieces = Vec::with_capacity(CSD_SHADOW_PIECES.len()); + for (kind, (active_buffer, inactive_buffer)) in + CSD_SHADOW_PIECES.into_iter().zip(buffers) + { + let surface = compositor.create_surface(qhandle, ()); + // The gutter is where this window is resized from, the way it is for every + // native app on the desktop: the pointer never has to compete with a widget + // for the edge, so the close button no longer swallows the top-right corner. + // Input regions are copied by the compositor at request time, and these are + // expressed so they survive any resize, so this is the only time they are set. + let grab = csd_shadow_grab_rect(kind); + let region = compositor.create_region(qhandle, ()); + region.add(grab.x, grab.y, grab.width, grab.height); + surface.set_input_region(Some(®ion)); + region.destroy(); + let subsurface = subcompositor.get_subsurface(&surface, parent, qhandle, ()); + subsurface.set_sync(); + subsurface.place_below(parent); + let viewport = viewporter.get_viewport(&surface, qhandle, ()); + pieces.push(CsdShadowPiece { + kind, + surface, + subsurface, + viewport, + active_buffer, + inactive_buffer, + }); + } + Some(Self { + pieces, + state: None, + }) + } + + /// The piece owning `surface`, if any. The pointer entering one means the pointer is + /// in this window's gutter rather than in the window. + fn piece_kind_for_surface( + &self, + surface_id: &wayland_client::backend::ObjectId, + ) -> Option { + self.pieces + .iter() + .find(|piece| piece.surface.id() == *surface_id) + .map(|piece| piece.kind) + } + + fn create_buffers( + shm: &wl_shm::WlShm, + qhandle: &QueueHandle, + ) -> Option> { + let mut offset = 0; + let layouts: Vec<_> = CSD_SHADOW_PIECES + .into_iter() + .map(|kind| { + let (width, height) = csd_shadow_buffer_size(kind); + let layout = (kind, width, height, offset); + offset += (width * height * 4) as usize; + layout + }) + .collect(); + let style_bytes = offset; + let total_bytes = style_bytes * 2; + let name = std::ffi::CString::new("makepad-csd-shadow").ok()?; + let raw_fd = + unsafe { crate::libc_sys::memfd_create(name.as_ptr(), crate::libc_sys::MFD_CLOEXEC) }; + if raw_fd < 0 { + return None; + } + let fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(raw_fd) }; + if unsafe { crate::libc_sys::ftruncate(fd.as_raw_fd(), total_bytes as i64) } != 0 { + return None; + } + let map = unsafe { + crate::libc_sys::mmap( + std::ptr::null_mut(), + total_bytes, + crate::libc_sys::PROT_READ | crate::libc_sys::PROT_WRITE, + crate::libc_sys::MAP_SHARED, + fd.as_raw_fd(), + 0, + ) + }; + if map == crate::libc_sys::MAP_FAILED { + return None; + } + let bytes = unsafe { std::slice::from_raw_parts_mut(map.cast::(), total_bytes) }; + for (style_index, active) in [true, false].into_iter().enumerate() { + for &(kind, width, height, offset) in &layouts { + let piece_offset = style_index * style_bytes + offset; + for y in 0..height { + for x in 0..width { + let pixel_offset = piece_offset + + ((y * width + x) * 4) as usize; + bytes[pixel_offset..pixel_offset + 4] + // wl_shm's ARGB8888 is defined as little endian regardless of + // the host, so the byte order cannot follow the CPU's. + .copy_from_slice(&csd_shadow_pixel(kind, x, y, active).to_le_bytes()); + } + } + } + } + unsafe { + crate::libc_sys::munmap(map, total_bytes); + } + + let pool = shm.create_pool(fd.as_fd(), total_bytes as i32, qhandle, ()); + let create_buffer = |offset: usize, width: i32, height: i32| { + pool.create_buffer( + offset as i32, + width, + height, + width * 4, + wl_shm::Format::Argb8888, + qhandle, + (), + ) + }; + let buffers = layouts + .into_iter() + .map(|(_, width, height, offset)| { + ( + create_buffer(offset, width, height), + create_buffer(style_bytes + offset, width, height), + ) + }) + .collect(); + pool.destroy(); + Some(buffers) + } + + fn is_visible(&self) -> bool { + self.state.is_some_and(|state| state.2) + } + + fn needs_update(&self, width: i32, height: i32, visible: bool, active: bool) -> bool { + let visible = csd_shadow_visible_at_size(visible, width, height); + let active = visible && active; + self.state != Some((width, height, visible, active)) + } + + fn update(&mut self, width: i32, height: i32, visible: bool, active: bool) -> bool { + // Nine-patch edge destinations must be positive. Makepad's 200x120 minimum is + // well above this; suppress the visual-only shadow for pathological sizes. + let visible = csd_shadow_visible_at_size(visible, width, height); + // Focus has no visual effect while detached. Ignoring it here also avoids + // repainting maximized, tiled, fullscreen, and server-decorated windows. + let active = visible && active; + let size_changed = self.state.map(|state| (state.0, state.1)) != Some((width, height)); + if !self.needs_update(width, height, visible, active) { + return false; + } + let was_visible = self.state.is_some_and(|state| state.2); + let style_changed = self.state.map_or(true, |state| state.3 != active); + for (kind, piece) in CSD_SHADOW_PIECES.into_iter().zip(&self.pieces) { + if visible { + let rect = csd_shadow_rect(kind, width, height); + // Both are sticky surface state, so a focus change — which swaps buffers + // at an unchanged size — has no reason to re-send them. + if !was_visible || size_changed { + piece.subsurface.set_position(rect.x, rect.y); + piece.viewport.set_destination(rect.width, rect.height); + } + if !was_visible || style_changed { + let buffer = if active { + &piece.active_buffer + } else { + &piece.inactive_buffer + }; + piece.surface.attach(Some(buffer), 0, 0); + } + if !was_visible || style_changed || size_changed { + piece.surface.damage(0, 0, rect.width, rect.height); + } + piece.surface.commit(); + } else if was_visible { + piece.surface.attach(None, 0, 0); + piece.surface.commit(); + } + } + self.state = Some((width, height, visible, active)); + size_changed + } + + fn destroy(self) { + for piece in self.pieces { + piece.viewport.destroy(); + piece.subsurface.destroy(); + piece.surface.destroy(); + piece.active_buffer.destroy(); + piece.inactive_buffer.destroy(); + } + } +} + +fn should_show_csd_shadow(uses_csd: bool, maximized: bool, fullscreen: bool, tiled: bool) -> bool { + uses_csd && !maximized && !fullscreen && !tiled +} + pub(crate) struct WaylandWindow { pub window_id: WindowId, pub base_surface: wl_surface::WlSurface, pub toplevel: xdg_toplevel::XdgToplevel, pub decoration: Option, + pub uses_client_side_decorations: bool, + pub pending_client_side_decorations: Option, + pub is_maximized: bool, + pub is_fullscreen: bool, + pub is_tiled: bool, + pub is_active: bool, + pub unavailable_resize_edges: u8, pub xdg_surface: xdg_surface::XdgSurface, pub viewport: Option, pub fractional_scale: Option, @@ -55,12 +591,17 @@ pub(crate) struct WaylandWindow { pub cal_size: Vec2d, pub wl_egl_surface: WlEglSurface, pub egl_surface: EGLSurface, + csd_shadow: Option, + /// The `(width, height, opaque)` the surface's opaque region was last set from, so a + /// steady-state frame re-sends nothing. + opaque_region_state: Option<(i32, i32, bool)>, } impl WaylandWindow { pub fn new( window_id: WindowId, compositer: &wl_compositor::WlCompositor, + subcompositor: Option<&wl_subcompositor::WlSubcompositor>, wm_base: &xdg_wm_base::XdgWmBase, decoration_manager: Option<&zxdg_decoration_manager_v1::ZxdgDecorationManagerV1>, scale_manager: Option<&wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1>, @@ -74,6 +615,7 @@ impl WaylandWindow { title: &str, app_id: &str, is_fullscreen: bool, + decoration_preference: WaylandDecorationPreference, ) -> WaylandWindow { // Checked "downcast" of the EGL platform display to a X11 display. assert_eq!(opengl_cx.egl_platform, egl_sys::EGL_PLATFORM_WAYLAND_KHR); @@ -91,12 +633,53 @@ impl WaylandWindow { // Set window icon via xdg-toplevel-icon-v1 if compositor supports it Self::set_wayland_icon(icon_manager, shm, &toplevel, qhandle); - let decoration = decoration_manager.map(|manager| { - let decoration = manager.get_toplevel_decoration(&toplevel, qhandle, ()); - decoration.set_mode(zxdg_toplevel_decoration_v1::Mode::ClientSide); - decoration + let uses_client_side_decorations = initially_uses_client_side_decorations( + decoration_manager.is_some(), + decoration_preference, + ); + let decoration = decoration_manager.and_then(|manager| { + if decoration_preference == WaylandDecorationPreference::ClientSide { + // Without negotiation the protocol requires clients to self-decorate; + // set_mode(ClientSide) would only be a preference the compositor may reject. + return None; + } + let decoration = manager.get_toplevel_decoration(&toplevel, qhandle, window_id); + decoration.set_mode(zxdg_toplevel_decoration_v1::Mode::ServerSide); + Some(decoration) }); + let surface_width = (inner_size.x as i32).max(1); + let surface_height = (inner_size.y as i32).max(1); + // Do not allocate eight shadow buffers and subsurfaces for a window that + // the compositor decorates. If negotiation later selects CSD, the + // configure handler creates them before the first client-decorated frame. + let mut csd_shadow = uses_client_side_decorations + .then(|| { + WaylandCsdShadow::new( + compositer, + subcompositor, + shm, + viewporter, + &base_surface, + qhandle, + ) + }) + .flatten(); + if let Some(shadow) = csd_shadow.as_mut() { + shell_surface.set_window_geometry(0, 0, surface_width, surface_height); + shadow.update( + surface_width, + surface_height, + should_show_csd_shadow( + uses_client_side_decorations, + false, + is_fullscreen, + false, + ), + false, + ); + } + if is_fullscreen { toplevel.set_fullscreen(None); } @@ -105,8 +688,8 @@ impl WaylandWindow { // `wl_egl_window_create` rejects a non-positive extent, and a float-to-int cast turns // both a negative and a NaN into zero, so the requested size is floored before the // call rather than allowed to panic an app at startup over a bad saved size. - let egl_w = (inner_size.x as i32).max(1); - let egl_h = (inner_size.y as i32).max(1); + let egl_w = surface_width; + let egl_h = surface_height; let mut wl_egl_surface = match WlEglSurface::new(base_surface.id(), egl_w, egl_h) { Ok(surface) => surface, Err(e) => { @@ -155,6 +738,13 @@ impl WaylandWindow { base_surface, toplevel, decoration, + uses_client_side_decorations, + pending_client_side_decorations: None, + is_maximized: false, + is_fullscreen, + is_tiled: false, + is_active: false, + unavailable_resize_edges: 0, viewport, fractional_scale, configured: false, @@ -164,6 +754,8 @@ impl WaylandWindow { window_geom: geom, wl_egl_surface, egl_surface, + csd_shadow, + opaque_region_state: None, } } /// Set the toplevel icon via xdg-toplevel-icon-v1 protocol using shm pixel data. @@ -254,27 +846,152 @@ impl WaylandWindow { // wl_buf kept alive until compositor reads it (destroyed on drop) } - pub fn resize_buffers(&mut self) -> bool { + pub fn prepare_buffer_size(&mut self, opengl_cx: &OpenglCx) -> bool { let cal_size = Vec2d { x: self.window_geom.inner_size.x * self.window_geom.dpi_factor, y: self.window_geom.inner_size.y * self.window_geom.dpi_factor, }; if self.cal_size != cal_size { - self.cal_size = cal_size; + // NVIDIA's Wayland EGL platform may defer resizing a non-current + // EGLSurface until its next swap. Bind this exact surface first so + // the next frame cannot mix the old buffer with the new viewport. + if !opengl_cx.make_current_with_surface(self.egl_surface) { + return false; + } let pix_width = cal_size.x.max(1.0) as i32; let pix_height = cal_size.y.max(1.0) as i32; self.wl_egl_surface.resize(pix_width, pix_height, 0, 0); - true + // Cache only a resize that was actually issued. A failed bind is + // retried on the next paint rather than leaving stale buffers. + self.cal_size = cal_size; + } + true + } + + /// Promises the compositor that the whole window rectangle is solid, so it can skip + /// blending this surface and cull everything the window covers, and can hand a + /// fullscreen buffer straight to the display controller instead of compositing it. + /// + /// The buffer is ARGB8888 — every EGL config Makepad accepts asks for 8 alpha bits — + /// so without this promise the compositor has no way to learn that the alpha channel + /// is uniformly opaque short of reading every pixel, and must assume it is not. + /// + /// `opaque` must be false for a window that actually wants translucency, or the + /// compositor will happily leave whatever was behind it on screen. The region covers + /// the base surface only: the shadow subsurfaces are genuinely translucent, and are + /// separate surfaces that keep their own (empty) opaque regions. + pub fn sync_opaque_region( + &mut self, + compositor: &wl_compositor::WlCompositor, + qhandle: &QueueHandle, + opaque: bool, + ) { + let width = (self.window_geom.inner_size.x as i32).max(1); + let height = (self.window_geom.inner_size.y as i32).max(1); + if self.opaque_region_state == Some((width, height, opaque)) { + return; + } + self.opaque_region_state = Some((width, height, opaque)); + if opaque { + let region = compositor.create_region(qhandle, ()); + region.add(0, 0, width, height); + self.base_surface.set_opaque_region(Some(®ion)); + region.destroy(); } else { - false + self.base_surface.set_opaque_region(None); } } + + /// Whether the shadow gutter is currently mapped, and therefore carrying this window's + /// resize grabs. While it is, the interior bands are redundant: they would only compete + /// with the app's own widgets for the pointer, which is what let the close button + /// swallow the top-right corner. A tiled window has no gutter and falls back to them. + pub fn csd_shadow_gutter_active(&self) -> bool { + self.csd_shadow + .as_ref() + .is_some_and(|shadow| shadow.is_visible()) + } + + /// The resize edge and cursor for a pointer that has entered one of this window's + /// shadow surfaces, or `None` if `surface` is not part of this window's shadow. + /// Edges the compositor has declared unavailable — a tiled window's shared borders — + /// are narrowed to the components that remain resizable, so a half-tiled window + /// keeps the corner grabs on its free axis. + pub fn csd_shadow_resize_for_surface( + &self, + surface_id: &wayland_client::backend::ObjectId, + ) -> Option<(xdg_toplevel::ResizeEdge, wp_cursor_shape_device_v1::Shape)> { + let kind = self + .csd_shadow + .as_ref()? + .piece_kind_for_surface(surface_id)?; + let edge = crate::wayland::wayland_state::available_resize_edge( + csd_shadow_resize_edge(kind), + self.unavailable_resize_edges, + )?; + Some((edge, crate::wayland::wayland_state::resize_edge_cursor(edge))) + } + + pub fn csd_shadow_needs_update(&self) -> bool { + let width = (self.window_geom.inner_size.x as i32).max(1); + let height = (self.window_geom.inner_size.y as i32).max(1); + let visible = should_show_csd_shadow( + self.uses_client_side_decorations, + self.is_maximized, + self.is_fullscreen, + self.is_tiled, + ); + self.csd_shadow + .as_ref() + .is_some_and(|shadow| shadow.needs_update(width, height, visible, self.is_active)) + } + + pub(crate) fn ensure_csd_shadow( + &mut self, + compositor: &wl_compositor::WlCompositor, + subcompositor: Option<&wl_subcompositor::WlSubcompositor>, + shm: Option<&wl_shm::WlShm>, + viewporter: Option<&wp_viewporter::WpViewporter>, + qhandle: &QueueHandle, + ) { + if self.csd_shadow.is_none() { + self.csd_shadow = WaylandCsdShadow::new( + compositor, + subcompositor, + shm, + viewporter, + &self.base_surface, + qhandle, + ); + } + } + + pub fn prepare_csd_shadow(&mut self) { + let width = (self.window_geom.inner_size.x as i32).max(1); + let height = (self.window_geom.inner_size.y as i32).max(1); + let visible = should_show_csd_shadow( + self.uses_client_side_decorations, + self.is_maximized, + self.is_fullscreen, + self.is_tiled, + ); + if let Some(shadow) = self.csd_shadow.as_mut() { + if shadow.update(width, height, visible, self.is_active) { + self.xdg_surface + .set_window_geometry(0, 0, width, height); + } + } + } + pub fn close_window(&mut self) { // Destroy in protocol order: role-specific objects first, base // surface last. if let Some(decoration) = self.decoration.take() { decoration.destroy(); } + if let Some(shadow) = self.csd_shadow.take() { + shadow.destroy(); + } self.toplevel.destroy(); self.xdg_surface.destroy(); if let Some(viewport) = self.viewport.take() { @@ -405,20 +1122,21 @@ impl WaylandPopupWindow { } } - pub fn resize_buffers(&mut self) -> bool { + pub fn prepare_buffer_size(&mut self, opengl_cx: &OpenglCx) -> bool { let cal_size = Vec2d { x: self.window_geom.inner_size.x * self.window_geom.dpi_factor, y: self.window_geom.inner_size.y * self.window_geom.dpi_factor, }; if self.cal_size != cal_size { - self.cal_size = cal_size; + if !opengl_cx.make_current_with_surface(self.egl_surface) { + return false; + } if let Some(ref wl_egl_surface) = self.wl_egl_surface { wl_egl_surface.resize(cal_size.x.max(1.0) as i32, cal_size.y.max(1.0) as i32, 0, 0); } - true - } else { - false + self.cal_size = cal_size; } + true } pub fn close_window(&mut self) { @@ -457,3 +1175,272 @@ impl Drop for WaylandPopupWindow { self.close_window(); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn alpha(pixel: u32) -> u8 { + (pixel >> 24) as u8 + } + + #[test] + fn decoration_initial_state_prefers_server_and_falls_back_without_protocol() { + assert!(!initially_uses_client_side_decorations( + true, + WaylandDecorationPreference::ServerSide + )); + assert!(initially_uses_client_side_decorations( + false, + WaylandDecorationPreference::ServerSide + )); + assert!(initially_uses_client_side_decorations( + true, + WaylandDecorationPreference::ClientSide + )); + } + + #[test] + fn shadow_layout_tiles_the_gutter_without_gaps_or_overlap() { + let expected = [ + (-25, -25, 41, 41), + (16, -25, 608, 25), + (624, -25, 41, 41), + (-25, 16, 25, 448), + (640, 16, 25, 448), + (-25, 464, 41, 41), + (16, 480, 608, 25), + (624, 464, 41, 41), + ]; + for (kind, expected) in CSD_SHADOW_PIECES.into_iter().zip(expected) { + let rect = csd_shadow_rect(kind, 640, 480); + assert_eq!((rect.x, rect.y, rect.width, rect.height), expected); + } + + // Every pixel of the ring around the window is covered exactly once. The pieces + // are translucent, so an overlap would double-blend into a visible seam. + // The smallest size `csd_shadow_visible_at_size` admits is checked too, since + // that is where the corner tiles come closest to colliding. + let smallest = 2 * CSD_SHADOW_CORNER_INSET + 1; + for (width, height) in [(640, 480), (smallest, smallest), (smallest, 480)] { + let rects: Vec<_> = CSD_SHADOW_PIECES + .into_iter() + .map(|kind| csd_shadow_rect(kind, width, height)) + .collect(); + for rect in &rects { + assert!( + rect.width > 0 && rect.height > 0, + "empty destination at {width}x{height}" + ); + } + for y in -CSD_SHADOW_MARGIN..height + CSD_SHADOW_MARGIN { + for x in -CSD_SHADOW_MARGIN..width + CSD_SHADOW_MARGIN { + let covers = rects + .iter() + .filter(|rect| { + x >= rect.x + && x < rect.x + rect.width + && y >= rect.y + && y < rect.y + rect.height + }) + .count(); + let inside_window = (0..width).contains(&x) && (0..height).contains(&y); + // The corner tiles reach `CSD_SHADOW_CORNER_INSET` px into the window, + // where the parent surface covers them; everywhere else in the ring is + // covered exactly once and nothing spills past the margin. + if inside_window { + assert!( + covers <= 1, + "overlap inside the {width}x{height} window at ({x}, {y})" + ); + } else { + assert_eq!( + covers, 1, + "gutter of {width}x{height} not covered once at ({x}, {y})" + ); + } + } + } + } + } + + #[test] + fn shadow_raster_is_monotonic_symmetric_and_seam_free() { + for i in 1..CSD_SHADOW_MARGIN { + let previous = alpha(csd_shadow_pixel(CsdShadowPieceKind::Top, 0, i - 1, true)); + let current = alpha(csd_shadow_pixel(CsdShadowPieceKind::Top, 0, i, true)); + assert!(current >= previous); + } + for i in 0..CSD_SHADOW_MARGIN { + assert_eq!( + alpha(csd_shadow_pixel(CsdShadowPieceKind::Top, 0, i, true)), + alpha(csd_shadow_pixel( + CsdShadowPieceKind::Bottom, + 0, + CSD_SHADOW_MARGIN - 1 - i, + true, + )) + ); + } + // The outermost row of the margin has faded to nothing, so the shadow does not + // end in a visible step. + assert_eq!( + alpha(csd_shadow_pixel(CsdShadowPieceKind::Top, 0, 0, true)), + 0 + ); + + // A square corner is where two half-covered edges meet, so it is lighter than a + // straight edge at the same distance but nowhere near the washed-out 14/255 that + // sampling a 15 px-rounded window's shadow here would give. + let corner = alpha(csd_shadow_pixel(CsdShadowPieceKind::TopLeft, 24, 24, true)); + let straight = alpha(csd_shadow_pixel(CsdShadowPieceKind::Top, 0, 24, true)); + assert_eq!((corner, straight), (44, 55)); + + let inner = csd_shadow_pixel(CsdShadowPieceKind::Top, 0, 24, true); + assert_eq!(inner & 0x00ff_ffff, 0, "must stay premultiplied black"); + assert_eq!(alpha(inner), straight); + } + + #[test] + fn shadow_corner_tiles_join_the_straight_edges_exactly() { + let last = CSD_SHADOW_CORNER_SIZE - 1; + for active in [false, true] { + // CSD_SHADOW_CORNER_INSET is chosen so the corner's second axis has reached + // full coverage by the time the strips take over: the join is not merely + // close, it is bit-identical, so no seam can appear at any scale. + for y in 0..CSD_SHADOW_MARGIN { + assert_eq!( + csd_shadow_pixel(CsdShadowPieceKind::TopLeft, last, y, active), + csd_shadow_pixel(CsdShadowPieceKind::Top, 0, y, active), + "top join differs at y={y}" + ); + } + for x in 0..CSD_SHADOW_MARGIN { + assert_eq!( + csd_shadow_pixel(CsdShadowPieceKind::TopLeft, x, last, active), + csd_shadow_pixel(CsdShadowPieceKind::Left, x, 0, active), + "left join differs at x={x}" + ); + } + + for y in 0..CSD_SHADOW_CORNER_SIZE { + for x in 0..CSD_SHADOW_CORNER_SIZE { + let mirror_x = last - x; + let mirror_y = last - y; + assert_eq!( + csd_shadow_pixel(CsdShadowPieceKind::TopLeft, x, y, active), + csd_shadow_pixel(CsdShadowPieceKind::TopRight, mirror_x, y, active) + ); + assert_eq!( + csd_shadow_pixel(CsdShadowPieceKind::TopLeft, x, y, active), + csd_shadow_pixel(CsdShadowPieceKind::BottomLeft, x, mirror_y, active) + ); + assert_eq!( + csd_shadow_pixel(CsdShadowPieceKind::TopLeft, x, y, active), + csd_shadow_pixel( + CsdShadowPieceKind::BottomRight, + mirror_x, + mirror_y, + active, + ) + ); + } + } + } + } + + #[test] + fn shadow_grab_rects_reach_exactly_the_libadwaita_input_region() { + // libadwaita grows its toplevel input region by 12 px on every side. The union of + // the eight grab rects must be the same ring, with each piece owning the side it + // resizes and none of them claiming the outer half of the gutter. + let (width, height) = (640, 480); + for kind in CSD_SHADOW_PIECES { + let rect = csd_shadow_rect(kind, width, height); + let grab = csd_shadow_grab_rect(kind); + // Clip the grab rect to the piece the way the compositor does, then place it + // in window coordinates. + let x0 = rect.x + grab.x.max(0); + let y0 = rect.y + grab.y.max(0); + let x1 = rect.x + (grab.x + grab.width).min(rect.width); + let y1 = rect.y + (grab.y + grab.height).min(rect.height); + assert!(x0 < x1 && y0 < y1, "{kind:?} has an empty grab region"); + assert!( + x0 >= -CSD_SHADOW_GRAB + && y0 >= -CSD_SHADOW_GRAB + && x1 <= width + CSD_SHADOW_GRAB + && y1 <= height + CSD_SHADOW_GRAB, + "{kind:?} grabs outside the 12 px halo: ({x0},{y0})..({x1},{y1})" + ); + } + + // The top-right corner is grabbable strictly outside the window, which is what + // keeps the close button from swallowing it. + let rect = csd_shadow_rect(CsdShadowPieceKind::TopRight, width, height); + let grab = csd_shadow_grab_rect(CsdShadowPieceKind::TopRight); + assert!(rect.x + grab.x + grab.width > width); + assert!(rect.y + grab.y < 0); + } + + #[test] + fn shadow_pieces_map_to_the_edge_they_sit_on() { + use xdg_toplevel::ResizeEdge; + for (kind, edge) in [ + (CsdShadowPieceKind::TopLeft, ResizeEdge::TopLeft), + (CsdShadowPieceKind::Top, ResizeEdge::Top), + (CsdShadowPieceKind::TopRight, ResizeEdge::TopRight), + (CsdShadowPieceKind::Left, ResizeEdge::Left), + (CsdShadowPieceKind::Right, ResizeEdge::Right), + (CsdShadowPieceKind::BottomLeft, ResizeEdge::BottomLeft), + (CsdShadowPieceKind::Bottom, ResizeEdge::Bottom), + (CsdShadowPieceKind::BottomRight, ResizeEdge::BottomRight), + ] { + assert_eq!(csd_shadow_resize_edge(kind), edge); + } + } + + #[test] + fn shadow_alpha_matches_the_installed_libadwaita_profile() { + let active = [54, 39, 34, 28, 24, 20, 17, 14, 12, 10, 8, 6, 5, 4, 3, 2, 1, 1, 1, 0]; + let inactive = [28, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 1, 1, 1, 0]; + for (distance, expected) in active.into_iter().enumerate() { + let actual = alpha(csd_shadow_pixel( + CsdShadowPieceKind::Top, + 0, + CSD_SHADOW_MARGIN - 1 - distance as i32, + true, + )); + assert!(actual.abs_diff(expected) <= 1); + } + for (distance, expected) in inactive.into_iter().enumerate() { + let actual = alpha(csd_shadow_pixel( + CsdShadowPieceKind::Top, + 0, + CSD_SHADOW_MARGIN - 1 - distance as i32, + false, + )); + assert!(actual.abs_diff(expected) <= 1); + } + assert!( + alpha(csd_shadow_pixel(CsdShadowPieceKind::Top, 0, 24, true)) + > alpha(csd_shadow_pixel(CsdShadowPieceKind::Top, 0, 24, false)) + ); + } + + #[test] + fn shadow_avoids_invalid_nine_patch_destinations_for_tiny_windows() { + assert!(!csd_shadow_visible_at_size(true, 32, 480)); + assert!(!csd_shadow_visible_at_size(true, 640, 32)); + assert!(csd_shadow_visible_at_size(true, 33, 33)); + assert!(!csd_shadow_visible_at_size(false, 640, 480)); + } + + #[test] + fn shadow_only_appears_on_floating_client_decorated_windows() { + assert!(should_show_csd_shadow(true, false, false, false)); + assert!(!should_show_csd_shadow(false, false, false, false)); + assert!(!should_show_csd_shadow(true, true, false, false)); + assert!(!should_show_csd_shadow(true, false, true, false)); + assert!(!should_show_csd_shadow(true, false, false, true)); + } +} diff --git a/platform/src/os/linux/wayland/wayland_state.rs b/platform/src/os/linux/wayland/wayland_state.rs index 1e8483540..5b6a5a9e0 100644 --- a/platform/src/os/linux/wayland/wayland_state.rs +++ b/platform/src/os/linux/wayland/wayland_state.rs @@ -4,7 +4,7 @@ use crate::{ makepad_math::{dvec2, Vec2d}, wayland::{wayland_type, xkb_sys}, Area, DragEvent, DragItem, DragResponse, DropEvent, KeyEvent, KeyModifiers, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, TextClipboardEvent, TextInputEvent, + MouseCursor, MouseDownEvent, MouseMoveEvent, MouseUpEvent, TextClipboardEvent, TextInputEvent, WindowClosedEvent, WindowDragQueryEvent, WindowDragQueryResponse, }; use std::{ @@ -21,7 +21,8 @@ use wayland_client::{ wl_buffer, wl_callback, wl_compositor, wl_data_device, wl_data_device_manager, wl_data_offer, wl_data_source, wl_keyboard, wl_output, wl_pointer::{self, ButtonState}, - wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_surface, + wl_region, wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_subcompositor, wl_subsurface, + wl_surface, }, Connection, Dispatch, Proxy, QueueHandle, WEnum, }; @@ -49,7 +50,10 @@ use wayland_protocols::{ use crate::{ cx_native::EventFlow, - event::{PopupDismissReason, PopupDismissedEvent, ScrollEvent, ScrollPhase, WindowGeom}, + event::{ + PopupDismissReason, PopupDismissedEvent, ScrollEvent, ScrollPhase, WindowGeom, + TAP_COUNT_DISTANCE, TAP_COUNT_TIME, + }, select_timer::SelectTimers, wayland::wayland_app::WaylandApp, x11::xlib_event::XlibEvent, @@ -62,6 +66,244 @@ use super::opengl_wayland::{WaylandPopupWindow, WaylandWindow}; /// Reserved timer ID for keyboard repeat. Uses a high value to avoid conflicts with app timers. const KEY_REPEAT_TIMER_ID: u64 = u64::MAX - 1; +/// Whether a pointer frame's scroll came from a wheel-like source: one that ratchets in +/// coarse steps, so its detents drive the delta, it carries no gesture phase, and it is +/// reported to widgets as mouse input. +/// +/// `source` is the frame's `wl_pointer::AxisSource`, or `None` when the compositor sent +/// none — the event is optional and only sent when the source is known. With no source, +/// the detents settle it: a device without discrete steps does not generate them, which +/// the spec spells out for `axis_discrete`. Guessing wheel-like is in any case the safe +/// guess, being the one classification that cannot strand a stretched rubber band waiting +/// for a terminator the spec does not promise. +fn scroll_is_wheel_like(source: Option, has_detents: bool) -> bool { + match source { + Some(wl_pointer::AxisSource::Wheel) | Some(wl_pointer::AxisSource::WheelTilt) => true, + // A trackpoint or button-held scroll is smooth, so it takes the raw pixel path even + // though it is not a gesture. + Some(wl_pointer::AxisSource::Finger) | Some(wl_pointer::AxisSource::Continuous) => false, + _ => has_detents, + } +} + +/// What one pointer frame's axis events add up to. +struct FrameScroll { + /// The delta in logical pixels. + delta: Vec2d, + phase: ScrollPhase, + /// Reported as `ScrollEvent::is_mouse`: a wheel that ratchets in steps, which widgets may + /// ease between. False for every smooth source, which needs no easing. + is_mouse: bool, +} + +/// Resolve a pointer frame's accumulated axis events into one scroll, or `None` when the +/// frame carries nothing worth dispatching. +/// +/// `source` is the frame's `wl_pointer::AxisSource` (`None` if the compositor sent none), +/// `gesture_active` whether the previous frame was a live touchpad gesture, and `stopped` +/// whether an `AxisStop` arrived in this frame. +fn frame_scroll( + source: Option, + gesture_active: bool, + stopped: bool, + acc: Vec2d, + detents: Vec2d, +) -> Option { + let has_detents = detents.x != 0.0 || detents.y != 0.0; + let has_delta = acc.x != 0.0 || acc.y != 0.0 || has_detents; + let is_wheel_like = scroll_is_wheel_like(source, has_detents); + // `axis_source` is per-frame and optional, so a compositor may name the source on a + // gesture's motion frames and omit it on the lift-off frame. Treating that frame as + // sourceless would drop the terminator and leave a stretched rubber band with nothing + // to release it, so a gesture already in flight carries its classification forward -- + // but never over a frame whose detents say it is a wheel. + let is_finger = match source { + Some(wl_pointer::AxisSource::Finger) => true, + None => gesture_active && !is_wheel_like, + _ => false, + }; + // A stop alongside live motion is not the end of the gesture. Per the `frame` event: + // "When a wl_pointer.axis and a wl_pointer.axis_stop event occur within the same frame, + // this indicates that axis movement in one axis has stopped but continues in the other + // axis." The lift-off frame that does end the gesture carries its stops alone. + let gesture_ended = is_finger && stopped && !has_delta; + if !has_delta && !gesture_ended { + // Only `Finger` is guaranteed an `AxisStop`; the spec tells clients to treat wheel, + // wheel_tilt and continuous sequences "as unterminated by default". A bare stop from + // one of those says nothing, and dispatching a zero-delta `ScrollPhase::None` for it + // would clear a widget's overscroll and cut short a running bounce. + return None; + } + // Scale wheel detents to a fixed distance each, so slow deliberate clicks and fast spins + // both move proportionally. Decided per axis: a frame can carry detents on one axis and + // only a smooth value on the other, and scaling that second axis by a zero detent count + // would silently drop it. + // + // An axis with no detents keeps its raw value. Compositors pair a detent event with every + // wheel-source axis event — `axis_discrete` is documented as absent only for continuous + // devices — and the seat binds above the v5 that introduced it, so a physical wheel + // always brings one. The fallback is for virtual pointers: `zwlr_virtual_pointer_v1` lets + // a client send a wheel-source axis value with no discrete step, and `wl_pointer.axis` + // defines that value as a "length of vector in surface-local coordinate space" — already + // a distance, with no detent count to recover and no units-per-detent constant that could + // recover one (compositors disagree, and hwdb ships wheels from 10 to 30 degrees a click). + let axis_scroll = |detent: f64, raw: f64| { + if detent != 0.0 { + detent * PIXELS_PER_WHEEL_DETENT + } else { + raw + } + }; + // Finger-driven (touchpad) scrolling reports `Changed` per frame and `Ended` when the + // fingers lift, which is what drives the rubber band at a scroll limit. Every other + // source is a plain delta with no gesture. + // + // Note this yields no kinetic scrolling for Wayland touchpads: widgets start their fling + // on `ScrollPhase::Momentum`, which only macOS emits — there the OS synthesizes that + // stream, while Wayland compositors do not and neither Linux backend fabricates one. + let phase = if !is_finger { + ScrollPhase::None + } else if gesture_ended { + ScrollPhase::Ended + } else { + ScrollPhase::Changed + }; + Some(FrameScroll { + delta: if is_wheel_like { + dvec2(axis_scroll(detents.x, acc.x), axis_scroll(detents.y, acc.y)) + } else { + acc + }, + phase, + is_mouse: is_wheel_like, + }) +} + +fn is_caption_double_click( + previous: Option<(WindowId, Vec2d, u32)>, + window_id: WindowId, + pos: Vec2d, + time: u32, +) -> bool { + previous.is_some_and(|(last_window_id, last_pos, last_time)| { + last_window_id == window_id + && time.wrapping_sub(last_time) <= (TAP_COUNT_TIME * 1000.0) as u32 + && (pos - last_pos).length() < TAP_COUNT_DISTANCE + }) +} + +#[derive(Clone, Copy, Debug)] +struct CaptionPress { + window_id: WindowId, + pos: Vec2d, + time: u32, + serial: u32, + drag_started: bool, +} + +impl CaptionPress { + fn start_drag_if_needed(&mut self, window_id: WindowId, pos: Vec2d) -> Option<(WindowId, u32)> { + if self.drag_started { + return None; + } + if self.window_id != window_id { + self.drag_started = true; + return None; + } + if (pos - self.pos).length() < TAP_COUNT_DISTANCE { + return None; + } + self.drag_started = true; + Some((self.window_id, self.serial)) + } + + fn completed_click(self, window_id: WindowId, pos: Vec2d) -> Option<(WindowId, Vec2d, u32)> { + (self.window_id == window_id + && !self.drag_started + && (pos - self.pos).length() < TAP_COUNT_DISTANCE) + .then_some((self.window_id, self.pos, self.time)) + } +} + +const RESIZE_EDGE_LEFT: u8 = 1 << 0; +const RESIZE_EDGE_RIGHT: u8 = 1 << 1; +const RESIZE_EDGE_TOP: u8 = 1 << 2; +const RESIZE_EDGE_BOTTOM: u8 = 1 << 3; + +fn xdg_toplevel_edge_mask(states: &[u8], first_state: u32) -> u8 { + [ + (first_state, RESIZE_EDGE_LEFT), + (first_state + 1, RESIZE_EDGE_RIGHT), + (first_state + 2, RESIZE_EDGE_TOP), + (first_state + 3, RESIZE_EDGE_BOTTOM), + ] + .into_iter() + .filter_map(|(state, edge)| WaylandState::xdg_toplevel_has_state(states, state).then_some(edge)) + .fold(0, |mask, edge| mask | edge) +} + +fn resize_edge_mask(edge: xdg_toplevel::ResizeEdge) -> u8 { + match edge { + xdg_toplevel::ResizeEdge::Top => RESIZE_EDGE_TOP, + xdg_toplevel::ResizeEdge::Bottom => RESIZE_EDGE_BOTTOM, + xdg_toplevel::ResizeEdge::Left => RESIZE_EDGE_LEFT, + xdg_toplevel::ResizeEdge::TopLeft => RESIZE_EDGE_TOP | RESIZE_EDGE_LEFT, + xdg_toplevel::ResizeEdge::BottomLeft => RESIZE_EDGE_BOTTOM | RESIZE_EDGE_LEFT, + xdg_toplevel::ResizeEdge::Right => RESIZE_EDGE_RIGHT, + xdg_toplevel::ResizeEdge::TopRight => RESIZE_EDGE_TOP | RESIZE_EDGE_RIGHT, + xdg_toplevel::ResizeEdge::BottomRight => RESIZE_EDGE_BOTTOM | RESIZE_EDGE_RIGHT, + _ => 0, + } +} + +fn resize_edge_from_mask(mask: u8) -> Option { + use xdg_toplevel::ResizeEdge; + Some( + match ( + mask & (RESIZE_EDGE_LEFT | RESIZE_EDGE_RIGHT), + mask & (RESIZE_EDGE_TOP | RESIZE_EDGE_BOTTOM), + ) { + (RESIZE_EDGE_LEFT, RESIZE_EDGE_TOP) => ResizeEdge::TopLeft, + (RESIZE_EDGE_LEFT, RESIZE_EDGE_BOTTOM) => ResizeEdge::BottomLeft, + (RESIZE_EDGE_RIGHT, RESIZE_EDGE_TOP) => ResizeEdge::TopRight, + (RESIZE_EDGE_RIGHT, RESIZE_EDGE_BOTTOM) => ResizeEdge::BottomRight, + (RESIZE_EDGE_LEFT, 0) => ResizeEdge::Left, + (RESIZE_EDGE_RIGHT, 0) => ResizeEdge::Right, + (0, RESIZE_EDGE_TOP) => ResizeEdge::Top, + (0, RESIZE_EDGE_BOTTOM) => ResizeEdge::Bottom, + _ => return None, + }, + ) +} + +/// Narrows `edge` to the components the compositor still allows. A tiled window shares +/// its inner borders with a neighbour and cannot resize them, but its outer ones stay +/// free; dropping the whole corner in that case would cost the user a grab they still +/// have, so a corner degrades to whichever of its two edges survives. +pub(crate) fn available_resize_edge( + edge: xdg_toplevel::ResizeEdge, + unavailable: u8, +) -> Option { + resize_edge_from_mask(resize_edge_mask(edge) & !unavailable) +} + +pub(crate) fn resize_edge_cursor( + edge: xdg_toplevel::ResizeEdge, +) -> wp_cursor_shape_device_v1::Shape { + use wp_cursor_shape_device_v1::Shape; + match edge { + xdg_toplevel::ResizeEdge::Top => Shape::NResize, + xdg_toplevel::ResizeEdge::Bottom => Shape::SResize, + xdg_toplevel::ResizeEdge::Left => Shape::WResize, + xdg_toplevel::ResizeEdge::Right => Shape::EResize, + xdg_toplevel::ResizeEdge::TopLeft => Shape::NwResize, + xdg_toplevel::ResizeEdge::TopRight => Shape::NeResize, + xdg_toplevel::ResizeEdge::BottomLeft => Shape::SwResize, + xdg_toplevel::ResizeEdge::BottomRight => Shape::SeResize, + _ => Shape::Default, + } +} + /// State for tracking keyboard key repeat. struct KeyRepeatState { key_code: KeyCode, @@ -82,6 +324,7 @@ struct PendingClipboardRead { pub(crate) struct WaylandState { pub(crate) compositor: Option, + pub(crate) subcompositor: Option, pub(crate) wm_base: Option, pub(crate) seat: Option, pub(crate) shm: Option, @@ -101,12 +344,19 @@ pub(crate) struct WaylandState { pub(crate) pointer: Option, pub(crate) last_mouse_pos: Vec2d, pub(crate) pointer_serial: Option, + pub(crate) pointer_enter_serial: Option, + pub(crate) requested_cursor: MouseCursor, pub(crate) keyboard_serial: Option, pub(crate) decoration_manager: Option, pub(crate) icon_manager: Option, pub(crate) windows: Vec, pub(crate) popups: Vec, pub(crate) pointer_window: Option, + /// Set while the pointer is over a window's shadow gutter rather than the window + /// itself, together with the edge a press there would resize. Kept apart from + /// [`Self::pointer_window`] because the gutter is outside the window: the app must + /// not see hover or clicks at coordinates that fall outside its own surface. + pub(crate) pointer_shadow: Option<(WindowId, xdg_toplevel::ResizeEdge)>, /// The latest un-dispatched pointer motion `(window_id, pos)`, coalesced across a whole /// `dispatch_pending` batch. A high-Hz mouse queues many `wl_pointer` motion+frame pairs between /// paints; dispatching each as a `MouseMove` runs a redundant hover hit-test across the whole @@ -137,6 +387,9 @@ pub(crate) struct WaylandState { Option, pub(crate) primary_selection_text: String, pub(crate) last_resize_edge: Option, + caption_press: Option, + last_caption_click: Option<(WindowId, Vec2d, u32)>, + consumed_pointer_buttons: MouseButton, event_callback: Option>, pub(crate) scroll_accumulator: Vec2d, @@ -144,12 +397,25 @@ pub(crate) struct WaylandState { /// (fractional detents on high-resolution wheels) or `AxisDiscrete` on pre-v8 /// compositors. Same sign convention as `scroll_accumulator`. pub(crate) scroll_detents: Vec2d, - pub(crate) scroll_is_wheel: bool, - /// Set when `wl_pointer::AxisStop` arrives in the current pointer frame: the fingers - /// lifted off the touchpad. The frame's Scroll event is then sent with - /// `ScrollPhase::Ended` (even if its delta is zero) so widgets can start their own - /// fling — Wayland compositors do not synthesize momentum scrolling for clients. + /// The `wl_pointer::AxisSource` reported for the current pointer frame, or `None` + /// when the compositor sent no `AxisSource` event — the event is optional ("If the + /// source is unknown for a particular axis event sequence, no event is sent") and a + /// source value newer than this protocol copy is likewise recorded as `None`. Scoped + /// to one frame, so it resets on every `Frame` and must never be assumed to carry + /// over. See [`scroll_is_wheel_like`]. + pub(crate) scroll_source: Option, + /// Set when `wl_pointer::AxisStop` arrives in the current pointer frame. It ends the + /// gesture — sending that frame's Scroll event with `ScrollPhase::Ended`, which springs + /// a stretched rubber band back and releases the widget's gesture ownership — only on a + /// finger frame that carries no motion of its own. A stop alongside live motion means + /// that one axis stopped while the other continues (see the `frame` event), not lift-off, + /// and a stop from a source that is not a gesture says nothing at all. See + /// [`frame_scroll`]. pub(crate) scroll_stopped: bool, + /// Whether the last dispatched pointer frame was a live touchpad gesture, so that a + /// lift-off frame on which the compositor omitted its `AxisSource` is still recognised + /// as the end of that gesture rather than as an unclassified scroll. + pub(crate) scroll_gesture_active: bool, /// Windows whose last presented frame's `wl_surface::frame` callback has not fired /// yet. While a window is listed here the compositor is not ready for a new frame /// on that surface, so presenting it is skipped (its pass stays dirty). See the @@ -170,6 +436,7 @@ impl WaylandState { pub fn new(event_callback: Box) -> Self { Self { compositor: None, + subcompositor: None, wm_base: None, seat: None, shm: None, @@ -193,9 +460,12 @@ impl WaylandState { windows: Vec::new(), popups: Vec::new(), pointer_window: None, + pointer_shadow: None, pending_motion: None, keyboard_window: None, pointer_serial: None, + pointer_enter_serial: None, + requested_cursor: MouseCursor::Default, keyboard_serial: None, modifiers: KeyModifiers::default(), xkb_state: None, @@ -211,11 +481,15 @@ impl WaylandState { primary_selection_text: String::new(), last_mouse_pos: dvec2(0., 0.), last_resize_edge: None, + caption_press: None, + last_caption_click: None, + consumed_pointer_buttons: MouseButton::empty(), timers: SelectTimers::new(), event_callback: Some(event_callback), scroll_accumulator: dvec2(0.0, 0.0), scroll_detents: dvec2(0.0, 0.0), - scroll_is_wheel: false, + scroll_source: None, + scroll_gesture_active: false, scroll_stopped: false, frame_callbacks_pending: Vec::new(), event_flow: EventFlow::Wait, @@ -255,6 +529,114 @@ impl WaylandState { .map(|win| win.xdg_surface.clone()) }) } + + fn clear_resize_edge(&mut self, force_cursor_update: bool) { + if self.last_resize_edge.take().is_some() || force_cursor_update { + if let (Some(cursor), Some(serial)) = + (self.cursor_shape.as_ref(), self.pointer_enter_serial) + { + cursor.set_shape(serial, self.requested_cursor.into()); + } + } + } + + fn update_resize_edge( + &mut self, + window_id: WindowId, + pos: Vec2d, + force_cursor_update: bool, + ) { + self.last_mouse_pos = pos; + let window_state = self + .windows + .iter() + .find(|window| window.window_id == window_id) + .filter(|window| { + window.uses_client_side_decorations + && !window.is_maximized + && !window.is_fullscreen + // The gutter already owns the grabs, and hit-testing here as well would + // charge every pointer motion near an edge for a whole-widget-tree + // `WindowDragQuery` dispatch that cannot change the answer. + && !window.csd_shadow_gutter_active() + }) + .map(|window| { + ( + window.window_geom.inner_size, + window.unavailable_resize_edges, + ) + }); + // The gutter outside the window is the primary way to resize, so these interior + // bands only need to cover the case where the shadow could not be created and + // there is no gutter to aim at. They stay narrow because every pixel they claim + // is a pixel the app's own widgets do not get. + let mut edge = window_state.and_then(|(size, unavailable)| { + let mut mask = 0; + if pos.x < 10.0 { + mask |= RESIZE_EDGE_LEFT; + } else if pos.x >= size.x - 10.0 { + mask |= RESIZE_EDGE_RIGHT; + } + if pos.y < 10.0 { + mask |= RESIZE_EDGE_TOP; + } else if pos.y >= size.y - 10.0 { + mask |= RESIZE_EDGE_BOTTOM; + } + // Away from a corner the band narrows to 5 px, so a single-axis hit outside + // that has to fall through to the app. + if mask.count_ones() == 1 + && pos.x >= 5.0 + && pos.x < size.x - 5.0 + && pos.y >= 5.0 + && pos.y < size.y - 5.0 + { + return None; + } + resize_edge_from_mask(mask & !unavailable) + }); + if edge.is_some() { + let response = Rc::new(Cell::new(WindowDragQueryResponse::NoAnswer)); + self.do_callback(XlibEvent::WindowDragQuery(WindowDragQueryEvent { + window_id, + abs: pos, + response: response.clone(), + })); + if matches!(response.get(), WindowDragQueryResponse::Client) { + edge = None; + } + } + if let Some(resize_edge) = edge { + self.last_resize_edge = Some(resize_edge); + if let (Some(cursor), Some(serial)) = + (self.cursor_shape.as_ref(), self.pointer_enter_serial) + { + cursor.set_shape(serial, resize_edge_cursor(resize_edge)); + } + } else { + self.clear_resize_edge(force_cursor_update); + } + } + + /// Handles the pointer entering one of a window's shadow surfaces: the pointer is in + /// the gutter, outside the window proper, where the only gesture is a resize. Returns + /// false when `surface` belongs to no shadow, leaving the caller's normal path intact. + fn enter_shadow_gutter(&mut self, surface: &wl_surface::WlSurface) -> bool { + let surface_id = surface.id(); + let Some((window_id, edge, shape)) = self.windows.iter().find_map(|window| { + window + .csd_shadow_resize_for_surface(&surface_id) + .map(|(edge, shape)| (window.window_id, edge, shape)) + }) else { + return false; + }; + self.pointer_shadow = Some((window_id, edge)); + if let (Some(cursor), Some(serial)) = + (self.cursor_shape.as_ref(), self.pointer_enter_serial) + { + cursor.set_shape(serial, shape); + } + true + } } impl Dispatch for WaylandState { @@ -278,9 +660,19 @@ impl Dispatch for WaylandState { wl_registry.bind::(name, 1, qhandle, ()); state.compositor = Some(compositor); } + "wl_subcompositor" => { + let subcompositor = wl_registry + .bind::(name, 1, qhandle, ()); + state.subcompositor = Some(subcompositor); + } "xdg_wm_base" => { let wm_base = - wl_registry.bind::(name, 1, qhandle, ()); + wl_registry.bind::( + name, + version.min(7), + qhandle, + (), + ); state.wm_base = Some(wm_base); } "wl_seat" => { @@ -453,7 +845,14 @@ impl Dispatch for WaylandState { height, states, } => { - if let Some(window) = state.windows.iter().find(|win| win.window_id == *window_id) { + let mut geom_change = None; + let mut disable_client_resize = false; + let mut refresh_client_resize = false; + if let Some(window) = state + .windows + .iter_mut() + .find(|win| win.window_id == *window_id) + { let inner_size = if width > 0 && height > 0 { dvec2(width as f64, height as f64) } else { @@ -463,13 +862,37 @@ impl Dispatch for WaylandState { WaylandState::xdg_toplevel_has_state(&states, 1 /* maximized */); let is_fullscreen = WaylandState::xdg_toplevel_has_state(&states, 2 /* fullscreen */); - state.do_callback(XlibEvent::WindowGeomChange(WindowGeomChangeEvent { + let is_active = + WaylandState::xdg_toplevel_has_state(&states, 4 /* activated */); + let tiled_edges = xdg_toplevel_edge_mask(&states, 5 /* tiled_left */); + let constrained_edges = + xdg_toplevel_edge_mask(&states, 10 /* constrained_left */); + let unavailable_resize_edges = tiled_edges | constrained_edges; + let resize_was_disabled = window.is_maximized || window.is_fullscreen; + let resize_edges_changed = + window.unavailable_resize_edges != unavailable_resize_edges; + window.is_maximized = is_maximized; + window.is_fullscreen = is_fullscreen; + window.is_tiled = tiled_edges != 0; + window.is_active = is_active; + window.unavailable_resize_edges = unavailable_resize_edges; + disable_client_resize = is_maximized || is_fullscreen; + // A size change moves the right and bottom bands out from under a + // stationary pointer. Without re-running the hit test the window keeps + // a resize cursor it no longer has an edge for, and the next click is + // swallowed starting a resize from nowhere. + refresh_client_resize = resize_edges_changed + || resize_was_disabled != disable_client_resize + || window.window_geom.inner_size != inner_size; + geom_change = Some(WindowGeomChangeEvent { window_id: *window_id, old_geom: window.window_geom.clone(), new_geom: WindowGeom { dpi_factor: window.window_geom.dpi_factor, can_fullscreen: false, xr_is_presenting: false, + // Preserve the established Makepad API: on Wayland this + // flag has always represented maximized or fullscreen. is_fullscreen: is_fullscreen || is_maximized, is_topmost: false, position: dvec2(0., 0.), @@ -477,7 +900,17 @@ impl Dispatch for WaylandState { outer_size: inner_size, ..Default::default() }, - })); + }); + } + if let Some(event) = geom_change { + state.do_callback(XlibEvent::WindowGeomChange(event)); + } + if state.pointer_window == Some(*window_id) { + if disable_client_resize { + state.clear_resize_edge(false); + } else if refresh_client_resize { + state.update_resize_edge(*window_id, state.last_mouse_pos, false); + } } } xdg_toplevel::Event::Close => { @@ -491,6 +924,38 @@ impl Dispatch for WaylandState { } } } + +impl Dispatch + for WaylandState +{ + fn event( + state: &mut Self, + _decoration: &zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1, + event: zxdg_toplevel_decoration_v1::Event, + window_id: &WindowId, + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + let zxdg_toplevel_decoration_v1::Event::Configure { mode } = event else { + return; + }; + let uses_client_side_decorations = match mode { + WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ServerSide) => false, + WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ClientSide) + | WEnum::Value(_) + | WEnum::Unknown(_) => true, + }; + if let Some(window) = state + .windows + .iter_mut() + .find(|window| window.window_id == *window_id) + { + // Decoration state is double-buffered with xdg_surface state. Keep + // only the latest mode and apply it at the matching surface configure. + window.pending_client_side_decorations = Some(uses_client_side_decorations); + } + } +} impl Dispatch for WaylandState { fn event( state: &mut Self, @@ -502,21 +967,59 @@ impl Dispatch for WaylandState { ) { if let xdg_surface::Event::Configure { serial, .. } = event { xdg_surface.ack_configure(serial); - let mut first_configure_event = None; + let mut configure_event = None; + let mut clear_resize_edge = false; + let mut update_resize_edge = false; + // Proxy clones are cheap handles and let us initialize CSD shadow + // resources while mutably borrowing the matching window. + let compositor = state.compositor.clone(); + let subcompositor = state.subcompositor.clone(); + let shm = state.shm.clone(); + let viewporter = state.viewporter.clone(); if let Some(window) = state .windows .iter_mut() .find(|win| win.window_id == *window_id) { + let decoration_changed = + if let Some(uses_csd) = window.pending_client_side_decorations.take() { + if uses_csd == window.uses_client_side_decorations { + false + } else { + if uses_csd { + if let Some(compositor) = compositor.as_ref() { + window.ensure_csd_shadow( + compositor, + subcompositor.as_ref(), + shm.as_ref(), + viewporter.as_ref(), + qhandle, + ); + } + } + clear_resize_edge = !uses_csd; + update_resize_edge = uses_csd; + window.uses_client_side_decorations = uses_csd; + true + } + } else { + false + }; if !window.configured { let mut old_geom = window.window_geom.clone(); old_geom.inner_size = dvec2(0., 0.); old_geom.outer_size = dvec2(0., 0.); - first_configure_event = Some(WindowGeomChangeEvent { + configure_event = Some(WindowGeomChangeEvent { window_id: *window_id, old_geom, new_geom: window.window_geom.clone(), }); + } else if decoration_changed { + configure_event = Some(WindowGeomChangeEvent { + window_id: *window_id, + old_geom: window.window_geom.clone(), + new_geom: window.window_geom.clone(), + }); } window.configured = true; } else if let Some(window) = state @@ -528,7 +1031,7 @@ impl Dispatch for WaylandState { let mut old_geom = window.window_geom.clone(); old_geom.inner_size = dvec2(0., 0.); old_geom.outer_size = dvec2(0., 0.); - first_configure_event = Some(WindowGeomChangeEvent { + configure_event = Some(WindowGeomChangeEvent { window_id: *window_id, old_geom, new_geom: window.window_geom.clone(), @@ -536,9 +1039,14 @@ impl Dispatch for WaylandState { } window.configured = true; } - if let Some(event) = first_configure_event { + if let Some(event) = configure_event { state.do_callback(XlibEvent::WindowGeomChange(event)); } + if clear_resize_edge && state.pointer_window == Some(*window_id) { + state.clear_resize_edge(false); + } else if update_resize_edge && state.pointer_window == Some(*window_id) { + state.update_resize_edge(*window_id, state.last_mouse_pos, false); + } } } } @@ -1125,12 +1633,26 @@ impl Dispatch for WaylandState { wl_pointer::Event::Enter { serial, surface, - surface_x: _, - surface_y: _, + surface_x, + surface_y, } => { state.pointer_serial = Some(serial); + state.pointer_enter_serial = Some(serial); state.flush_pending_clipboard_copy(qhandle, serial); + state.clear_resize_edge(true); + state.pointer_shadow = None; + state.pointer_window = None; + if state.enter_shadow_gutter(&surface) { + return; + } state.pointer_window = state.window_id_for_surface(&surface); + if let Some(window_id) = state.pointer_window { + let pos = dvec2(surface_x as f64, surface_y as f64); + state.last_mouse_pos = pos; + // Deliver the enter position through the normal coalesced motion path so + // stationary pointers establish hover state and the right app cursor. + state.pending_motion = Some((window_id, pos)); + } } wl_pointer::Event::Leave { serial, surface: _ } => { // Dispatch any buffered motion before the pointer leaves, so the final hover @@ -1139,93 +1661,41 @@ impl Dispatch for WaylandState { state.pointer_serial = Some(serial); state.flush_pending_clipboard_copy(qhandle, serial); state.pointer_window = None; + state.pointer_shadow = None; + state.scroll_gesture_active = false; + state.pointer_enter_serial = None; state.last_resize_edge = None; + state.caption_press = None; + state.last_caption_click = None; } wl_pointer::Event::Motion { - time, + time: _, surface_x, surface_y, } => { if let Some(window_id) = state.pointer_window { let pos = dvec2(surface_x as f64, surface_y as f64); state.last_mouse_pos = pos; - - // Edge-resize detection (matches X11 backend thresholds) - let window_size = state - .windows - .iter() - .find(|w| w.window_id == window_id) - .map(|w| w.window_geom.inner_size); - if let Some(ws) = window_size { - let edge = if pos.x < 10.0 && pos.y < 10.0 { - Some(( - xdg_toplevel::ResizeEdge::TopLeft, - wp_cursor_shape_device_v1::Shape::NwResize, - )) - } else if pos.x < 10.0 && pos.y >= ws.y - 10.0 { - Some(( - xdg_toplevel::ResizeEdge::BottomLeft, - wp_cursor_shape_device_v1::Shape::SwResize, - )) - } else if pos.x < 5.0 { - Some(( - xdg_toplevel::ResizeEdge::Left, - wp_cursor_shape_device_v1::Shape::WResize, - )) - } else if pos.x >= ws.x - 10.0 && pos.y < 10.0 { - Some(( - xdg_toplevel::ResizeEdge::TopRight, - wp_cursor_shape_device_v1::Shape::NeResize, - )) - } else if pos.x >= ws.x - 10.0 && pos.y >= ws.y - 10.0 { - Some(( - xdg_toplevel::ResizeEdge::BottomRight, - wp_cursor_shape_device_v1::Shape::SeResize, - )) - } else if pos.x >= ws.x - 5.0 { - Some(( - xdg_toplevel::ResizeEdge::Right, - wp_cursor_shape_device_v1::Shape::EResize, - )) - } else if pos.y < 5.0 { - Some(( - xdg_toplevel::ResizeEdge::Top, - wp_cursor_shape_device_v1::Shape::NResize, - )) - } else if pos.y >= ws.y - 5.0 { - Some(( - xdg_toplevel::ResizeEdge::Bottom, - wp_cursor_shape_device_v1::Shape::SResize, - )) - } else { - None - }; - if let Some((resize_edge, cursor_shape)) = edge { - state.last_resize_edge = Some(resize_edge); - if let (Some(cursor_dev), Some(serial)) = - (state.cursor_shape.as_ref(), state.pointer_serial) - { - cursor_dev.set_shape(serial, cursor_shape); - } - } else { - if state.last_resize_edge.is_some() { - if let (Some(cursor_dev), Some(serial)) = - (state.cursor_shape.as_ref(), state.pointer_serial) - { - cursor_dev.set_shape( - serial, - wp_cursor_shape_device_v1::Shape::Default, - ); - } - } - state.last_resize_edge = None; + let drag_request = state + .caption_press + .as_mut() + .and_then(|press| press.start_drag_if_needed(window_id, pos)); + if let Some((press_window, press_serial)) = drag_request { + state.last_caption_click = None; + if let (Some(seat), Some(window)) = ( + state.seat.as_ref(), + state + .windows + .iter() + .find(|window| window.window_id == press_window), + ) { + window.toplevel._move(seat, press_serial); } } // Buffer this motion instead of dispatching immediately; the latest one is // flushed as a single MouseMove once the whole event batch is drained (or before - // an intervening button/leave). The edge-resize cursor above still updates per - // motion so the resize cursor stays responsive. See `flush_pending_motion`. + // an intervening button/leave). See `flush_pending_motion`. state.pending_motion = Some((window_id, pos)); } } @@ -1243,7 +1713,12 @@ impl Dispatch for WaylandState { // Outside-click popup dismissal: if press lands on a // regular window while popups are open, fire dismiss. if let WEnum::Value(ButtonState::Pressed) = key_state { - if let Some(win_id) = state.pointer_window { + // A press in the shadow gutter is as much "outside" as one on the + // window, so it dismisses popups too. + if let Some(win_id) = state.pointer_window.or(state + .pointer_shadow + .map(|(window_id, _)| window_id)) + { if state.windows.iter().any(|w| w.window_id == win_id) && !state.popups.is_empty() { @@ -1258,26 +1733,47 @@ impl Dispatch for WaylandState { } } } + // In the gutter the only gesture is a resize, and the app is not told about + // it: these coordinates are outside its surface, and the compositor takes + // the pointer grab for the duration of the drag. + if let Some((window_id, resize_edge)) = state.pointer_shadow { + if let (WEnum::Value(ButtonState::Pressed), Some(MouseButton::PRIMARY)) = + (key_state, wayland_type::from_mouse(button)) + { + if let (Some(seat), Some(window)) = ( + state.seat.as_ref(), + state.windows.iter().find(|win| win.window_id == window_id), + ) { + window.toplevel.resize(seat, serial, resize_edge); + } + } + return; + } if let Some(btn) = wayland_type::from_mouse(button) { if let Some(window_id) = state.pointer_window { match key_state { WEnum::Value(ButtonState::Pressed) => { - if btn == MouseButton::PRIMARY { - if state.windows.iter().any(|win| win.window_id == window_id) { - // Edge resize takes priority - if let Some(resize_edge) = state.last_resize_edge.take() { - if let (Some(seat), Some(window)) = ( - state.seat.as_ref(), - state - .windows - .iter() - .find(|win| win.window_id == window_id), - ) { - window.toplevel.resize(seat, serial, resize_edge); - return; - } - } - + // A surface can disappear before delivering a release. Do not let + // that stale bit consume the next independent press/release pair. + state.consumed_pointer_buttons.remove(btn); + let previous_caption_click = if btn == MouseButton::PRIMARY { + state.last_caption_click.take() + } else { + state.last_caption_click = None; + None + }; + state.caption_press = None; + if btn == MouseButton::PRIMARY + || btn == MouseButton::SECONDARY + { + let uses_client_side_decorations = state + .windows + .iter() + .find(|win| win.window_id == window_id) + .is_some_and(|win| { + win.uses_client_side_decorations && !win.is_fullscreen + }); + if uses_client_side_decorations { let response = Rc::new(Cell::new(WindowDragQueryResponse::NoAnswer)); state.do_callback(XlibEvent::WindowDragQuery( @@ -1287,10 +1783,41 @@ impl Dispatch for WaylandState { response: response.clone(), }, )); + let response = response.get(); + // The top resize zone overlaps the caption vertically, but + // caption buttons must keep their full-height click target. + if btn == MouseButton::PRIMARY + && !matches!( + response, + WindowDragQueryResponse::Client + ) + { + if let Some(resize_edge) = state.last_resize_edge { + if let (Some(seat), Some(window)) = ( + state.seat.as_ref(), + state + .windows + .iter() + .find(|win| win.window_id == window_id), + ) { + window + .toplevel + .resize(seat, serial, resize_edge); + state.consumed_pointer_buttons.insert(btn); + return; + } + } + } if matches!( - response.get(), + response, WindowDragQueryResponse::Caption ) { + let is_double_click = is_caption_double_click( + previous_caption_click, + window_id, + state.last_mouse_pos, + time, + ); if let (Some(seat), Some(window)) = ( state.seat.as_ref(), state @@ -1298,7 +1825,33 @@ impl Dispatch for WaylandState { .iter() .find(|win| win.window_id == window_id), ) { - window.toplevel._move(seat, serial); + if btn == MouseButton::SECONDARY { + window.toplevel.show_window_menu( + seat, + serial, + state.last_mouse_pos.x as i32, + state.last_mouse_pos.y as i32, + ); + state.consumed_pointer_buttons.insert(btn); + return; + } + if is_double_click { + if window.is_maximized { + window.toplevel.unset_maximized(); + } else { + window.toplevel.set_maximized(); + } + state.consumed_pointer_buttons.insert(btn); + return; + } + state.caption_press = Some(CaptionPress { + window_id, + pos: state.last_mouse_pos, + time, + serial, + drag_started: false, + }); + state.consumed_pointer_buttons.insert(btn); return; } } @@ -1314,6 +1867,20 @@ impl Dispatch for WaylandState { })) } WEnum::Value(ButtonState::Released) => { + let consumed = state.consumed_pointer_buttons.contains(btn); + if consumed { + state.consumed_pointer_buttons.remove(btn); + } + if btn == MouseButton::PRIMARY { + if let Some(press) = state.caption_press.take() { + state.last_caption_click = + press.completed_click(window_id, state.last_mouse_pos); + return; + } + } + if consumed { + return; + } state.do_callback(XlibEvent::MouseUp(MouseUpEvent { abs: state.last_mouse_pos, button: btn, @@ -1341,118 +1908,116 @@ impl Dispatch for WaylandState { } } } - // Wayland axis values use motion-event coordinates: positive - // vertical = downward on screen = content slides down = viewport - // moves UP. Makepad's internal convention is positive = viewport - // moves DOWN (matching X11 button mapping and macOS after its - // negation of scrollingDeltaY). Negate to align conventions, - // same as winit does for the same reason. + // Wayland axis values already match Makepad's convention: positive vertical = + // scroll down = viewport moves DOWN. The spec pins the sign in + // wl_pointer::axis_relative_direction, whose `identical` case is fingers moving + // down producing a "vertical_scroll down" axis event; libinput documents the + // same ("the positive direction being down or right"). So pass the values + // through untouched — the compositor has already applied the user's + // natural-scrolling preference to the sign, and negating here would invert both + // settings. Toolkits that do negate (winit, SDL, Chromium) only do so because + // their own convention is inverted; GTK, which shares Makepad's, does not. wl_pointer::Event::Axis { time: _, axis, value, } => match axis { WEnum::Value(wl_pointer::Axis::VerticalScroll) => { - state.scroll_accumulator.y -= value; + state.scroll_accumulator.y += value; } WEnum::Value(wl_pointer::Axis::HorizontalScroll) => { - state.scroll_accumulator.x -= value; + state.scroll_accumulator.x += value; } _ => {} }, wl_pointer::Event::AxisSource { axis_source } => { - state.scroll_is_wheel = axis_source == WEnum::Value(wl_pointer::AxisSource::Wheel); + // A source this protocol copy predates (`AxisSource` is `#[non_exhaustive]`) + // is as good as no source: record `None` rather than letting it fall through + // to the finger branch, which is the one classification that can strand a + // stretched rubber band. + state.scroll_source = match axis_source { + WEnum::Value(source) => Some(source), + WEnum::Unknown(_) => None, + }; } wl_pointer::Event::Frame => { - let acc = state.scroll_accumulator; - let detents = state.scroll_detents; - // Dispatch when there is a scroll delta, or when the touchpad gesture just - // ended (AxisStop): the `Ended` event may carry a zero delta but is what lets - // widgets start their fling animation at finger lift-off. - if acc.x != 0.0 - || acc.y != 0.0 - || detents.x != 0.0 - || detents.y != 0.0 - || state.scroll_stopped - { - if let Some(window_id) = state.pointer_window { - // Deliver any buffered motion first so the Scroll event's hover - // position is current (Button and Leave already do this). - state.flush_pending_motion(); - let time_now = state.time_now(); - let scroll = if state.scroll_is_wheel { - if detents.x != 0.0 || detents.y != 0.0 { - // Scale wheel detents to a fixed distance each so slow, - // deliberate clicks and fast spins both move proportionally. - dvec2( - detents.x * PIXELS_PER_WHEEL_DETENT, - detents.y * PIXELS_PER_WHEEL_DETENT, - ) - } else { - // Some compositors send wheel frames without discrete or - // value120 information; the accumulated axis value is - // already a real distance in pixels. - acc - } - } else { - acc - }; - // Wheels have no gesture phases. Finger-driven (touchpad) scrolling - // reports `Changed` per frame and `Ended` when the fingers lift - // (AxisStop), letting widgets run their own momentum fling — - // Wayland compositors do not synthesize momentum for clients. - let phase = if state.scroll_is_wheel { - ScrollPhase::None - } else if state.scroll_stopped { - ScrollPhase::Ended - } else { - ScrollPhase::Changed - }; - state.do_callback(XlibEvent::Scroll(ScrollEvent { - window_id, - scroll, - abs: state.last_mouse_pos, - modifiers: state.modifiers, - is_mouse: state.scroll_is_wheel, - handled_x: Cell::new(false), - handled_y: Cell::new(false), - time: time_now, - phase, - })); - } + let frame = frame_scroll( + state.scroll_source, + state.scroll_gesture_active, + state.scroll_stopped, + state.scroll_accumulator, + state.scroll_detents, + ); + if let Some(frame) = &frame { + // Tracked whether or not a window is under the pointer, so a gesture that + // starts over one window and lifts over another still terminates. + state.scroll_gesture_active = frame.phase == ScrollPhase::Changed; + } + if let (Some(frame), Some(window_id)) = (frame, state.pointer_window) { + // Deliver any buffered motion first so the Scroll event's hover + // position is current (Button and Leave already do this). + state.flush_pending_motion(); + let time_now = state.time_now(); + state.do_callback(XlibEvent::Scroll(ScrollEvent { + window_id, + scroll: frame.delta, + abs: state.last_mouse_pos, + modifiers: state.modifiers, + is_mouse: frame.is_mouse, + handled_x: Cell::new(false), + handled_y: Cell::new(false), + time: time_now, + phase: frame.phase, + })); } state.scroll_accumulator = dvec2(0.0, 0.0); state.scroll_detents = dvec2(0.0, 0.0); - state.scroll_is_wheel = false; + state.scroll_source = None; state.scroll_stopped = false; } - wl_pointer::Event::AxisStop { time: _, axis: _ } => { - // Fingers lifted off the touchpad: mark the gesture ended so this pointer - // frame's Scroll event goes out with `ScrollPhase::Ended`. - state.scroll_stopped = true; + wl_pointer::Event::AxisStop { time: _, axis } => { + // An axis stopped. One flag for the whole frame rather than one per axis: + // `ScrollEvent` carries a single phase for both axes, so a per-axis mask + // could not be expressed anyway. `frame_scroll` separates the two cases the + // protocol defines — "this axis stopped, the other continues" from a real + // lift-off — by whether the frame also carries motion. + if matches!( + axis, + WEnum::Value(wl_pointer::Axis::VerticalScroll) + | WEnum::Value(wl_pointer::Axis::HorizontalScroll) + ) { + state.scroll_stopped = true; + } } - // Wheel detent counts, negated to match the Axis sign convention above. + // Wheel detent counts, carrying the same sign convention as the Axis event + // above: the spec states each expresses its direction in terms of the positive + // or negative direction of the same axis, never inverted relative to it. // AxisDiscrete is only sent by compositors below seat v8; v8+ compositors // send AxisValue120 instead (120 units per detent, fractional detents // allowed for high-resolution wheels), so the two never double-count. wl_pointer::Event::AxisDiscrete { axis, discrete } => match axis { WEnum::Value(wl_pointer::Axis::VerticalScroll) => { - state.scroll_detents.y -= discrete as f64; + state.scroll_detents.y += discrete as f64; } WEnum::Value(wl_pointer::Axis::HorizontalScroll) => { - state.scroll_detents.x -= discrete as f64; + state.scroll_detents.x += discrete as f64; } _ => {} }, wl_pointer::Event::AxisValue120 { axis, value120 } => match axis { WEnum::Value(wl_pointer::Axis::VerticalScroll) => { - state.scroll_detents.y -= value120 as f64 / 120.0; + state.scroll_detents.y += value120 as f64 / 120.0; } WEnum::Value(wl_pointer::Axis::HorizontalScroll) => { - state.scroll_detents.x -= value120 as f64 / 120.0; + state.scroll_detents.x += value120 as f64 / 120.0; } _ => {} }, + // Purely informational: the physical direction of the entity that caused the + // axis event. The axis value itself already reflects the user's natural-scrolling + // setting, so scrolling content must ignore this. It exists for widgets that + // should follow the physical wheel regardless of that setting — the spec's + // example is a volume slider — which Makepad has no plumbing for, so drop it. wl_pointer::Event::AxisRelativeDirection { axis: _, direction: _, @@ -1502,8 +2067,10 @@ delegate_noop!(WaylandState: ignore wl_surface::WlSurface); delegate_noop!(WaylandState: ignore wp_cursor_shape_device_v1::WpCursorShapeDeviceV1); delegate_noop!(WaylandState: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1); delegate_noop!(WaylandState: ignore wl_compositor::WlCompositor); +delegate_noop!(WaylandState: ignore wl_region::WlRegion); +delegate_noop!(WaylandState: ignore wl_subcompositor::WlSubcompositor); +delegate_noop!(WaylandState: ignore wl_subsurface::WlSubsurface); delegate_noop!(WaylandState: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1); -delegate_noop!(WaylandState: ignore zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1); delegate_noop!(WaylandState: ignore xdg_toplevel_icon_v1::XdgToplevelIconV1); delegate_noop!(WaylandState: ignore wl_shm::WlShm); delegate_noop!(WaylandState: ignore wl_shm_pool::WlShmPool); @@ -1766,6 +2333,7 @@ impl WaylandState { { return; } + self.update_resize_edge(window_id, pos, false); self.do_callback(XlibEvent::MouseMove(MouseMoveEvent { lock_delta: Default::default(), abs: pos, @@ -1861,3 +2429,307 @@ impl WaylandState { self.timers.time_now() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wheel_like_sources_take_the_detent_path() { + // Wheels and wheel tilts ratchet, whether or not this frame carried detents. + for source in [ + wl_pointer::AxisSource::Wheel, + wl_pointer::AxisSource::WheelTilt, + ] { + assert!(scroll_is_wheel_like(Some(source), true)); + assert!(scroll_is_wheel_like(Some(source), false)); + } + // A touchpad gesture and a trackpoint / button-held scroll are both smooth. + for source in [ + wl_pointer::AxisSource::Finger, + wl_pointer::AxisSource::Continuous, + ] { + assert!(!scroll_is_wheel_like(Some(source), false)); + assert!(!scroll_is_wheel_like(Some(source), true)); + } + } + + #[test] + fn a_frame_without_an_axis_source_is_classified_by_its_detents() { + // `axis_source` is optional, and an unknown value is recorded as `None`. Detents + // then decide, and the sourceless default must not be the finger path. + assert!(scroll_is_wheel_like(None, true)); + assert!(!scroll_is_wheel_like(None, false)); + } + + /// A frame carrying no stop, from a source with no gesture in flight. + fn plain_frame( + source: Option, + acc: Vec2d, + detents: Vec2d, + ) -> Option { + frame_scroll(source, false, false, acc, detents) + } + + #[test] + fn each_axis_chooses_detents_or_raw_pixels_on_its_own() { + // A wheel frame with a detented vertical axis and a smooth horizontal one: scaling + // the horizontal by its zero detent count would drop it entirely. + let frame = plain_frame( + Some(wl_pointer::AxisSource::Wheel), + dvec2(7.5, 15.0), + dvec2(0.0, 1.0), + ) + .expect("a frame with a delta dispatches"); + assert_eq!(frame.delta, dvec2(7.5, PIXELS_PER_WHEEL_DETENT)); + assert!(frame.is_mouse); + assert_eq!(frame.phase, ScrollPhase::None); + } + + #[test] + fn a_wheel_frame_without_detents_keeps_its_raw_distance_unscaled() { + let frame = plain_frame( + Some(wl_pointer::AxisSource::Wheel), + dvec2(0.0, 15.0), + dvec2(0.0, 0.0), + ) + .expect("a frame with a delta dispatches"); + assert_eq!(frame.delta, dvec2(0.0, 15.0)); + } + + #[test] + fn a_sourceless_frame_with_detents_takes_the_wheel_path() { + let frame = plain_frame(None, dvec2(0.0, 15.0), dvec2(0.0, 1.0)) + .expect("a frame with a delta dispatches"); + assert_eq!(frame.delta, dvec2(0.0, PIXELS_PER_WHEEL_DETENT)); + assert!(frame.is_mouse); + assert_eq!(frame.phase, ScrollPhase::None); + } + + #[test] + fn a_touchpad_gesture_reports_changed_then_ended_at_lift_off() { + let moving = plain_frame( + Some(wl_pointer::AxisSource::Finger), + dvec2(0.0, 12.0), + dvec2(0.0, 0.0), + ) + .expect("a frame with a delta dispatches"); + assert_eq!(moving.phase, ScrollPhase::Changed); + assert_eq!(moving.delta, dvec2(0.0, 12.0)); + assert!(!moving.is_mouse); + + // Lift-off: the stops arrive alone, and the zero-delta event is what springs a + // stretched rubber band back. + let lifted = frame_scroll( + Some(wl_pointer::AxisSource::Finger), + true, + true, + dvec2(0.0, 0.0), + dvec2(0.0, 0.0), + ) + .expect("a bare stop ends the gesture"); + assert_eq!(lifted.phase, ScrollPhase::Ended); + assert_eq!(lifted.delta, dvec2(0.0, 0.0)); + } + + #[test] + fn a_stop_alongside_live_motion_is_one_axis_stopping_not_lift_off() { + // The `frame` event defines axis + axis_stop in one frame as "movement in one axis + // has stopped but continues in the other axis". + let frame = frame_scroll( + Some(wl_pointer::AxisSource::Finger), + true, + true, + dvec2(0.0, 12.0), + dvec2(0.0, 0.0), + ) + .expect("a frame with a delta dispatches"); + assert_eq!(frame.phase, ScrollPhase::Changed); + } + + #[test] + fn a_gesture_in_flight_still_ends_when_the_compositor_drops_the_axis_source() { + // `axis_source` is per-frame and optional, so the lift-off frame may carry none. + // Losing the terminator would strand a stretched rubber band. + let frame = frame_scroll(None, true, true, dvec2(0.0, 0.0), dvec2(0.0, 0.0)) + .expect("the in-flight gesture recognises its own lift-off"); + assert_eq!(frame.phase, ScrollPhase::Ended); + + // With no gesture in flight the same frame says nothing and must not dispatch. + assert!(frame_scroll(None, false, true, dvec2(0.0, 0.0), dvec2(0.0, 0.0)).is_none()); + } + + #[test] + fn a_bare_stop_from_a_source_with_no_gesture_dispatches_nothing() { + // Only `Finger` is guaranteed an AxisStop. A zero-delta `ScrollPhase::None` from one + // of the others would clear a widget's overscroll and cut short a running bounce. + for source in [ + wl_pointer::AxisSource::Wheel, + wl_pointer::AxisSource::WheelTilt, + wl_pointer::AxisSource::Continuous, + ] { + assert!( + frame_scroll(Some(source), true, true, dvec2(0.0, 0.0), dvec2(0.0, 0.0)) + .is_none(), + "{source:?} has no gesture to end" + ); + } + } + + #[test] + fn a_trackpoint_scroll_is_a_plain_delta_that_skips_wheel_easing() { + let frame = plain_frame( + Some(wl_pointer::AxisSource::Continuous), + dvec2(0.0, 9.0), + dvec2(0.0, 0.0), + ) + .expect("a frame with a delta dispatches"); + assert_eq!(frame.phase, ScrollPhase::None); + assert_eq!(frame.delta, dvec2(0.0, 9.0)); + assert!(!frame.is_mouse); + } + + #[test] + fn an_empty_frame_dispatches_nothing() { + assert!(plain_frame(None, dvec2(0.0, 0.0), dvec2(0.0, 0.0)).is_none()); + } + + fn encoded_states(states: &[u32]) -> Vec { + states + .iter() + .flat_map(|state| state.to_ne_bytes()) + .collect() + } + + #[test] + fn caption_double_click_requires_matching_window_time_and_position() { + let window = WindowId(2, 1); + let previous = Some((window, dvec2(10.0, 10.0), u32::MAX - 100)); + assert!(is_caption_double_click( + previous, + window, + dvec2(12.0, 11.0), + 50 + )); + assert!(!is_caption_double_click( + previous, + WindowId(3, 1), + dvec2(12.0, 11.0), + 50 + )); + assert!(!is_caption_double_click( + previous, + window, + dvec2(20.0, 10.0), + 50 + )); + assert!(!is_caption_double_click( + previous, + window, + dvec2(12.0, 11.0), + 600 + )); + } + + #[test] + fn caption_press_waits_for_drag_threshold_before_requesting_move() { + let window = WindowId(2, 1); + let mut press = CaptionPress { + window_id: window, + pos: dvec2(10.0, 10.0), + time: 100, + serial: 77, + drag_started: false, + }; + + assert_eq!( + press.start_drag_if_needed(window, dvec2(13.0, 13.0)), + None + ); + assert!(!press.drag_started); + assert_eq!( + press.completed_click(window, dvec2(13.0, 13.0)), + Some((window, dvec2(10.0, 10.0), 100)) + ); + } + + #[test] + fn caption_drag_requests_move_once_and_cannot_complete_as_click() { + let window = WindowId(2, 1); + let mut press = CaptionPress { + window_id: window, + pos: dvec2(10.0, 10.0), + time: 100, + serial: 77, + drag_started: false, + }; + + assert_eq!( + press.start_drag_if_needed(window, dvec2(15.0, 10.0)), + Some((window, 77)) + ); + assert!(press.drag_started); + assert_eq!( + press.start_drag_if_needed(window, dvec2(20.0, 10.0)), + None + ); + assert_eq!(press.completed_click(window, dvec2(10.0, 10.0)), None); + } + + #[test] + fn tiled_and_constrained_states_disable_only_their_resize_edges() { + let states = encoded_states(&[5, 7, 11, 13]); + let tiled = xdg_toplevel_edge_mask(&states, 5); + let constrained = xdg_toplevel_edge_mask(&states, 10); + assert_eq!(tiled, RESIZE_EDGE_LEFT | RESIZE_EDGE_TOP); + assert_eq!(constrained, RESIZE_EDGE_RIGHT | RESIZE_EDGE_BOTTOM); + // A corner whose edges are both free is kept whole. + assert_eq!( + available_resize_edge(xdg_toplevel::ResizeEdge::BottomRight, tiled), + Some(xdg_toplevel::ResizeEdge::BottomRight) + ); + // A corner with one tiled edge degrades to the edge that is still free, + // rather than losing the grab entirely. + assert_eq!( + available_resize_edge(xdg_toplevel::ResizeEdge::TopLeft, constrained), + Some(xdg_toplevel::ResizeEdge::TopLeft) + ); + assert_eq!( + available_resize_edge(xdg_toplevel::ResizeEdge::TopRight, tiled), + Some(xdg_toplevel::ResizeEdge::Right) + ); + assert_eq!( + available_resize_edge(xdg_toplevel::ResizeEdge::BottomLeft, constrained), + Some(xdg_toplevel::ResizeEdge::Left) + ); + // Both components gone means no grab at all. + assert_eq!( + available_resize_edge(xdg_toplevel::ResizeEdge::TopLeft, tiled), + None + ); + assert_eq!( + available_resize_edge(xdg_toplevel::ResizeEdge::Top, tiled), + None + ); + } + + #[test] + fn resize_edge_cursor_matches_every_edge() { + use wp_cursor_shape_device_v1::Shape; + use xdg_toplevel::ResizeEdge; + for (edge, shape) in [ + (ResizeEdge::Top, Shape::NResize), + (ResizeEdge::Bottom, Shape::SResize), + (ResizeEdge::Left, Shape::WResize), + (ResizeEdge::Right, Shape::EResize), + (ResizeEdge::TopLeft, Shape::NwResize), + (ResizeEdge::TopRight, Shape::NeResize), + (ResizeEdge::BottomLeft, Shape::SwResize), + (ResizeEdge::BottomRight, Shape::SeResize), + ] { + assert_eq!(resize_edge_cursor(edge), shape); + // Every edge must survive a round trip through the mask it degrades with. + assert_eq!(available_resize_edge(edge, 0), Some(edge)); + } + } +} diff --git a/platform/src/script/draw.rs b/platform/src/script/draw.rs index 054fdf74f..5a0f264ff 100644 --- a/platform/src/script/draw.rs +++ b/platform/src/script/draw.rs @@ -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); diff --git a/platform/src/window.rs b/platform/src/window.rs index 922e98792..c5391a44c 100644 --- a/platform/src/window.rs +++ b/platform/src/window.rs @@ -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] diff --git a/tools/cargo_makepad/src/apple/compile.rs b/tools/cargo_makepad/src/apple/compile.rs index cb9f3ad7e..a1413a60b 100644 --- a/tools/cargo_makepad/src/apple/compile.rs +++ b/tools/cargo_makepad/src/apple/compile.rs @@ -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 { 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::() + 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::() + 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) { diff --git a/tools/cargo_makepad/src/apple/info_plist.rs b/tools/cargo_makepad/src/apple/info_plist.rs new file mode 100644 index 000000000..fa11a398b --- /dev/null +++ b/tools/cargo_makepad/src/apple/info_plist.rs @@ -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, +} + +#[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, 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 { + self.merge_inner(generated) + .map_err(|e| format!("Cannot merge custom Info.plist {}: {e}", self.path.display())) + } + + fn merge_inner(&self, generated: &str) -> Result { + 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 { + let xml = run(Command::new("/usr/bin/plutil") + .args(["-convert", "xml1", "-o", "-", "--"]) + .arg(path))?; + let root = xml.split_once("')) + .map(|(_, rest)| rest.trim_start()) + .ok_or_else(|| "plutil did not return a plist document".to_string())?; + if !root.starts_with("") && !root.starts_with("") { + return Err("Info.plist must contain a dictionary".to_string()); + } + Ok(xml) +} + +fn run(command: &mut Command) -> Result { + 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 { + // 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); + } +} diff --git a/tools/cargo_makepad/src/apple/info_plist/tests.rs b/tools/cargo_makepad/src/apple/info_plist/tests.rs new file mode 100644 index 000000000..8c4118f76 --- /dev/null +++ b/tools/cargo_makepad/src/apple/info_plist/tests.rs @@ -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, 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!("\n{root}\n") +} + +fn one_entry(key: &str, value_xml: &str) -> String { + plist_xml(&format!("{key}{value_xml}")) +} + +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", "Custom App"), 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!("{label}")), 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#" + NSMicrophoneUsageDescriptionRecord the user's "voice" & review <text>. + NSSpeechRecognitionUsageDescriptionTranscribe a message. + LSEnvironmentAPP_SETTINGcustom + AppConfiguration + Enabled + SignedCount-42 + UnsignedCount18446744073709551615 + PayloadAAF/gP8= + Created2026-09-09T12:34:56Z + Itemsone & <two>1.5 + odd: key & <tag>unusual key + + "#); + 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 ."), + ("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 & "), + ("AppConfiguration.odd: key & ", "string", "unusual key"), + ] { + assert_eq!(raw(&merged, key, value_type), expected, "{key}, binary={binary}"); + } + assert_eq!(raw(&merged, "AppConfiguration.Items.1", "float").parse::().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"Broken").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("not a dictionary"), 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 ["different", ""] { + 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("CFBundleIdentifierorg.example.plist-test\ + CFBundleExecutableplist-test\ + CFBundleDisplayNameCustom App"); + 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"); +} diff --git a/tools/cargo_makepad/src/apple/mod.rs b/tools/cargo_makepad/src/apple/mod.rs index b5cfd6bf4..056841893 100644 --- a/tools/cargo_makepad/src/apple/mod.rs +++ b/tools/cargo_makepad/src/apple/mod.rs @@ -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::*; diff --git a/tools/cargo_makepad/src/main.rs b/tools/cargo_makepad/src/main.rs index 7852e8b52..5d691bcf6 100644 --- a/tools/cargo_makepad/src/main.rs +++ b/tools/cargo_makepad/src/main.rs @@ -143,6 +143,12 @@ fn show_help() { " --device= 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!( diff --git a/widgets/derive_widget/src/lib.rs b/widgets/derive_widget/src/lib.rs index c159086df..07880d5e7 100644 --- a/widgets/derive_widget/src/lib.rs +++ b/widgets/derive_widget/src/lib.rs @@ -23,6 +23,7 @@ pub fn derive_widget(input: TokenStream) -> TokenStream { area, event, visible, + imperative, action_data, uid, cast, diff --git a/widgets/src/animator.rs b/widgets/src/animator.rs index a3a8a5fc2..d78de3e64 100644 --- a/widgets/src/animator.rs +++ b/widgets/src/animator.rs @@ -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 { - 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, ); } diff --git a/widgets/src/desktop_button.rs b/widgets/src/desktop_button.rs index fa0c11f8f..abf16c0a2 100644 --- a/widgets/src/desktop_button.rs +++ b/widgets/src/desktop_button.rs @@ -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 } diff --git a/widgets/src/drop_down.rs b/widgets/src/drop_down.rs index e3959e2d3..87e3ec83f 100644 --- a/widgets/src/drop_down.rs +++ b/widgets/src/drop_down.rs @@ -420,6 +420,7 @@ pub struct DropDown { #[rust] popup_global: PopupMenuGlobal, + #[imperative] #[live] selected_item: usize, diff --git a/widgets/src/lib.rs b/widgets/src/lib.rs index eff4334b6..26f637afa 100644 --- a/widgets/src/lib.rs +++ b/widgets/src/lib.rs @@ -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, diff --git a/widgets/src/page_flip.rs b/widgets/src/page_flip.rs index b61f95ea2..6f898761b 100644 --- a/widgets/src/page_flip.rs +++ b/widgets/src/page_flip.rs @@ -22,6 +22,7 @@ pub struct PageFlip { layout: Layout, #[live(false)] lazy_init: bool, + #[imperative] #[live] active_page: LiveId, #[rust] diff --git a/widgets/src/splash.rs b/widgets/src/splash.rs index ea4d376a3..b7230f97b 100644 --- a/widgets/src/splash.rs +++ b/widgets/src/splash.rs @@ -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 { 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, }) }) diff --git a/widgets/src/view.rs b/widgets/src/view.rs index e9f4c5ee4..c3e65d8cc 100644 --- a/widgets/src/view.rs +++ b/widgets/src/view.rs @@ -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>, @@ -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 { diff --git a/widgets/src/widget_async.rs b/widgets/src/widget_async.rs index 07b2abec6..4ddc094b9 100644 --- a/widgets/src/widget_async.rs +++ b/widgets/src/widget_async.rs @@ -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 diff --git a/widgets/src/window.rs b/widgets/src/window.rs index 61c25195d..7bbd660ae 100644 --- a/widgets/src/window.rs +++ b/widgets/src/window.rs @@ -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, - /// 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