From 6d71eda34fc5a8da6f62c26f4670754f291c953b Mon Sep 17 00:00:00 2001 From: Kevin Boos <1139460+kevinaboos@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:58:22 -0700 Subject: [PATCH 1/7] Splash: host->script hook calls find fns defined after a shadowing let (#1210) `call_script_fn` looked names up in the module body scope, but a `let`/`fn` that shadows a name already in scope opens a child scope, and everything the script defines after it lands there, invisible from the module scope. The Splash prefix's own `let fs` / `let host` can be that shadow, so app hooks never resolved. * The VM records the scope a root frame ended in (`ScriptBody::end_scope`) * Splash looks hooks up there, falling back to the module scope --- platform/script/src/opcodes_control.rs | 6 +++ platform/script/src/thread.rs | 4 ++ platform/script/src/vm.rs | 10 ++++- platform/script/tests/hook_scope.rs | 53 ++++++++++++++++++++++++++ widgets/src/splash.rs | 12 ++++-- 5 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 platform/script/tests/hook_scope.rs 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/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/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/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, }) }) From ab7e2230d86818336b74905d1929f2d16b7c2929 Mon Sep 17 00:00:00 2001 From: Kevin Boos <1139460+kevinaboos@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:03:44 -0700 Subject: [PATCH 2/7] Splash: let the host pick the theme its isolates boot with (#1211) * an isolate's widget prelude snapshots `mod.theme` at boot, which was always the default dark theme even under a light host, so default labels and pressed buttons went light-on-light * `set_splash_theme(SplashTheme)` names the theme applied between `theme_mod` and `widgets_mod` for every new isolate --- widgets/src/lib.rs | 2 +- widgets/src/widget_async.rs | 38 +++++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) 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/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 From 4383a1383221b8459ac54f78f90c714840c9b1ff Mon Sep 17 00:00:00 2001 From: Kevin Boos <1139460+kevinaboos@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:03:57 -0700 Subject: [PATCH 3/7] View: an on_item_tap hook for script-rendered lists (#1212) * View: an on_item_tap hook for script-rendered lists Rows built by `on_render` can't carry `on_click` closures (they stop the list re-rendering), so lists had no way to be tappable. * `on_item_tap: |index|` on the container fires with the direct child under a tap * Runs after the scroll bars with capture overload, so a press still starts a drag scroll and a Button child keeps its own click * View: on_item_tap hit-tests rows with clipped_rect, so scrolled lists map to the right row * View: a press that catches a fling never counts as an item tap --- platform/src/event/finger.rs | 6 ++++++ widgets/src/view.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) 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/widgets/src/view.rs b/widgets/src/view.rs index e9f4c5ee4..f1b9a2e85 100644 --- a/widgets/src/view.rs +++ b/widgets/src/view.rs @@ -123,9 +123,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 +926,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 { From c5fb78ba091c8e69b3128989d7261e9267b9b114 Mon Sep 17 00:00:00 2001 From: Kevin Boos <1139460+kevinaboos@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:23:43 -0700 Subject: [PATCH 4/7] wayland: client-side decorations that cast a real shadow (#1215) Makepad windows on Wayland had no drop shadow, which on GNOME reads as broken next to everything else on the desktop. Mutter implements no server-side decoration protocol at all -- it advertises neither zxdg_decoration_manager_v1 nor any KDE equivalent, and its shadow code (MetaShadowFactory) lives in src/x11/ and isn't even in the introspection surface. Every shadow on that desktop is drawn by the app that owns the window. So draw one, out of eight wl_subsurfaces hung outside the toplevel: four corner tiles and four edge strips, backed by one memfd wl_shm pool and sized with wp_viewport, with xdg_surface.set_window_geometry keeping them out of the window's logical bounds. GTK instead oversizes its own surface and paints the shadow into a transparent margin. Subsurfaces keep the GL surface exactly window-sized, so the shadow costs no per-frame GPU fill, and no margin ever crosses the platform/widget boundary -- which is the entire class of off-by-a-margin bugs the other approach invites. The profile is libadwaita 1.9's, computed rather than sampled. A rectangle's Gaussian shadow is separable, so each box-shadow layer's 2-D coverage is the product of two 1-D normal CDFs, and evaluating that for a *square* rectangle is what makes the corners hug the window: sampling a rounded window's shadow gives 14/255 where a square corner needs 44/255, and fades the edge out over the last 20px before every corner. The straight-edge profile this produces matches a capture of the real libadwaita output to within 1/255, which is what the test pins. Corner tiles reach 16px along each edge, far enough that they join the strips bit-identically at any scale. Resizing happens in the gutter, the way it does for every native app. The shadow surfaces carry input regions whose union is the window rect grown by 12px -- the same halo libadwaita gives its toplevels -- and each piece maps to exactly one edge, so landing on a surface is the hit test. Window controls no longer compete with the corner grabs for the pointer, which is what let the close button swallow the top-right corner. Server-side decorations are requested wherever a compositor offers them, overridable per process with --wayland-decoration= or MAKEPAD_WAYLAND_DECORATION, and fall back to the frame above. KWin and wlroots grant them; GNOME cannot. Alongside, the caption bar gains double-click-to-maximize, a right-click window menu, resize cursors keyed off the wl_pointer.enter serial the protocol actually asks for, and tiled/constrained edges that suppress the grabs they cannot service -- degrading a corner to its free axis rather than dropping it. Finally, declare the toplevel's opaque region, under the same `!transparent && backdrop == None` condition macOS already uses for its layer's opaque flag. The buffer is ARGB8888, so without that promise a compositor cannot learn the alpha is uniformly solid short of reading every pixel: it must blend the whole window, cannot cull what the window covers, and cannot scan a fullscreen buffer out directly. Verified against a WAYLAND_DEBUG trace: over 67 committed frames the shadow issues no protocol traffic at all, and set_window_geometry, set_opaque_region and the nine wl_regions are each sent and destroyed exactly once. --- platform/src/lib.rs | 4 +- platform/src/os/linux/opengl_cx.rs | 38 +- .../src/os/linux/wayland/linux_wayland.rs | 227 +++- .../src/os/linux/wayland/opengl_wayland.rs | 1021 ++++++++++++++++- .../src/os/linux/wayland/wayland_state.rs | 798 +++++++++++-- platform/src/script/draw.rs | 2 + platform/src/window.rs | 91 ++ widgets/src/desktop_button.rs | 5 +- widgets/src/window.rs | 266 ++++- 9 files changed, 2206 insertions(+), 246 deletions(-) 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/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..69205f20f 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,131 @@ 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; +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 +211,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 +231,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 +274,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, @@ -170,6 +310,7 @@ impl WaylandState { pub fn new(event_callback: Box) -> Self { Self { compositor: None, + subcompositor: None, wm_base: None, seat: None, shm: None, @@ -193,9 +334,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,6 +355,9 @@ 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), @@ -255,6 +402,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 +533,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 +718,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 +735,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 +773,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 +797,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 +840,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 +904,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 +912,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 +1506,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 +1534,40 @@ 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.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 +1585,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 +1605,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 +1655,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 +1697,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 +1739,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, @@ -1502,8 +1941,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 +2207,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 +2303,147 @@ impl WaylandState { self.timers.time_now() } } + +#[cfg(test)] +mod tests { + use super::*; + + 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/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/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 From a13034d85d5564f95d1ce100fa6fd19827c73117 Mon Sep 17 00:00:00 2001 From: Kevin Boos <1139460+kevinaboos@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:18:26 -0700 Subject: [PATCH 5/7] wayland: stop inverting the scroll direction (#1216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wayland: stop inverting the scroll direction Wayland's wl_pointer axis values already carry Makepad's scroll convention -- positive vertical means scroll down, i.e. the viewport moves down. The backend negated them, so wheel and touchpad both scrolled backwards relative to X11, macOS, Windows and web. The spec pins the sign in wl_pointer::axis_relative_direction, whose `identical` case is a user's fingers moving down producing a "vertical_scroll down" axis event. libinput, which produces the values compositors forward, documents the same: "the positive direction being down or right". Makepad's own convention matches -- ScrollBar applies `scroll_pos + e.scroll.y` against a position clamped to [0, view_total - view_visible], and the turtle draws content at `origin - layout.scroll` inside a clip rect fixed at the unshifted origin, so a positive delta moves the viewport down. The negation came from #875, which read a positive axis value as content sliding down and cited winit's negation as precedent. But winit's MouseScrollDelta is documented as positive = content moves down, the inverse of Makepad's convention -- winit's own comment reads "Wayland sign convention is the inverse of winit" -- so copying it was a double negation. Whether a toolkit negates is decided by its own convention, not by anything about Wayland: GTK, which shares Makepad's convention, passes the values through; SDL and Chromium negate because theirs are inverted, and SDL negates vertical only, which is self-consistent just in case Wayland's +y is down and +x is right. #875 also cited the web backend as agreeing, but web forwards DOM deltaY unnegated, and deltaY is positive when scrolling down. The AxisDiscrete and AxisValue120 handlers added later inherited the sign, so all six sites flip together; the spec states each expresses its direction along the same axis as the coupled axis event. Natural scrolling needs no client-side handling. libinput applies it in evdev_notify_axis_*, below the compositor, so the delivered axis value already reflects the user's setting -- the negation was not implementing that, it inverted both settings equally. AxisRelativeDirection stays ignored, which is correct for scrolling content; it exists so widgets that should track the physical wheel regardless of the setting (the spec's example is a volume slider) can recover the direction. Fixes #1173 * wayland: classify the scroll source, and choose each axis's delta on its own Five defects in the wl_pointer frame handler, adjacent to the sign fix in the previous commit but independent of it. The detent-vs-pixel choice was made once for both axes, so a frame carrying detents on one axis and only a smooth value on the other scaled that second axis by a zero detent count and silently dropped it. Each axis now chooses on its own. `scroll_is_wheel` collapsed a five-valued classification into "Wheel vs everything else", and its false default meant "finger gesture". So a wheel tilt discarded its detents, a continuous source — a trackpoint, or button-held scrolling — was reported as a touchpad gesture, and so was a frame from a compositor that sent no axis_source at all, the event being optional and sent only when the source is known. That default is the one classification that can strand a widget: ScrollPhase::Ended is what springs a stretched rubber band back, only a finger source is guaranteed an AxisStop, and the spec tells clients to treat every other source as unterminated by default. The bool gives way to the source itself, and a sourceless frame is classified by whether it carried detents. A bare AxisStop no longer dispatches for a source with no gesture to end. Compositors stop an axis whenever its value reaches zero, whatever the source, and a zero-delta ScrollPhase::None clears a widget's overscroll and cuts short a running bounce. Nor is a stop arriving alongside live motion treated as lift-off. Per the frame event: "When a wl_pointer.axis and a wl_pointer.axis_stop event occur within the same frame, this indicates that axis movement in one axis has stopped but continues in the other axis." And because axis_source is per-frame and optional, a gesture in flight now carries its classification forward, so a lift-off frame that omits the source still ends the gesture instead of losing the terminator. The raw-pixel fallback for an axis without detents stays unscaled, which is a deliberate non-change rather than an oversight. No units-per-detent constant exists to scale it by — compositors disagree, and hwdb ships wheels from 10 to 30 degrees per click — and a physical wheel never reaches it: the fallback is for virtual pointers, whose axis value the protocol already defines as a distance. Finally, the claim that ScrollPhase::Ended lets widgets run their own momentum fling was wrong. Widgets start their fling on ScrollPhase::Momentum, which only macOS emits, so Wayland touchpads have no kinetic scrolling at all; the comment now says that rather than its opposite. The frame decision moves into `frame_scroll`, which puts every case above under a unit test instead of leaving it to be re-derived by reading. --- .../src/os/linux/wayland/wayland_state.rs | 448 ++++++++++++++---- 1 file changed, 367 insertions(+), 81 deletions(-) diff --git a/platform/src/os/linux/wayland/wayland_state.rs b/platform/src/os/linux/wayland/wayland_state.rs index 69205f20f..5b6a5a9e0 100644 --- a/platform/src/os/linux/wayland/wayland_state.rs +++ b/platform/src/os/linux/wayland/wayland_state.rs @@ -66,6 +66,119 @@ 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, @@ -284,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 @@ -362,7 +488,8 @@ impl WaylandState { 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, @@ -1535,6 +1662,7 @@ impl Dispatch for WaylandState { 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; @@ -1780,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: _, @@ -2308,6 +2434,166 @@ impl WaylandState { 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() From 14fe611e66c7defda5cef84cc1bbcae2eee29b2f Mon Sep 17 00:00:00 2001 From: Jason Yau <91023929+jason-yau@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:26:03 +0800 Subject: [PATCH 6/7] Add `Apply::Rebake` so `script_mod` re-runs stop clobbering imperative state (#1219) Co-authored-by: jasonqiu --- .../script/derive/src/derive_scriptable.rs | 18 +++++ platform/script/derive/src/lib.rs | 1 + platform/script/src/apply.rs | 67 +++++++++++++++++-- platform/script/src/prims.rs | 15 +++-- platform/script/src/traits.rs | 8 ++- platform/src/app_main.rs | 5 +- platform/src/arc_string_mut.rs | 10 +-- platform/src/cx.rs | 18 +++-- platform/src/cx_api.rs | 17 +++-- platform/src/os/cx_shared.rs | 5 ++ widgets/derive_widget/src/lib.rs | 1 + widgets/src/animator.rs | 38 ++++++++--- widgets/src/drop_down.rs | 1 + widgets/src/page_flip.rs | 1 + widgets/src/view.rs | 1 + 15 files changed, 168 insertions(+), 38 deletions(-) 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/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/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/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/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/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/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/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/view.rs b/widgets/src/view.rs index f1b9a2e85..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)] From 4b25a1bf1ed3a1dbb1a7cfea1a030ab8c290fc5a Mon Sep 17 00:00:00 2001 From: Kevin Boos <1139460+kevinaboos@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:53:29 -0700 Subject: [PATCH 7/7] cargo-makepad: support custom iOS and tvOS Info.plist entries (#1221) * cargo-makepad: declare native speech recognition metadata * cargo-makepad: make Apple plist customization opt-in * cargo-makepad: use macOS tools for plist overlays --- tools/cargo_makepad/src/apple/compile.rs | 50 +-- tools/cargo_makepad/src/apple/info_plist.rs | 161 ++++++++++ .../src/apple/info_plist/tests.rs | 300 ++++++++++++++++++ tools/cargo_makepad/src/apple/mod.rs | 1 + tools/cargo_makepad/src/main.rs | 6 + 5 files changed, 496 insertions(+), 22 deletions(-) create mode 100644 tools/cargo_makepad/src/apple/info_plist.rs create mode 100644 tools/cargo_makepad/src/apple/info_plist/tests.rs 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!(