From dd4c8309d95088547c443649e6b31d1c8049671d Mon Sep 17 00:00:00 2001 From: Kevin Boos <1139460+kevinaboos@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:44:10 -0700 Subject: [PATCH 01/11] Resources: search the executable's directory, not only the working directory (#1196) A packaged desktop build addresses its resources through a relative package root -- `cargo packager` and `robius-packaging-commands` both use `.`, with the resource trees sitting beside the executable -- and a relative `File::open` resolves against the process working directory. Any launcher that sets no working directory therefore starts the app somewhere unrelated and every font, icon and image open fails: a URL-protocol handler (`HKCR\\shell\open\ command` carries no working directory), a file association, a service, a shortcut created without one. The result is not a clean failure. The window comes up and lays out correctly, shader-drawn shapes and buttons render, and network-loaded images appear, but every glyph and every bundled icon is missing, because those are the parts that need a file. It reads as a renderer bug rather than a missing directory. macOS avoids this through `apple_bundle_load_dependencies`, and a Linux `deb` package uses an absolute `/usr/lib/`, so Windows is the only desktop target whose resource lookup depends on where it was started from. Retry a failed open against the directory holding the executable. This is purely additive -- a path that resolves today resolves identically, and only an open that would have failed reaches the fallback -- so a dev build's workspace-relative dependency paths keep working unchanged. Co-authored-by: Claude Opus 5 (1M context) --- platform/src/os/cx_native.rs | 62 ++++++++++++++++++++++++++++++------ platform/src/script/res.rs | 9 +++--- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/platform/src/os/cx_native.rs b/platform/src/os/cx_native.rs index da447d131..1f3517521 100644 --- a/platform/src/os/cx_native.rs +++ b/platform/src/os/cx_native.rs @@ -1,6 +1,13 @@ use { crate::cx::Cx, - std::{fs::File, io::prelude::*, rc::Rc, time::SystemTime}, + std::{ + fs::File, + io::prelude::*, + path::{Path, PathBuf}, + rc::Rc, + sync::OnceLock, + time::SystemTime, + }, }; #[derive(PartialEq, Eq, Clone, Copy, Debug)] @@ -10,21 +17,58 @@ pub enum EventFlow { Exit, } +/// The directory holding the running executable, queried once. +fn exe_dir() -> Option<&'static Path> { + static EXE_DIR: OnceLock> = OnceLock::new(); + EXE_DIR + .get_or_init(|| { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(Path::to_path_buf)) + }) + .as_deref() +} + +/// Resolves a relative resource path against the directory holding the executable. +/// +/// Packaged desktop layouts ship resources beside the executable and address them through a +/// relative package root, which a plain relative open resolves against the process working +/// directory instead. Any launcher that does not set a working directory — a URL-protocol +/// handler, a file association, a service, a shortcut without one — then starts the app in an +/// unrelated directory and every resource open fails, leaving a window that draws its shapes +/// but has no fonts, icons or images. Callers retry through here so the executable's own +/// directory is searched as well. Returns `None` for an absolute path (already anchored) and +/// when the executable path is unavailable. +pub fn exe_relative_path(rel: impl AsRef) -> Option { + let rel = rel.as_ref(); + if rel.is_absolute() { + return None; + } + Some(exe_dir()?.join(rel)) +} + +/// Reads a file at `path`, falling back to the same path resolved against the executable's +/// directory. Returns `None` when neither location holds a readable file. +pub fn read_file_cwd_or_exe_relative(path: impl AsRef) -> Option> { + fn read(path: &Path) -> Option> { + let mut buffer = Vec::::new(); + File::open(path).ok()?.read_to_end(&mut buffer).ok()?; + Some(buffer) + } + let path = path.as_ref(); + read(path).or_else(|| read(&exe_relative_path(path)?)) +} + // lets start a websocket thread impl Cx { pub fn native_load_dependencies(&mut self) { for (path, dep) in &mut self.dependencies { - if let Ok(mut file_handle) = File::open(path) { - let mut buffer = Vec::::new(); - if file_handle.read_to_end(&mut buffer).is_ok() { - dep.data = Some(Ok(Rc::new(buffer))); - } else { - dep.data = Some(Err("read_to_end failed".to_string())); - } + if let Some(buffer) = read_file_cwd_or_exe_relative(path) { + dep.data = Some(Ok(Rc::new(buffer))); } else { println!("Could not load resource {}", path); - dep.data = Some(Err("File! open failed".to_string())); + dep.data = Some(Err(format!("Could not read resource {}", path))); } } } diff --git a/platform/src/script/res.rs b/platform/src/script/res.rs index b7f5b5e16..c01abc5e4 100644 --- a/platform/src/script/res.rs +++ b/platform/src/script/res.rs @@ -247,6 +247,10 @@ fn load_packaged_resource(cx: &Cx, dep_path: &str) -> Option>> { /// Try to load a resource from the packaged location on desktop. /// Returns None when not in packaged mode (package_root is None). +/// +/// A relative `package_root` (the desktop packagers use `.` beside the executable) is searched +/// both from the working directory and from the executable's own directory, because a launcher +/// is free to start the process anywhere — see `crate::os::cx_native::exe_relative_path`. #[cfg(all( not(target_arch = "wasm32"), not(any(target_os = "android", target_os = "ios", target_os = "tvos")), @@ -255,10 +259,7 @@ fn load_packaged_resource(cx: &Cx, dep_path: &str) -> Option>> { fn load_packaged_resource(cx: &Cx, dep_path: &str) -> Option>> { let root = cx.package_root.as_deref()?; let full_path = format!("{}/{}", root, dep_path); - let mut file = File::open(&full_path).ok()?; - let mut data = Vec::new(); - file.read_to_end(&mut data).ok()?; - Some(Rc::new(data)) + crate::os::cx_native::read_file_cwd_or_exe_relative(&full_path).map(Rc::new) } /// Load a file directly from the filesystem (desktop/mobile only, not wasm). From e40a5318f70cdb05c2245774b33859a835c4c1d4 Mon Sep 17 00:00:00 2001 From: Kevin Boos <1139460+kevinaboos@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:44:21 -0700 Subject: [PATCH 02/11] Windows: fit a restored window to the displays that are actually attached (#1197) An app that persists its window geometry has to restore it into a display arrangement that may have changed completely since it was saved: the display the window sat on can be gone, a docked laptop can be back on its built-in panel, and the file can hold values no display ever had. Nothing validated any of it -- `configure_window` stored the numbers and each backend passed them straight to `CreateWindowExW` / `initWithContentRect:` / `XCreateWindow` -- so a window could come back off-screen or too small to grab, with no way back except deleting the state file. Windows also manufactured those values. Win32 reports a minimized window at `(-32000, -32000)` with a zero-sized client rect, `WM_SIZE` published that as the window's authoritative geometry, and an app saving on shutdown wrote it down. `WM_MOVE` meanwhile published nothing, so a window that was dragged but not resized persisted its pre-drag position (X11's `ConfigureNotify` and macOS's `windowDidMove:` both already published). Add `platform/src/screen.rs`, holding the policy in one place: - `sanitize_window_geom` needs no display knowledge and every backend reaches it through `CxWindow::create_geom`. It is what protects the backends a fit cannot help: Wayland enumerates no displays for a client and hands the size to `wl_egl_window_create`, which rejects a non-positive one -- a persisted `0` or `NaN` panicked the app at startup -- and X11 encodes extents as unsigned 16-bit and answers a zero with a protocol error that, with no error handler installed, terminates the process. - `clamp_point_to_screens` pins the origin BEFORE the window is created. The fit alone is too late: `set_inner_size` runs in between and works relative to wherever the window landed. - `fit_window_rect_to_screens` corrects the finished rectangle. A window already wholly on the desktop is returned untouched, including one deliberately spanning two adjacent displays; anything else moves to the display it overlaps most, or nearest by centre, capped to that work area. Displays come from `EnumDisplayMonitors` + `GetMonitorInfoW` on Windows (both absent from the vendored bindings, so linked here), `NSScreen.screens` on macOS, and the root geometry plus EWMH `_NET_WORKAREA` on X11. Wayland is unaffected by the class of bug: a client there cannot know or choose where its windows go. Report a minimized window from `GetWindowPlacement().rcNormalPosition`, converted out of workspace coordinates, so what an app persists is the geometry the window actually returns to; skip publishing on `SIZE_MINIMIZED`; publish on `WM_MOVE`, deferred to `WM_EXITSIZEMOVE` during a user drag because the Cx handler redraws on every geometry event. Positions are now documented and implemented as physical screen pixels on Windows and X11 (points on macOS) and are never DPI-scaled, matching `get_position`, `create_position` and the platform calls. `set_position` alone had been scaling its argument, so `set_position(get_position())` moved a window to twice its coordinates on a 200% display. Co-authored-by: Claude Opus 5 (1M context) --- platform/src/lib.rs | 2 + platform/src/os/apple/macos/macos.rs | 5 +- platform/src/os/apple/macos/macos_window.rs | 91 +++- platform/src/os/headless/event_loop.rs | 11 +- .../src/os/linux/wayland/linux_wayland.rs | 8 +- .../src/os/linux/wayland/opengl_wayland.rs | 15 +- platform/src/os/linux/x11/linux_x11.rs | 5 +- platform/src/os/linux/x11/mod.rs | 1 + platform/src/os/linux/x11/x11_screen.rs | 127 +++++ platform/src/os/linux/x11/xlib_window.rs | 102 +++- platform/src/os/windows/mod.rs | 1 + platform/src/os/windows/win32_screen.rs | 114 ++++ platform/src/os/windows/win32_window.rs | 229 ++++++-- platform/src/os/windows/windows.rs | 5 +- platform/src/screen.rs | 497 ++++++++++++++++++ platform/src/window.rs | 25 + 16 files changed, 1162 insertions(+), 76 deletions(-) create mode 100644 platform/src/os/linux/x11/x11_screen.rs create mode 100644 platform/src/os/windows/win32_screen.rs create mode 100644 platform/src/screen.rs diff --git a/platform/src/lib.rs b/platform/src/lib.rs index ba4497fe9..168631806 100644 --- a/platform/src/lib.rs +++ b/platform/src/lib.rs @@ -52,6 +52,7 @@ mod performance_stats; pub mod memory_watchdog; pub mod perf_monitor; pub mod permission; +mod screen; mod texture; mod uniform_buffer; mod window; @@ -215,6 +216,7 @@ pub use { unregister_media_playback_session, MediaPlaybackSessionId, }, script::vm::*, + screen::{fit_window_rect_to_screens, ScreenGeom, MIN_WINDOW_SIZE}, shared_bytes::{MappedBytes, SharedBytes, SharedBytesStats}, texture::{ image_cache_use_mipmaps, Texture, TextureAnimation, TextureFormat, TextureId, diff --git a/platform/src/os/apple/macos/macos.rs b/platform/src/os/apple/macos/macos.rs index 7f99a64e9..178f44293 100644 --- a/platform/src/os/apple/macos/macos.rs +++ b/platform/src/os/apple/macos/macos.rs @@ -1539,11 +1539,12 @@ impl Cx { match op { CxOsOp::CreateWindow(window_id) => { let window = &mut self.windows[window_id]; + let (create_position, create_inner_size) = window.create_geom(); let mut metal_window = MetalWindow::new( window_id, &metal_cx, - window.create_inner_size.unwrap_or(dvec2(800., 600.)), - window.create_position, + create_inner_size, + create_position, &window.create_title, window.is_fullscreen, window.macos, diff --git a/platform/src/os/apple/macos/macos_window.rs b/platform/src/os/apple/macos/macos_window.rs index f117b6365..2577b2ad2 100644 --- a/platform/src/os/apple/macos/macos_window.rs +++ b/platform/src/os/apple/macos/macos_window.rs @@ -6,7 +6,7 @@ use { MouseUpEvent, ScrollEvent, ScrollPhase, TextInputEvent, WindowCloseRequestedEvent, WindowDragQueryEvent, WindowDragQueryResponse, WindowGeom, WindowGeomChangeEvent, }, - makepad_math::{Rect, Vec2d}, + makepad_math::{dvec2, Rect, Vec2d}, os::{ apple::apple_sys::*, apple::apple_util::str_to_nsstring, @@ -18,6 +18,7 @@ use { macos_event::MacosEvent, }, }, + screen::{clamp_point_to_screens, fit_window_rect_to_screens, ScreenGeom}, window::{ MacosWindowChrome, MacosWindowConfig, MacosWindowKind, MacosWindowLevel, WindowBackdrop, WindowId, WindowVisuals, @@ -255,9 +256,13 @@ impl MacosWindow { let () = msg_send![self.view, setAllowedTouchTypes: 2u64]; let left_top = if let Some(position) = position { + // A restored position can name a display that is gone. Pinning it before the + // window is built keeps it from being ordered on screen somewhere unreachable; + // `fit_to_screens` below corrects the finished frame. + let pinned = clamp_point_to_screens(&macos_screens(), position); NSPoint { - x: position.x as f64, - y: position.y as f64, + x: pinned.x, + y: pinned.y, } } else { NSPoint { x: 0., y: 0. } @@ -350,6 +355,11 @@ impl MacosWindow { if position.is_none() { let () = msg_send![self.window, center]; } + if !is_fullscreen { + // A restored size and position are only as good as the display arrangement + // they were saved on; a fullscreen window is AppKit's to place. + self.fit_to_screens(); + } let input_context: ObjcId = msg_send![self.view, inputContext]; let () = msg_send![input_context, invalidateCharacterCoordinates]; @@ -763,12 +773,34 @@ impl MacosWindow { let mut window_frame: NSRect = unsafe { msg_send![self.window, frame] }; window_frame.origin.x = pos.x as f64; window_frame.origin.y = pos.y as f64; - //not very nice: CGDisplay::main().pixels_high() as f64 + // A caller placing the window cannot know the display arrangement it is placing + // into, so the request is fitted to the displays that are actually attached. + let fitted = fit_window_rect_to_screens(&macos_screens(), rect_of(window_frame)); unsafe { - let () = msg_send![self.window, setFrame: window_frame display: YES]; + let () = msg_send![self.window, setFrame: ns_rect_of(fitted) display: YES]; }; } + /// Moves and resizes the window so it sits entirely within one display's visible frame. + /// + /// See `crate::screen::fit_window_rect_to_screens` for what counts as a fit and why it + /// is unconditional. A window that already fits is left untouched. + pub fn fit_to_screens(&mut self) { + let screens = macos_screens(); + if screens.is_empty() { + return; + } + let frame: NSRect = unsafe { msg_send![self.window, frame] }; + let current = rect_of(frame); + let fitted = fit_window_rect_to_screens(&screens, current); + if fitted == current { + return; + } + unsafe { + let () = msg_send![self.window, setFrame: ns_rect_of(fitted) display: YES]; + } + } + pub fn get_position(&self) -> Vec2d { let window_frame: NSRect = unsafe { msg_send![self.window, frame] }; Vec2d { @@ -1183,3 +1215,52 @@ pub fn get_cocoa_window(this: &Object) -> &mut MacosWindow { &mut *(ptr as *mut MacosWindow) } } + +/// Converts an `NSRect` to makepad's rectangle, leaving Cocoa's bottom-left origin as it is. +fn rect_of(r: NSRect) -> Rect { + Rect { + pos: dvec2(r.origin.x, r.origin.y), + size: dvec2(r.size.width, r.size.height), + } +} + +/// Converts makepad's rectangle back to an `NSRect`. +fn ns_rect_of(r: Rect) -> NSRect { + NSRect { + origin: NSPoint { + x: r.pos.x, + y: r.pos.y, + }, + size: NSSize { + width: r.size.x, + height: r.size.y, + }, + } +} + +/// The displays currently attached, in Cocoa's global point space (bottom-left origin) — +/// the space an `NSWindow` frame is expressed in. +pub fn macos_screens() -> Vec { + unsafe { + let screens: ObjcId = msg_send![class!(NSScreen), screens]; + let count: usize = msg_send![screens, count]; + let mut out = Vec::with_capacity(count); + for index in 0..count { + let screen: ObjcId = msg_send![screens, objectAtIndex: index]; + if screen == nil { + continue; + } + let frame: NSRect = msg_send![screen, frame]; + let visible: NSRect = msg_send![screen, visibleFrame]; + out.push(ScreenGeom { + bounds: rect_of(frame), + work_area: rect_of(visible), + // Element zero of `NSScreen.screens` is the display holding the menu bar, + // which is the one Cocoa places windows against; `mainScreen` follows the + // key window instead and would move under the app. + is_primary: index == 0, + }); + } + out + } +} diff --git a/platform/src/os/headless/event_loop.rs b/platform/src/os/headless/event_loop.rs index 3d788a37b..5a45ba7d1 100644 --- a/platform/src/os/headless/event_loop.rs +++ b/platform/src/os/headless/event_loop.rs @@ -633,10 +633,13 @@ impl Cx { } let window = &mut self.windows[window_id]; - let inner_size = window - .create_inner_size - .unwrap_or_else(|| dvec2(1920.0, 1080.0)); - let position = window.create_position.unwrap_or_else(|| dvec2(0.0, 0.0)); + let (position, inner_size) = window.create_geom(); + let inner_size = if window.create_inner_size.is_some() { + inner_size + } else { + dvec2(1920.0, 1080.0) + }; + let position = position.unwrap_or_else(|| dvec2(0.0, 0.0)); let dpi_factor = configured_headless_dpi(); let state = &mut windows[window_id.id()]; diff --git a/platform/src/os/linux/wayland/linux_wayland.rs b/platform/src/os/linux/wayland/linux_wayland.rs index fe8862b70..ace77a28a 100644 --- a/platform/src/os/linux/wayland/linux_wayland.rs +++ b/platform/src/os/linux/wayland/linux_wayland.rs @@ -634,6 +634,7 @@ impl WaylandCx { let compositor = state.compositor.as_ref().unwrap(); let wm_base = state.wm_base.as_ref().unwrap(); let window = &cx.windows[window_id]; + let (create_position, create_inner_size) = window.create_geom(); let app_id = if window.create_app_id.is_empty() { "Makepad" } else { @@ -650,8 +651,8 @@ impl WaylandCx { state.shm.as_ref(), self.qhandle.as_ref().unwrap(), gl_cx, - window.create_inner_size.unwrap_or(dvec2(800., 600.)), - window.create_position, + create_inner_size, + create_position, &window.create_title, app_id, window.is_fullscreen, @@ -765,6 +766,9 @@ impl WaylandCx { } } CxOsOp::ResizeWindow(window_id, size) => {} + // A Wayland client is not told where its windows are and cannot move them; + // the compositor owns placement, so a window here is never left off-screen + // by a restored position the way it can be on Windows, macOS and X11. CxOsOp::RepositionWindow(window_id, size) => {} CxOsOp::SetWindowVisuals(_window_id, visuals) => { if visuals.backdrop != crate::window::WindowBackdrop::None { diff --git a/platform/src/os/linux/wayland/opengl_wayland.rs b/platform/src/os/linux/wayland/opengl_wayland.rs index bc25aa593..5a19f4d8b 100644 --- a/platform/src/os/linux/wayland/opengl_wayland.rs +++ b/platform/src/os/linux/wayland/opengl_wayland.rs @@ -87,8 +87,19 @@ impl WaylandWindow { } base_surface.commit(); - let wl_egl_surface = - WlEglSurface::new(base_surface.id(), inner_size.x as i32, inner_size.y as i32).unwrap(); + // `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 wl_egl_surface = match WlEglSurface::new(base_surface.id(), egl_w, egl_h) { + Ok(surface) => surface, + Err(e) => { + crate::error!("wl_egl_window_create failed at {egl_w}x{egl_h}: {e:?}"); + WlEglSurface::new(base_surface.id(), 800, 600) + .expect("wl_egl_window_create failed at the fallback size too") + } + }; let egl_surface = unsafe { (opengl_cx.libegl.eglCreateWindowSurface.unwrap())( opengl_cx.egl_display, diff --git a/platform/src/os/linux/x11/linux_x11.rs b/platform/src/os/linux/x11/linux_x11.rs index a36930176..b5e680694 100644 --- a/platform/src/os/linux/x11/linux_x11.rs +++ b/platform/src/os/linux/x11/linux_x11.rs @@ -550,11 +550,12 @@ impl X11Cx { CxOsOp::CreateWindow(window_id) => { let gl_cx = cx.os.opengl_cx.as_ref().unwrap(); let window = &cx.windows[window_id]; + let (create_position, create_inner_size) = window.create_geom(); let opengl_window = OpenglWindow::new( window_id, gl_cx, - window.create_inner_size.unwrap_or(dvec2(800., 600.)), - window.create_position, + create_inner_size, + create_position, &window.create_title, &window.create_app_id, window.is_fullscreen, diff --git a/platform/src/os/linux/x11/mod.rs b/platform/src/os/linux/x11/mod.rs index c56a271e5..73e842377 100644 --- a/platform/src/os/linux/x11/mod.rs +++ b/platform/src/os/linux/x11/mod.rs @@ -1,6 +1,7 @@ pub mod linux_x11; pub mod linux_x11_stdin; pub mod opengl_x11; +pub mod x11_screen; pub mod x11_sys; pub mod xlib_app; pub mod xlib_event; diff --git a/platform/src/os/linux/x11/x11_screen.rs b/platform/src/os/linux/x11/x11_screen.rs new file mode 100644 index 000000000..3b7a74555 --- /dev/null +++ b/platform/src/os/linux/x11/x11_screen.rs @@ -0,0 +1,127 @@ +//! Display geometry for the X11 backend. + +use { + self::super::{x11_sys, xlib_app::get_xlib_app_global}, + crate::{makepad_math::*, screen::ScreenGeom}, + std::{ + ffi::CString, + mem, + os::raw::{c_int, c_long, c_uchar, c_ulong}, + ptr, + }, +}; + +/// Reads a `CARDINAL` array property from the root window. +/// +/// Returns an empty vector when the property is absent, which is the normal answer from a +/// window manager that does not implement the hint. +unsafe fn root_cardinals(name: &str) -> Vec { + let display = get_xlib_app_global().display; + let Ok(name) = CString::new(name) else { + return Vec::new(); + }; + // `only_if_exists` = true: never define the atom, only look one up. + let atom = unsafe { x11_sys::XInternAtom(display, name.as_ptr(), 1) }; + if atom == 0 { + return Vec::new(); + } + let root = unsafe { + let screen = x11_sys::XDefaultScreen(display); + x11_sys::XRootWindow(display, screen) + }; + + let mut actual_type: x11_sys::Atom = 0; + let mut actual_format: c_int = 0; + let mut n_items: c_ulong = 0; + let mut bytes_after: c_ulong = 0; + let mut data: *mut c_uchar = ptr::null_mut(); + // A long_length of 64 covers 16 desktops' worth of four-value work areas; anything past + // that is left unread rather than paged in. + let status = unsafe { + x11_sys::XGetWindowProperty( + display, + root, + atom, + 0, + 64, + 0, + x11_sys::AnyPropertyType as c_ulong, + &mut actual_type, + &mut actual_format, + &mut n_items, + &mut bytes_after, + &mut data, + ) + }; + // Xlib's `Success` is zero; the constant itself is not in the bindings. + if status != 0 || data.is_null() { + return Vec::new(); + } + // Xlib hands back 32-bit properties widened to `long`, whatever the wire format says. + let out = if actual_format == 32 { + unsafe { std::slice::from_raw_parts(data as *const c_long, n_items as usize).to_vec() } + } else { + Vec::new() + }; + unsafe { x11_sys::XFree(data as *mut _) }; + out +} + +/// The X screen's full extent, from the root window's geometry. +unsafe fn root_bounds() -> Option { + let display = get_xlib_app_global().display; + let root = unsafe { + let screen = x11_sys::XDefaultScreen(display); + x11_sys::XRootWindow(display, screen) + }; + let mut xwa = mem::MaybeUninit::::uninit(); + if unsafe { x11_sys::XGetWindowAttributes(display, root, xwa.as_mut_ptr()) } == 0 { + return None; + } + let xwa = unsafe { xwa.assume_init() }; + if xwa.width <= 0 || xwa.height <= 0 { + return None; + } + Some(Rect { + pos: dvec2(0.0, 0.0), + size: dvec2(xwa.width as f64, xwa.height as f64), + }) +} + +/// The desktop area a window may occupy, in physical pixels — the coordinate space +/// `XMoveWindow` and `XCreateWindow` take positions in. +/// +/// This is one entry covering the whole X screen, not one per physical monitor: splitting a +/// Xinerama screen into its heads needs libXinerama or libXrandr, and makepad links neither. +/// It still keeps a window on the desktop and clear of the panels, which is what a restored +/// position can get wrong. The extent comes from the root window, and the reserved edges +/// from the EWMH `_NET_WORKAREA` hint of the current desktop, falling back to the full extent +/// under a window manager that publishes neither. +pub fn x11_screens() -> Vec { + let Some(bounds) = (unsafe { root_bounds() }) else { + return Vec::new(); + }; + + let desktop = unsafe { root_cardinals("_NET_CURRENT_DESKTOP") } + .first() + .copied() + .unwrap_or(0) + .max(0) as usize; + let areas = unsafe { root_cardinals("_NET_WORKAREA") }; + let work_area = areas + .chunks_exact(4) + .nth(desktop) + .or_else(|| areas.chunks_exact(4).next()) + .map(|a| Rect { + pos: dvec2(a[0] as f64, a[1] as f64), + size: dvec2(a[2] as f64, a[3] as f64), + }) + .filter(|r| r.size.x > 0.0 && r.size.y > 0.0) + .unwrap_or(bounds); + + vec![ScreenGeom { + bounds, + work_area, + is_primary: true, + }] +} diff --git a/platform/src/os/linux/x11/xlib_window.rs b/platform/src/os/linux/x11/xlib_window.rs index ab1920579..8e1ea9373 100644 --- a/platform/src/os/linux/x11/xlib_window.rs +++ b/platform/src/os/linux/x11/xlib_window.rs @@ -1,6 +1,12 @@ use { self::super::{x11_sys, xlib_app::*, xlib_event::XlibEvent}, - crate::{area::Area, cursor::MouseCursor, event::*, makepad_math::{Rect, Vec2d}, window::WindowId}, + crate::{ + area::Area, cursor::MouseCursor, event::*, + makepad_math::{dvec2, Rect, Vec2d}, + os::linux::x11::x11_screen::x11_screens, + screen::fit_window_rect_to_screens, + window::WindowId, + }, std::{ cell::Cell, ffi::{CStr, CString}, @@ -110,22 +116,26 @@ impl XlibWindow { | x11_sys::LeaveWindowMask) as c_long; let dpi_factor = self.get_dpi_factor(); + // A restored size and position are only as good as the desktop layout they were + // saved on, so the request is fitted before it reaches the server. Doing it here + // covers the geometry, the size hints and the pre-map move alike. + let (position, size) = fit_create_geom(position, size, dpi_factor); // Create a window + // X11 encodes a window position as INT16 and an extent as CARD16, and a request + // outside those ranges is a BadValue protocol error — which, with no error handler + // installed, terminates the process. The fit above already keeps a placement on the + // desktop; these clamps are what guarantee the request is expressible at all. + let (create_x, create_y) = match position { + Some(position) => (clamp_coord(position.x), clamp_coord(position.y)), + None => (150, 60), + }; let window = x11_sys::XCreateWindow( display, root_window, - if position.is_some() { - position.unwrap().x - } else { - 150.0 - } as i32, - if position.is_some() { - position.unwrap().y - } else { - 60.0 - } as i32, - (size.x * dpi_factor) as u32, - (size.y * dpi_factor) as u32, + create_x, + create_y, + clamp_extent(size.x * dpi_factor), + clamp_extent(size.y * dpi_factor), 0, visual_info.depth, x11_sys::InputOutput as u32, @@ -738,6 +748,8 @@ impl XlibWindow { } } + /// The window's top-left corner in physical screen pixels; see [`Self::set_position`] + /// for why positions are not scaled the way sizes are. pub fn get_position(&self) -> Vec2d { unsafe { let display = get_xlib_app_global().display; @@ -793,18 +805,29 @@ impl XlibWindow { } } + /// Moves the window's top-left corner to `pos`, in physical screen pixels — the same + /// space [`Self::get_position`] reports and `XCreateWindow` takes, so + /// `set_position(get_position())` leaves the window where it is. Sizes are logical and + /// scale with the DPI; positions are not, because a screen coordinate on a multi-monitor + /// desktop has no single scale factor to be logical in. pub fn set_position(&mut self, pos: Vec2d) { unsafe { let display = get_xlib_app_global().display; - let dpi_factor = self.get_dpi_factor(); + // A caller placing the window cannot know the desktop it is placing into, so the + // request is fitted to the desktop that is actually there. + let want = Rect { + pos, + size: self.get_outer_size(), + }; + let fitted = fit_window_rect_to_screens(&x11_screens(), want); x11_sys::XMoveWindow( display, self.window.unwrap(), - (pos.x * dpi_factor) as i32, - (pos.y * dpi_factor) as i32, + clamp_coord(fitted.pos.x), + clamp_coord(fitted.pos.y), ); x11_sys::XFlush(display); - self.last_window_geom.position = pos; + self.last_window_geom.position = fitted.pos; } } @@ -1284,3 +1307,48 @@ impl DndAtoms { } } } + +/// Fits a requested window placement onto the desktop. +/// +/// Takes and returns the pair `XCreateWindow` is called with: a position in physical pixels +/// and an inner size in logical pixels. `None` leaves placement to the window manager, which +/// already puts the window somewhere visible, so it passes straight through. +fn fit_create_geom( + position: Option, + size: Vec2d, + dpi_factor: f64, +) -> (Option, Vec2d) { + let Some(pos) = position else { + return (None, size); + }; + let screens = x11_screens(); + if screens.is_empty() { + return (position, size); + } + let want = Rect { + pos, + size: dvec2(size.x * dpi_factor, size.y * dpi_factor), + }; + let fitted = fit_window_rect_to_screens(&screens, want); + ( + Some(fitted.pos), + dvec2(fitted.size.x / dpi_factor, fitted.size.y / dpi_factor), + ) +} + +/// Clamps a window coordinate into the INT16 range the X11 protocol encodes it in. +fn clamp_coord(v: f64) -> c_int { + if !v.is_finite() { + return 0; + } + (v as i64).clamp(-32768, 32767) as c_int +} + +/// Clamps a window extent into the CARD16 range the X11 protocol encodes it in. Zero is not +/// a legal extent, so the floor is one pixel. +fn clamp_extent(v: f64) -> u32 { + if !v.is_finite() { + return 1; + } + (v as i64).clamp(1, 65535) as u32 +} diff --git a/platform/src/os/windows/mod.rs b/platform/src/os/windows/mod.rs index 9e696b68f..ed84c865c 100644 --- a/platform/src/os/windows/mod.rs +++ b/platform/src/os/windows/mod.rs @@ -12,6 +12,7 @@ pub mod video_file_decoder; pub mod video_file_encoder; pub mod wasapi; pub mod win32_event; +pub mod win32_screen; pub mod win32_window; pub mod windows_media; pub mod windows_media_engine_notify; diff --git a/platform/src/os/windows/win32_screen.rs b/platform/src/os/windows/win32_screen.rs new file mode 100644 index 000000000..c7f070654 --- /dev/null +++ b/platform/src/os/windows/win32_screen.rs @@ -0,0 +1,114 @@ +//! Display enumeration for the Win32 backend. + +#![allow(non_snake_case)] + +use { + crate::{ + makepad_math::*, + screen::ScreenGeom, + windows::Win32::{ + Foundation::{LPARAM, RECT}, + Graphics::Gdi::{HDC, HMONITOR}, + }, + }, + std::{mem::size_of, ptr}, +}; + +/// `MONITORINFO`, absent from the vendored `windows` bindings. `cb_size` tells +/// `GetMonitorInfoW` which layout it was handed, so it must be filled in before the call. +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct MonitorInfo { + cb_size: u32, + rc_monitor: RECT, + rc_work: RECT, + dw_flags: u32, +} + +/// `MONITORINFOF_PRIMARY`: the display holding the origin of the virtual screen. +const MONITORINFOF_PRIMARY: u32 = 1; + +type MonitorEnumProc = + unsafe extern "system" fn(HMONITOR, HDC, *mut RECT, LPARAM) -> windows_core::BOOL; + +#[inline] +unsafe fn EnumDisplayMonitors( + hdc: HDC, + clip: *const RECT, + callback: MonitorEnumProc, + data: LPARAM, +) -> windows_core::BOOL { + windows_core::link!("user32.dll" "system" fn EnumDisplayMonitors(hdc : HDC, clip : *const RECT, callback : MonitorEnumProc, data : LPARAM) -> windows_core::BOOL); + unsafe { EnumDisplayMonitors(hdc, clip, callback, data) } +} + +#[inline] +unsafe fn GetMonitorInfoW(monitor: HMONITOR, info: *mut MonitorInfo) -> windows_core::BOOL { + windows_core::link!("user32.dll" "system" fn GetMonitorInfoW(monitor : HMONITOR, info : *mut MonitorInfo) -> windows_core::BOOL); + unsafe { GetMonitorInfoW(monitor, info) } +} + +/// Converts a Win32 edge-addressed rectangle to the origin-plus-size form makepad uses. +fn rect_of(r: RECT) -> Rect { + Rect { + pos: dvec2(r.left as f64, r.top as f64), + size: dvec2((r.right - r.left) as f64, (r.bottom - r.top) as f64), + } +} + +/// The displays currently attached, in physical screen pixels — the coordinate space +/// `CreateWindowExW` and `MoveWindow` take window positions in. +pub fn win32_screens() -> Vec { + unsafe extern "system" fn collect( + monitor: HMONITOR, + _hdc: HDC, + _clip: *mut RECT, + data: LPARAM, + ) -> windows_core::BOOL { + let screens = unsafe { &mut *(data.0 as *mut Vec) }; + let mut info = MonitorInfo { + cb_size: size_of::() as u32, + ..Default::default() + }; + if unsafe { GetMonitorInfoW(monitor, &mut info) }.as_bool() { + screens.push(ScreenGeom { + bounds: rect_of(info.rc_monitor), + work_area: rect_of(info.rc_work), + is_primary: info.dw_flags & MONITORINFOF_PRIMARY != 0, + }); + } + // Keep enumerating; a display whose info could not be read is simply skipped. + windows_core::BOOL(1) + } + + let mut screens = Vec::new(); + unsafe { + let _ = EnumDisplayMonitors( + HDC::default(), + ptr::null(), + collect, + LPARAM(&mut screens as *mut Vec as isize), + ); + } + screens +} + +/// Converts a rectangle in Win32 "workspace" coordinates to screen coordinates. +/// +/// `WINDOWPLACEMENT` reports a normal top-level window in workspace coordinates: screen +/// coordinates shifted by the primary display's reserved edges. The two spaces coincide for +/// the usual bottom-docked taskbar and differ by its thickness when it sits at the top or on +/// the left, so the shift is read from the primary display rather than assumed to be zero. +pub fn workspace_rect_to_screen(r: RECT) -> RECT { + let Some(primary) = win32_screens().into_iter().find(|s| s.is_primary) else { + return r; + }; + let dx = (primary.work_area.pos.x - primary.bounds.pos.x) as i32; + let dy = (primary.work_area.pos.y - primary.bounds.pos.y) as i32; + RECT { + left: r.left + dx, + top: r.top + dy, + right: r.right + dx, + bottom: r.bottom + dy, + } +} diff --git a/platform/src/os/windows/win32_window.rs b/platform/src/os/windows/win32_window.rs index dc4f334a4..1097c5e06 100644 --- a/platform/src/os/windows/win32_window.rs +++ b/platform/src/os/windows/win32_window.rs @@ -10,7 +10,9 @@ use { droptarget::*, win32_app::{encode_wide, with_win32_app, Win32App}, win32_event::*, + win32_screen::{win32_screens, workspace_rect_to_screen}, }, + screen::{clamp_point_to_screens, fit_window_rect_to_screens}, window::{WindowBackdrop, WindowId, WindowVisuals}, windows::{ core::PCWSTR, @@ -88,6 +90,7 @@ use { WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_NCCALCSIZE, WM_NCHITTEST, WM_RBUTTONDOWN, WM_RBUTTONUP, WM_SIZE, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, + GetWindowPlacement, WINDOWPLACEMENT, WS_BORDER, WS_CAPTION, WS_CLIPCHILDREN, WS_CLIPSIBLINGS, WS_EX_ACCEPTFILES, WS_EX_APPWINDOW, WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_WINDOWEDGE, WS_OVERLAPPEDWINDOW, WS_POPUP, WS_THICKFRAME, @@ -108,6 +111,13 @@ use { }, }; +/// Whether a screen coordinate survives the conversion `CreateWindowExW` and `MoveWindow` +/// take: a real number inside `i32`, and not the `CW_USEDEFAULT` sentinel that `i32::MIN` +/// would be read as. +fn is_placeable(v: f64) -> bool { + v.is_finite() && v > i32::MIN as f64 && v < i32::MAX as f64 +} + #[repr(C)] struct AccentPolicy { accent_state: u32, @@ -203,6 +213,11 @@ pub struct Win32Window { /// Set by `close_window()`; suppresses the WM_ACTIVATE-derived /// `PopupDismissed(FocusLost)`, which would duplicate the closer's dismissal. pub is_closing: Cell, + /// Whether the window is inside the system's modal move/size loop, i.e. the user is + /// dragging it. `WM_MOVE` arrives per mouse step there, so the position is published once + /// on the way out rather than on every step; a programmatic move, which sets no such + /// state, publishes immediately. + pub in_size_move: Cell, pub ignore_wmsize: usize, pub hwnd: HWND, pub track_mouse_event: bool, @@ -518,10 +533,22 @@ impl Win32Window { let style_ex = WS_EX_WINDOWEDGE | WS_EX_APPWINDOW | WS_EX_ACCEPTFILES; - let (x, y) = if let Some(position) = position { - (position.x as i32, position.y as i32) - } else { - (CW_USEDEFAULT, CW_USEDEFAULT) + let (x, y) = match position { + // A restored position can name a display that is gone, or hold values no display + // ever had. Pinning it now keeps `CreateWindowExW` and the sizing that follows + // working on real coordinates; `init` fits the finished rectangle once the size is + // known. A coordinate still out of range after pinning means no display could be + // enumerated, so the system's own placement is used instead of a value that would + // saturate on the way to the API. + Some(position) => { + let pinned = clamp_point_to_screens(&win32_screens(), position); + if is_placeable(pinned.x) && is_placeable(pinned.y) { + (pinned.x as i32, pinned.y as i32) + } else { + (CW_USEDEFAULT, CW_USEDEFAULT) + } + } + None => (CW_USEDEFAULT, CW_USEDEFAULT), }; let hwnd = unsafe { @@ -567,6 +594,7 @@ impl Win32Window { nc_dq_gen: Cell::new(0), geom_event_gen: Cell::new(0), is_closing: Cell::new(false), + in_size_move: Cell::new(false), ignore_wmsize: 0, hwnd, track_mouse_event: false, @@ -621,6 +649,7 @@ impl Win32Window { nc_dq_gen: Cell::new(0), geom_event_gen: Cell::new(0), is_closing: Cell::new(false), + in_size_move: Cell::new(false), ignore_wmsize: 0, hwnd, track_mouse_event: false, @@ -649,6 +678,50 @@ impl Win32Window { self.set_inner_size(size); if self.is_fullscreen { self.maximize(); + } else if !self.is_popup { + // A restored size and position are only as good as the display layout they were + // saved on. Popups are placed against their parent and left alone; a maximized + // window is the system's to place. + self.fit_to_screens(); + } + } + + /// Moves and resizes the window so it sits entirely within one display's work area. + /// + /// See `crate::screen::fit_window_rect_to_screens` for what counts as a fit and why it + /// is unconditional. A window rectangle that already fits is left untouched, so this + /// costs one `GetWindowRect` and a display enumeration in the common case. + pub fn fit_to_screens(&mut self) { + let screens = win32_screens(); + if screens.is_empty() { + return; + } + let mut rect = RECT::default(); + if unsafe { GetWindowRect(self.hwnd, &mut rect) }.is_err() { + return; + } + let current = Rect { + pos: dvec2(rect.left as f64, rect.top as f64), + size: dvec2( + (rect.right - rect.left) as f64, + (rect.bottom - rect.top) as f64, + ), + }; + let fitted = fit_window_rect_to_screens(&screens, current); + if fitted == current { + return; + } + if let Err(e) = unsafe { + MoveWindow( + self.hwnd, + fitted.pos.x as i32, + fitted.pos.y as i32, + fitted.size.x as i32, + fitted.size.y as i32, + true, + ) + } { + crate::error!("Fitting the window into the visible screen area failed: {}", e); } } @@ -1014,12 +1087,25 @@ impl Win32Window { })); } WM_ENTERSIZEMOVE => { + window.in_size_move.set(true); with_win32_app(|app| app.start_resize()); window.do_callback(Win32Event::WindowResizeLoopStart(window.window_id)); } + // WM_CANCELMODE (0x001F): the system is telling the window to abandon any internal + // mode it is in. DefWindowProc normally still leaves the move/size loop through + // WM_EXITSIZEMOVE, so this is a failsafe: `in_size_move` is the only thing gating + // position publication, and a stuck `true` would silently stop it for the window's + // lifetime. + 0x001F => { + window.in_size_move.set(false); + } WM_EXITSIZEMOVE => { + window.in_size_move.set(false); with_win32_app(|app| app.stop_resize()); window.do_callback(Win32Event::WindowResizeLoopStop(window.window_id)); + // A drag that only moved the window produced no WM_SIZE, so this is the one + // chance to publish where it ended up. + window.send_move_event(); } // WM_SIZING (0x0214) fires BEFORE the window is resized with // the proposed new rect. By pre-rendering at this size, the @@ -1033,6 +1119,14 @@ impl Win32Window { // The window may have moved to a monitor with a different scale; drop the cached // DPI so send_change_event() (and subsequent hit-tests) re-read the new value. window.invalidate_cached_dpi(); + // Minimizing does not change the window's geometry, it parks it. Publishing the + // iconic rect would relayout the whole UI at zero size and poison whatever the + // app persists; `outer_rect` already answers from the restored placement, so + // there is nothing here worth reporting either. + const SIZE_MINIMIZED: usize = 1; + if wparam.0 == SIZE_MINIMIZED { + return LRESULT(0); + } window.send_change_event(); } WM_DPICHANGED => { @@ -1071,6 +1165,13 @@ impl Win32Window { 0x0003 => { window.nc_dq_cache.set(None); window.nc_dq_gen.set(window.nc_dq_gen.get().wrapping_add(1)); + // Publish the new position, or the window keeps reporting — and the app keeps + // persisting — where it used to be. A user drag is left to WM_EXITSIZEMOVE: + // this message arrives per mouse step, and each published geometry costs a + // full redraw on the Cx side. + if !window.in_size_move.get() { + window.send_move_event(); + } } WM_CLOSE => { // close requested @@ -1370,31 +1471,51 @@ impl Win32Window { self.ime_rect = rect; } - pub fn get_position(&self) -> Vec2d { + /// The window's outer rectangle in screen pixels, answered from the restored placement + /// while the window is minimized. + /// + /// A minimized window has no on-screen rectangle: `GetWindowRect` reports the off-screen + /// parking position `(-32000, -32000)` and `GetClientRect` a zero size. An app that + /// persists its geometry on shutdown would save those and restore, next launch, a window + /// it can neither see nor grab — so the restored placement the system keeps for exactly + /// this purpose is reported instead. + fn outer_rect(&self) -> RECT { unsafe { - let mut rect = RECT { - left: 0, - top: 0, - bottom: 0, - right: 0, - }; - GetWindowRect(self.hwnd, &mut rect).unwrap(); - Vec2d { - x: rect.left as f64, - y: rect.top as f64, + if self.is_iconic() { + let mut placement = WINDOWPLACEMENT { + length: mem::size_of::() as u32, + ..Default::default() + }; + if GetWindowPlacement(self.hwnd, &mut placement).is_ok() { + return workspace_rect_to_screen(placement.rcNormalPosition); + } } + let mut rect = RECT::default(); + GetWindowRect(self.hwnd, &mut rect).unwrap(); + rect + } + } + + /// The window's top-left corner in physical screen pixels; see [`Self::set_position`] + /// for why positions are not scaled the way sizes are. + pub fn get_position(&self) -> Vec2d { + let rect = self.outer_rect(); + Vec2d { + x: rect.left as f64, + y: rect.top as f64, } } pub fn get_inner_size(&self) -> Vec2d { unsafe { - let mut rect = RECT { - left: 0, - top: 0, - bottom: 0, - right: 0, - }; - GetClientRect(self.hwnd, &mut rect).unwrap(); + let mut rect = RECT::default(); + if self.is_iconic() { + // A restored window of this backend is fully client-sized (see the + // `WM_NCCALCSIZE` handler), so its outer rectangle is also its client size. + rect = self.outer_rect(); + } else { + GetClientRect(self.hwnd, &mut rect).unwrap(); + } let dpi = self.get_dpi_factor(); Vec2d { x: (rect.right - rect.left) as f64 / dpi, @@ -1404,22 +1525,19 @@ impl Win32Window { } pub fn get_outer_size(&self) -> Vec2d { - unsafe { - let mut rect = RECT { - left: 0, - top: 0, - bottom: 0, - right: 0, - }; - GetWindowRect(self.hwnd, &mut rect).unwrap(); - let dpi = self.get_dpi_factor(); - Vec2d { - x: (rect.right - rect.left) as f64 / dpi, - y: (rect.bottom - rect.top) as f64 / dpi, - } + let rect = self.outer_rect(); + let dpi = self.get_dpi_factor(); + Vec2d { + x: (rect.right - rect.left) as f64 / dpi, + y: (rect.bottom - rect.top) as f64 / dpi, } } + /// Moves the window's top-left corner to `pos`, in physical screen pixels — the same + /// space [`Self::get_position`] reports and `CreateWindowExW` takes, so + /// `set_position(get_position())` leaves the window where it is. Sizes are logical and + /// scale with the DPI; positions are not, because a screen coordinate on a multi-monitor + /// desktop has no single scale factor to be logical in. pub fn set_position(&mut self, pos: Vec2d) { unsafe { let mut window_rect = RECT { @@ -1429,13 +1547,23 @@ impl Win32Window { right: 0, }; GetWindowRect(self.hwnd, &mut window_rect).unwrap(); - let dpi = self.get_dpi_factor(); + // A caller placing the window — restoring a saved position, cascading a new + // window — cannot know the display layout it is placing into, so the request is + // fitted to the displays that are actually attached. + let want = Rect { + pos, + size: dvec2( + (window_rect.right - window_rect.left) as f64, + (window_rect.bottom - window_rect.top) as f64, + ), + }; + let fitted = fit_window_rect_to_screens(&win32_screens(), want); MoveWindow( self.hwnd, - (pos.x * dpi) as i32, - (pos.y * dpi) as i32, - window_rect.right - window_rect.left, - window_rect.bottom - window_rect.top, + fitted.pos.x as i32, + fitted.pos.y as i32, + fitted.size.x as i32, + fitted.size.y as i32, false, ) .unwrap(); @@ -1595,6 +1723,27 @@ impl Win32Window { Win32App::do_callback(event); } + /// Publishes a position-only geometry change. + /// + /// Moving a window does not change what it draws, so unlike [`Self::send_change_event`] + /// this asks for no repaint; it only keeps the published geometry — which is what an app + /// persists — in step with where the window actually is. Nothing is dispatched when the + /// geometry is unchanged, which is also what makes this safe to call for a minimize, + /// where `outer_rect` keeps answering from the restored placement. + pub fn send_move_event(&mut self) { + let new_geom = self.get_window_geom(); + if new_geom == self.last_window_geom { + return; + } + let old_geom = std::mem::replace(&mut self.last_window_geom, new_geom.clone()); + self.geom_event_gen.set(self.geom_event_gen.get().wrapping_add(1)); + self.do_callback(Win32Event::WindowGeomChange(WindowGeomChangeEvent { + window_id: self.window_id, + old_geom, + new_geom, + })); + } + pub fn send_change_event(&mut self) { // Record that a geometry event is published (see `geom_event_gen`). self.geom_event_gen.set(self.geom_event_gen.get().wrapping_add(1)); diff --git a/platform/src/os/windows/windows.rs b/platform/src/os/windows/windows.rs index c48dfbb63..d48f92f82 100644 --- a/platform/src/os/windows/windows.rs +++ b/platform/src/os/windows/windows.rs @@ -758,11 +758,12 @@ impl Cx { match op { CxOsOp::CreateWindow(window_id) => { let window = &mut self.windows[window_id]; + let (create_position, create_inner_size) = window.create_geom(); let d3d11_window = D3d11Window::new( window_id, &d3d11_cx, - window.create_inner_size.unwrap_or(dvec2(800., 600.)), - window.create_position, + create_inner_size, + create_position, &window.create_title, window.is_fullscreen, ); diff --git a/platform/src/screen.rs b/platform/src/screen.rs new file mode 100644 index 000000000..779ab80a3 --- /dev/null +++ b/platform/src/screen.rs @@ -0,0 +1,497 @@ +//! Display geometry, and the policy that keeps a window inside it. + +use crate::makepad_math::*; + +/// The smallest window extent a fit ever produces. Small enough to leave a deliberately +/// compact tool window alone, large enough that the window still has a title bar to grab. +pub const MIN_WINDOW_SIZE: Vec2d = Vec2d { x: 200.0, y: 120.0 }; + +/// One display attached to the system. +/// +/// The rectangles are in the same coordinate space as the platform's window-position API, +/// so a backend must build them from the same system calls it positions windows with: +/// physical pixels with a top-left origin on Windows and X11, points with Cocoa's +/// bottom-left origin on macOS. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScreenGeom { + /// The display's full extent. + pub bounds: Rect, + /// The extent left over once the system reserves its own space — the Windows taskbar, + /// the macOS menu bar and Dock, X11 struts. Windows are placed inside this. + pub work_area: Rect, + /// Whether this is the system's primary display. + pub is_primary: bool, +} + +/// Area shared by two rectangles; zero when they do not overlap. +fn overlap_area(a: Rect, b: Rect) -> f64 { + let w = (a.pos.x + a.size.x).min(b.pos.x + b.size.x) - a.pos.x.max(b.pos.x); + let h = (a.pos.y + a.size.y).min(b.pos.y + b.size.y) - a.pos.y.max(b.pos.y); + if w <= 0.0 || h <= 0.0 { + 0.0 + } else { + w * h + } +} + +/// Squared distance between two rectangles' centres. +fn center_distance_sq(a: Rect, b: Rect) -> f64 { + let d = a.center() - b.center(); + d.x * d.x + d.y * d.y +} + +/// The parts of `a` that `b` does not cover, as up to four rectangles. +fn subtract(a: Rect, b: Rect) -> Vec { + if overlap_area(a, b) <= 0.0 { + return vec![a]; + } + let (ax0, ay0) = (a.pos.x, a.pos.y); + let (ax1, ay1) = (a.pos.x + a.size.x, a.pos.y + a.size.y); + let bx0 = b.pos.x.max(ax0); + let by0 = b.pos.y.max(ay0); + let bx1 = (b.pos.x + b.size.x).min(ax1); + let by1 = (b.pos.y + b.size.y).min(ay1); + let mut out = Vec::new(); + let mut push = |x0: f64, y0: f64, x1: f64, y1: f64| { + if x1 > x0 && y1 > y0 { + out.push(Rect { + pos: dvec2(x0, y0), + size: dvec2(x1 - x0, y1 - y0), + }); + } + }; + push(ax0, ay0, ax1, by0); + push(ax0, by1, ax1, ay1); + push(ax0, by0, bx0, by1); + push(bx1, by0, ax1, by1); + out +} + +/// Whether `r` lies entirely within the union of `areas`, which may be several displays +/// covering it between them. +fn is_covered_by(areas: &[Rect], r: Rect) -> bool { + if !is_usable(r) { + return false; + } + let mut remaining = vec![r]; + for area in areas { + let mut next = Vec::new(); + for piece in remaining.drain(..) { + next.extend(subtract(piece, *area)); + } + if next.is_empty() { + return true; + } + remaining = next; + } + remaining.is_empty() +} + +/// Whether a rectangle is usable as a destination: real numbers, and some area to put a +/// window in. +fn is_usable(r: Rect) -> bool { + r.pos.x.is_finite() + && r.pos.y.is_finite() + && r.size.x.is_finite() + && r.size.y.is_finite() + && r.size.x > 0.0 + && r.size.y > 0.0 +} + +/// Fits a window's outer rectangle inside the work area of the display it belongs to. +/// +/// Every window position an app restores has to survive a display layout that may have +/// changed completely since it was written: the display the window sat on can be gone, a +/// docked laptop can be back on a smaller built-in panel, and a state file saved while the +/// window was minimized holds coordinates no display ever had — Win32 reports position +/// `(-32000, -32000)` and a zero-sized client area for a minimized window, and an app that +/// persists that on shutdown restores a window it cannot see or grab on the next launch, +/// with no way back short of deleting the file. Fitting therefore applies to every +/// placement rather than only to values that look wrong. +/// +/// A window that is already wholly on the desktop is returned untouched, including one +/// deliberately spanning two adjacent displays — the point is to rescue placements that +/// cannot be reached, not to enforce one window per display. Anything else moves onto the +/// display it overlaps most, or, when it overlaps none, the display nearest its centre; its +/// size is capped to that work area and floored at [`MIN_WINDOW_SIZE`], and its position is +/// pulled in until the whole window is visible. +/// +/// An empty `screens` means the backend cannot enumerate displays — Wayland, where a client +/// is not allowed to know or choose where its windows go — and `window` is returned as-is. +pub fn fit_window_rect_to_screens(screens: &[ScreenGeom], window: Rect) -> Rect { + let usable: Vec = screens + .iter() + .map(|s| s.work_area) + .filter(|r| is_usable(*r)) + .collect(); + let Some(&first) = usable.first() else { + return window; + }; + let primary = screens + .iter() + .find(|s| s.is_primary && is_usable(s.work_area)) + .map_or(first, |s| s.work_area); + + // Coordinates that are not real numbers cannot be compared or clamped, so they name no + // display and get the primary's geometry to start from. + let mut want = window; + if !want.size.x.is_finite() || !want.size.y.is_finite() { + want.size = primary.size * 0.5; + } + if !want.pos.x.is_finite() || !want.pos.y.is_finite() { + want.pos = primary.pos; + } + + // A window already wholly on the desktop is left exactly where it is, including one + // deliberately spanning two adjacent displays. Fitting exists to rescue a placement that + // cannot be reached, not to enforce one window per display. + if is_covered_by(&usable, want) { + return want; + } + + let area = usable + .iter() + .copied() + .max_by(|a, b| { + let (oa, ob) = (overlap_area(*a, want), overlap_area(*b, want)); + oa.total_cmp(&ob).then_with(|| { + // No overlap anywhere leaves every candidate tied at zero; nearest centre + // breaks the tie, so a window off the right edge lands on the right display. + center_distance_sq(*b, want).total_cmp(¢er_distance_sq(*a, want)) + }) + }) + .unwrap_or(primary); + + let size = dvec2( + want.size.x.clamp(MIN_WINDOW_SIZE.x.min(area.size.x), area.size.x), + want.size.y.clamp(MIN_WINDOW_SIZE.y.min(area.size.y), area.size.y), + ); + let pos = dvec2( + want.pos.x.clamp(area.pos.x, area.pos.x + area.size.x - size.x), + want.pos.y.clamp(area.pos.y, area.pos.y + area.size.y - size.y), + ); + Rect { pos, size } +} + +/// Clamps a point into the work area of the display nearest to it, leaving room for a +/// window of at least [`MIN_WINDOW_SIZE`] to be visible from there. +/// +/// A window origin can break creation on its own, before there is a finished rectangle to +/// fit: a coordinate out of the platform's integer range saturates when it reaches the +/// system call, and the sizing that follows is done relative to wherever the window landed. +/// Backends pin the origin through here first and fit the finished rectangle afterwards. +/// +/// An empty `screens` returns the point unchanged, for the same reason +/// [`fit_window_rect_to_screens`] does. +pub fn clamp_point_to_screens(screens: &[ScreenGeom], point: Vec2d) -> Vec2d { + fit_window_rect_to_screens( + screens, + Rect { + pos: point, + size: dvec2(0.0, 0.0), + }, + ) + .pos +} + +/// The size a window falls back to when the requested one carries no usable information. +pub const DEFAULT_WINDOW_SIZE: Vec2d = Vec2d { x: 800.0, y: 600.0 }; + +/// Reduces a requested window size and position to values a windowing system can act on, +/// without needing to know anything about the attached displays. +/// +/// This is the guard that has to hold everywhere, including the backends +/// [`fit_window_rect_to_screens`] cannot help: Wayland enumerates no displays for a client +/// and passes the size straight to `wl_egl_window_create`, which rejects a non-positive one; +/// X11 encodes width and height as unsigned 16-bit and answers a zero with a protocol error +/// that terminates the process by default. A saved `0`, a negative, or a `NaN` — all of which +/// a JSON state file can hold, and which `as i32` quietly turns into `0` — must therefore +/// never leave this function. Position is dropped rather than corrected when it is not a real +/// number: `None` means "the system places this window", which is always a safe answer. +pub fn sanitize_window_geom(position: Option, size: Vec2d) -> (Option, Vec2d) { + // A non-positive extent carries no information about how big the window should be — it is + // what a zeroed, truncated or minimized-window state file holds — so it gets the default + // rather than the floor, which would restore a technically-visible 200x120 sliver. A small + // positive size is a real request and is only raised to something grabbable. + let size = if size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0 { + dvec2( + size.x.max(MIN_WINDOW_SIZE.x), + size.y.max(MIN_WINDOW_SIZE.y), + ) + } else { + DEFAULT_WINDOW_SIZE + }; + let position = position.filter(|p| p.x.is_finite() && p.y.is_finite()); + (position, size) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn screen(x: f64, y: f64, w: f64, h: f64, is_primary: bool) -> ScreenGeom { + let bounds = rect(x, y, w, h); + ScreenGeom { + bounds, + work_area: bounds, + is_primary, + } + } + + fn rect(x: f64, y: f64, w: f64, h: f64) -> Rect { + Rect { + pos: dvec2(x, y), + size: dvec2(w, h), + } + } + + #[test] + fn a_window_already_inside_a_display_is_left_alone() { + let screens = [screen(0.0, 0.0, 1920.0, 1080.0, true)]; + let want = rect(100.0, 100.0, 800.0, 600.0); + assert_eq!(fit_window_rect_to_screens(&screens, want), want); + } + + #[test] + fn no_screens_leaves_the_request_untouched() { + let want = rect(-32000.0, -32000.0, 0.0, 0.0); + assert_eq!(fit_window_rect_to_screens(&[], want), want); + } + + #[test] + fn the_win32_minimized_sentinel_comes_back_onto_the_primary_display() { + let screens = [screen(0.0, 0.0, 1920.0, 1080.0, true)]; + let fitted = fit_window_rect_to_screens(&screens, rect(-32000.0, -32000.0, 0.0, 0.0)); + assert_eq!(fitted.pos, dvec2(0.0, 0.0)); + assert_eq!(fitted.size, MIN_WINDOW_SIZE); + } + + #[test] + fn a_window_past_the_right_edge_is_pulled_back_in() { + let screens = [screen(0.0, 0.0, 1920.0, 1080.0, true)]; + let fitted = fit_window_rect_to_screens(&screens, rect(1900.0, 50.0, 800.0, 600.0)); + assert_eq!(fitted, rect(1120.0, 50.0, 800.0, 600.0)); + } + + #[test] + fn a_window_larger_than_the_work_area_is_capped_to_it() { + let screens = [ScreenGeom { + bounds: rect(0.0, 0.0, 1920.0, 1080.0), + work_area: rect(0.0, 0.0, 1920.0, 1040.0), + is_primary: true, + }]; + let fitted = fit_window_rect_to_screens(&screens, rect(-500.0, -500.0, 4000.0, 4000.0)); + assert_eq!(fitted, rect(0.0, 0.0, 1920.0, 1040.0)); + } + + #[test] + fn a_window_keeps_the_secondary_display_it_sits_on() { + let screens = [ + screen(0.0, 0.0, 1920.0, 1080.0, true), + screen(1920.0, 0.0, 2560.0, 1440.0, false), + ]; + let want = rect(2000.0, 200.0, 800.0, 600.0); + assert_eq!(fit_window_rect_to_screens(&screens, want), want); + } + + #[test] + fn a_window_on_a_display_that_is_gone_moves_to_the_nearest_one() { + // The secondary display it was saved on is no longer attached. + let screens = [screen(0.0, 0.0, 1920.0, 1080.0, true)]; + let fitted = fit_window_rect_to_screens(&screens, rect(3000.0, 200.0, 800.0, 600.0)); + assert_eq!(fitted, rect(1120.0, 200.0, 800.0, 600.0)); + } + + #[test] + fn a_window_spanning_two_adjacent_displays_is_left_alone() { + let screens = [ + screen(0.0, 0.0, 1920.0, 1080.0, true), + screen(1920.0, 0.0, 1920.0, 1080.0, false), + ]; + // The window straddles the seam but every pixel of it is on a display. + let want = rect(1720.0, 100.0, 800.0, 600.0); + assert_eq!(fit_window_rect_to_screens(&screens, want), want); + } + + #[test] + fn a_window_over_a_gap_between_displays_moves_to_the_one_holding_most_of_it() { + // Displays side by side with a gap between them, as a mismatched pair produces. + let screens = [ + screen(0.0, 0.0, 1920.0, 1080.0, true), + screen(2400.0, 0.0, 1920.0, 1080.0, false), + ]; + let fitted = fit_window_rect_to_screens(&screens, rect(1800.0, 100.0, 800.0, 600.0)); + assert_eq!(fitted, rect(2400.0, 100.0, 800.0, 600.0)); + } + + #[test] + fn a_window_hanging_off_the_end_of_the_arrangement_is_pulled_in() { + let screens = [ + screen(0.0, 0.0, 1920.0, 1080.0, true), + screen(1920.0, 0.0, 1920.0, 1080.0, false), + ]; + let fitted = fit_window_rect_to_screens(&screens, rect(3600.0, 100.0, 800.0, 600.0)); + assert_eq!(fitted, rect(3040.0, 100.0, 800.0, 600.0)); + } + + #[test] + fn a_window_spanning_displays_of_different_heights_is_not_left_hanging() { + // The taller display sits lower, so the strip below the shorter one is off-desktop. + let screens = [ + screen(0.0, 0.0, 1920.0, 1080.0, true), + screen(1920.0, 0.0, 1920.0, 1440.0, false), + ]; + let fitted = fit_window_rect_to_screens(&screens, rect(1600.0, 900.0, 800.0, 400.0)); + assert!(fitted != rect(1600.0, 900.0, 800.0, 400.0)); + assert!(screens.iter().any(|s| fitted.is_inside_of(s.work_area))); + } + + #[test] + fn non_finite_geometry_falls_back_to_the_primary_display() { + let screens = [ + screen(-1920.0, 0.0, 1920.0, 1080.0, false), + screen(0.0, 0.0, 1920.0, 1080.0, true), + ]; + let fitted = + fit_window_rect_to_screens(&screens, rect(f64::NAN, f64::INFINITY, f64::NAN, 600.0)); + assert_eq!(fitted, rect(0.0, 0.0, 960.0, 540.0)); + } + + #[test] + fn cocoa_bottom_left_coordinates_fit_the_same_way() { + // macOS reports the primary display at the origin with y growing upwards; a window + // saved below the display comes back inside it. + let screens = [ScreenGeom { + bounds: rect(0.0, 0.0, 1728.0, 1117.0), + work_area: rect(0.0, 76.0, 1728.0, 1004.0), + is_primary: true, + }]; + let fitted = fit_window_rect_to_screens(&screens, rect(20.0, -400.0, 900.0, 700.0)); + assert_eq!(fitted, rect(20.0, 76.0, 900.0, 700.0)); + } + + #[test] + fn a_display_smaller_than_the_minimum_size_still_fits_a_window() { + let screens = [screen(0.0, 0.0, 100.0, 60.0, true)]; + let fitted = fit_window_rect_to_screens(&screens, rect(500.0, 500.0, 800.0, 600.0)); + assert_eq!(fitted, rect(0.0, 0.0, 100.0, 60.0)); + } + + #[test] + fn a_negative_size_is_raised_to_the_minimum() { + let screens = [screen(0.0, 0.0, 1920.0, 1080.0, true)]; + let fitted = fit_window_rect_to_screens(&screens, rect(10.0, 10.0, -800.0, -600.0)); + assert_eq!(fitted, rect(10.0, 10.0, MIN_WINDOW_SIZE.x, MIN_WINDOW_SIZE.y)); + } + + #[test] + fn coordinates_far_outside_the_integer_range_land_on_a_display() { + let screens = [screen(0.0, 0.0, 1920.0, 1080.0, true)]; + for want in [ + rect(1e300, 1e300, 800.0, 600.0), + rect(-1e300, -1e300, 800.0, 600.0), + rect(f64::MAX, f64::MIN, f64::MAX, f64::MAX), + ] { + let fitted = fit_window_rect_to_screens(&screens, want); + assert!(fitted.is_inside_of(screens[0].work_area), "{fitted:?}"); + } + } + + #[test] + fn every_fitted_rectangle_lies_within_some_work_area() { + let screens = [ + screen(0.0, 0.0, 1920.0, 1080.0, true), + screen(1920.0, -200.0, 2560.0, 1440.0, false), + ]; + for want in [ + rect(-32000.0, -32000.0, 0.0, 0.0), + rect(f64::NAN, f64::NAN, f64::NAN, f64::NAN), + rect(f64::INFINITY, f64::NEG_INFINITY, 1e12, -1e12), + rect(1e9, 1e9, 1e9, 1e9), + rect(4400.0, 1100.0, 300.0, 200.0), + rect(0.0, 0.0, 0.0, 0.0), + ] { + let fitted = fit_window_rect_to_screens(&screens, want); + let areas: Vec = screens.iter().map(|s| s.work_area).collect(); + assert!(is_covered_by(&areas, fitted), "{want:?} fitted to {fitted:?}"); + assert!(fitted.pos.x.is_finite() && fitted.pos.y.is_finite()); + assert!(fitted.size.x > 0.0 && fitted.size.y > 0.0); + } + } + + #[test] + fn sanitizing_rejects_every_size_a_windowing_system_cannot_use() { + for bad in [ + dvec2(0.0, 0.0), + dvec2(-800.0, -600.0), + dvec2(f64::NAN, f64::NAN), + dvec2(f64::INFINITY, 600.0), + dvec2(1.0, 1.0), + ] { + let (_, size) = sanitize_window_geom(None, bad); + assert!(size.x >= MIN_WINDOW_SIZE.x && size.y >= MIN_WINDOW_SIZE.y, "{bad:?}"); + assert!(size.x.is_finite() && size.y.is_finite(), "{bad:?}"); + } + } + + #[test] + fn a_size_carrying_no_information_becomes_the_default_not_the_floor() { + // Restoring a 200x120 sliver from a zeroed state file is visible but useless. + for empty in [ + dvec2(0.0, 0.0), + dvec2(-800.0, -600.0), + dvec2(0.0, 800.0), + dvec2(f64::NAN, f64::NAN), + ] { + assert_eq!(sanitize_window_geom(None, empty).1, DEFAULT_WINDOW_SIZE, "{empty:?}"); + } + // A small but real request is only raised to something grabbable. + assert_eq!( + sanitize_window_geom(None, dvec2(50.0, 40.0)).1, + MIN_WINDOW_SIZE + ); + } + + #[test] + fn sanitizing_keeps_a_usable_request_intact() { + let (pos, size) = sanitize_window_geom(Some(dvec2(-1200.0, 40.0)), dvec2(1280.0, 800.0)); + // A position on a left-hand secondary display is legitimate and is not a size problem, + // so it survives untouched; fitting to the displays is a separate, later step. + assert_eq!(pos, Some(dvec2(-1200.0, 40.0))); + assert_eq!(size, dvec2(1280.0, 800.0)); + } + + #[test] + fn sanitizing_drops_a_position_that_is_not_a_real_number() { + assert_eq!( + sanitize_window_geom(Some(dvec2(f64::NAN, 0.0)), dvec2(800.0, 600.0)).0, + None + ); + assert_eq!( + sanitize_window_geom(Some(dvec2(0.0, f64::INFINITY)), dvec2(800.0, 600.0)).0, + None + ); + } + + #[test] + fn a_clamped_point_leaves_a_minimum_window_visible() { + let screens = [ScreenGeom { + bounds: rect(0.0, 0.0, 1920.0, 1080.0), + work_area: rect(0.0, 0.0, 1920.0, 1040.0), + is_primary: true, + }]; + assert_eq!( + clamp_point_to_screens(&screens, dvec2(-32000.0, -32000.0)), + dvec2(0.0, 0.0) + ); + assert_eq!( + clamp_point_to_screens(&screens, dvec2(1e9, 1e9)), + dvec2(1920.0 - MIN_WINDOW_SIZE.x, 1040.0 - MIN_WINDOW_SIZE.y) + ); + assert_eq!( + clamp_point_to_screens(&screens, dvec2(f64::NAN, 5.0)), + dvec2(0.0, 0.0) + ); + assert_eq!(clamp_point_to_screens(&screens, dvec2(40.0, 50.0)), dvec2(40.0, 50.0)); + } +} diff --git a/platform/src/window.rs b/platform/src/window.rs index 8deac7523..0ad46c9dc 100644 --- a/platform/src/window.rs +++ b/platform/src/window.rs @@ -8,6 +8,7 @@ use crate::{ makepad_math::*, //makepad_live_id::*, makepad_script::*, + screen::{sanitize_window_geom, DEFAULT_WINDOW_SIZE}, script::vm::*, }; @@ -513,6 +514,8 @@ impl WindowHandle { cx.windows[self.window_id()].get_inner_size() } + /// The window's top-left corner, in the space [`Self::reposition`] accepts: physical + /// screen pixels on Windows and X11, points on macOS. Never scaled by the DPI factor. pub fn get_position(&self, cx: &Cx) -> Vec2d { cx.windows[self.window_id()].get_position() } @@ -608,6 +611,12 @@ impl WindowHandle { cx.push_unique_platform_op(CxOsOp::ResizeWindow(self.window_id(), size)); } + /// Moves the window's top-left corner to `position`, in the same space + /// [`Self::get_position`] reports: physical screen pixels on Windows and X11, points on + /// macOS. Unlike [`Self::resize`], which takes a logical size that scales with the DPI, a + /// position is never scaled — a screen coordinate spanning displays of different scales + /// has no single factor to be logical in. Backends fit the request to the displays that + /// are actually attached, so a window cannot be placed where it could not be reached. pub fn reposition(&self, cx: &mut Cx, position: Vec2d) { cx.push_unique_platform_op(CxOsOp::RepositionWindow(self.window_id(), position)); } @@ -694,6 +703,22 @@ impl Default for CxWindow { } impl CxWindow { + /// The geometry to create this window with, reduced to values a windowing system can act + /// on: a size no smaller than [`crate::screen::MIN_WINDOW_SIZE`], and a position that is + /// either real coordinates or `None` for "the system places it". + /// + /// Every backend reads its creation geometry through here, so no request — a restored + /// state file, a DSL literal, a computed popup rect — can reach a platform call carrying a + /// size it will reject or a coordinate that is not a number. Placing the window on a + /// display that exists is a separate, per-backend step; see + /// [`crate::screen::fit_window_rect_to_screens`]. + pub fn create_geom(&self) -> (Option, Vec2d) { + sanitize_window_geom( + self.create_position, + self.create_inner_size.unwrap_or(DEFAULT_WINDOW_SIZE), + ) + } + pub(crate) fn valid_dpi_factor(dpi_factor: f64) -> Option { if dpi_factor.is_finite() && dpi_factor > 0.0 { Some(dpi_factor) From db2f5e18e95175cfd732b3ddac4e0b973077eab1 Mon Sep 17 00:00:00 2001 From: andodeki Date: Sun, 16 Aug 2026 14:36:10 +0300 Subject: [PATCH 03/11] feat(test): Android makepad_test via adb + in-process hub (legacy Java path) Adds the Android test runtime to makepad_test: builds the APK with cargo-makepad's standard Java path, installs and launches via adb with makepad.STUDIO_* intent extras (incl. STUDIO_BUILD), connects the app to an in-process hub over adb reverse, and waits for startup + responsiveness. Adds clean in-process hub shutdown (HttpServerHandle + GatewayHandle Drop) and the STUDIO_BUILD intent parsing on the app side. No native-activity or NDK APK compilation code is included. --- examples/counter/src/main.rs | 23 +- libs/makepad_test/src/runtime.rs | 367 ++++++++++++++++++- platform/network/src/http_server.rs | 45 ++- platform/network/src/lib.rs | 2 +- platform/network/src/runtime.rs | 4 +- platform/src/os/linux/android/android_jni.rs | 12 + studio/hub/Cargo.toml | 4 + studio/hub/src/bin/hub_server.rs | 38 ++ studio/hub/src/gateway.rs | 30 +- tools/web_server/src/main.rs | 2 +- 10 files changed, 492 insertions(+), 35 deletions(-) create mode 100644 studio/hub/src/bin/hub_server.rs diff --git a/examples/counter/src/main.rs b/examples/counter/src/main.rs index 1e63fa5d5..449f5b598 100644 --- a/examples/counter/src/main.rs +++ b/examples/counter/src/main.rs @@ -6,15 +6,8 @@ app_main!(App); script_mod! { use mod.prelude.widgets.* - let state = { - counter: 0 - } - mod.state = state startup() do #(App::script_component(vm)){ ui: Root{ - on_startup:||{ // right now render isnt called automatically yet - ui.main_view.render() - } main_window := Window{ window.inner_size: vec2(420, 220) body +: { @@ -24,11 +17,9 @@ script_mod! { flow: Down spacing: 12 align: Center - on_render: ||{ - counter_label := Label{ - text: "Count: " + state.counter - draw_text.text_style.font_size: 24 - } + counter_label := Label{ + text: "Count: 0" + draw_text.text_style.font_size: 24 } } increment_button := Button{ @@ -44,15 +35,15 @@ script_mod! { pub struct App { #[live] ui: WidgetRef, + #[rust] + counter: i32, } impl MatchEvent for App { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { if self.ui.button(cx, ids!(increment_button)).clicked(actions) { - script_eval!(cx,{ - mod.state.counter += 1 - ui.main_view.render() - }); + self.counter += 1; + self.ui.label(cx, ids!(counter_label)).set_text(cx, &format!("Count: {}", self.counter)); } } } diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index 13c28ed75..ecad4edbf 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -85,6 +85,11 @@ pub struct TestConfig { pub startup_pause: Duration, pub action_delay: Duration, pub keep_open: Duration, + pub android: bool, + pub device_serial: Option, + pub adb_path: Option, + pub android_port: u16, + pub android_native_activity: bool, } impl TestConfig { @@ -126,6 +131,11 @@ impl TestConfig { startup_pause: env_duration_ms("MAKEPAD_TEST_STARTUP_DELAY_MS"), action_delay: env_duration_ms("MAKEPAD_TEST_ACTION_DELAY_MS"), keep_open: env_duration_ms("MAKEPAD_TEST_KEEP_OPEN_MS"), + android: android_test_enabled(), + device_serial: android_device_serial(), + adb_path: android_adb_path(), + android_port: android_hub_port(), + android_native_activity: env_truthy("MAKEPAD_TEST_NATIVE_ACTIVITY"), }) } @@ -157,6 +167,13 @@ impl TestConnection { Self::Remote(connection) => connection.recv_timeout(timeout), } } + + fn studio_addr(&self) -> Option { + match self { + Self::InProcess(connection) => connection.studio_addr(), + Self::Remote(_) => None, + } + } } struct TestAppInner { @@ -208,7 +225,9 @@ impl TestApp { } fn start_once(config: TestConfig) -> TestResult { - let (connection, build_id) = if visible_mode_enabled() { + let (connection, build_id) = if config.android { + start_android_app(&config)? + } else if visible_mode_enabled() { start_visible_app(&config)? } else { start_headless_app(&config)? @@ -623,6 +642,10 @@ impl TestApp { if inner.build_stopped.is_some() { return; } + if inner.config.android { + let full_package = android_full_package_name(&inner.config.package_name); + let _ = adb_force_stop(&inner.config, &full_package); + } let build_id = inner.build_id; let _ = inner.connection.send(ClientToHub::ClearBuild { build_id }); } @@ -1537,6 +1560,348 @@ fn env_duration_ms(name: &str) -> Duration { .unwrap_or(Duration::ZERO) } +// --------------------------------------------------------------------------- +// Android test helpers +// --------------------------------------------------------------------------- + +const DEFAULT_ANDROID_PORT: u16 = 8001; +const ANDROID_STARTUP_TIMEOUT: Duration = Duration::from_secs(120); +const ANDROID_LAUNCH_TIMEOUT: Duration = Duration::from_secs(60); + +fn android_test_enabled() -> bool { + env_truthy("MAKEPAD_TEST_ANDROID") +} + +fn android_device_serial() -> Option { + std::env::var("MAKEPAD_TEST_DEVICE") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn android_hub_port() -> u16 { + std::env::var("MAKEPAD_TEST_ANDROID_PORT") + .ok() + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(DEFAULT_ANDROID_PORT) +} + +fn android_adb_path() -> Option { + std::env::var("MAKEPAD_TEST_ADB") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn adb_command(config: &TestConfig) -> std::process::Command { + let adb = config + .adb_path + .as_deref() + .unwrap_or("adb"); + let mut cmd = std::process::Command::new(adb); + if let Some(ref serial) = config.device_serial { + cmd.arg("-s").arg(serial); + } + cmd +} + +fn adb_exec(config: &TestConfig, args: &[&str]) -> TestResult { + let output = adb_command(config) + .args(args) + .output() + .map_err(|err| TestError::new(format!("failed to run adb: {err}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(TestError::new(format!( + "adb {} failed: {}", + args.join(" "), + stderr.trim() + ))); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn android_full_package_name(package_name: &str) -> String { + let underscore = package_name.replace('-', "_"); + format!("dev.makepad.{underscore}") +} + +fn adb_forward(config: &TestConfig, port: u16) -> TestResult<()> { + let _ = adb_exec(config, &["reverse", "--remove", &format!("tcp:{port}")]); + adb_exec( + config, + &["reverse", &format!("tcp:{port}"), &format!("tcp:{port}")], + )?; + Ok(()) +} + +fn adb_install(config: &TestConfig, apk_path: &std::path::Path) -> TestResult<()> { + let output = adb_command(config) + .args(["install", "-r"]) + .arg(apk_path) + .output() + .map_err(|err| TestError::new(format!("failed to run adb install: {err}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(TestError::new(format!("adb install failed: {}", stderr.trim()))); + } + Ok(()) +} + +fn adb_launch( + config: &TestConfig, + package: &str, + build_id: u64, + crate_name: &str, + port: u16, +) -> TestResult<()> { + // Native-activity builds launch `android.app.NativeActivity` directly (no + // Java Activity). Legacy Java builds launch the generated `MakepadApp` + // subclass that bridges into `MakepadNative.activityOnCreate`. + let activity = if config.android_native_activity { + format!("{package}/android.app.NativeActivity") + } else { + format!("{package}/.MakepadApp") + }; + let studio_host = format!("127.0.0.1:{port}"); + let output = adb_command(config) + .args([ + "shell", "am", "start", + "-n", &activity, + "-e", "makepad.STUDIO_HOST", &studio_host, + "-e", "makepad.STUDIO_BUILD", &build_id.to_string(), + "-e", "makepad.STUDIO_CRATE", crate_name, + ]) + .output() + .map_err(|err| TestError::new(format!("failed to run adb shell am start: {err}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(TestError::new(format!("adb launch failed: {}", stderr.trim()))); + } + Ok(()) +} + +fn adb_force_stop(config: &TestConfig, package: &str) -> TestResult<()> { + let _ = adb_exec(config, &["shell", "am", "force-stop", package]); + Ok(()) +} + +fn resolve_workspace_root(manifest_dir: &std::path::Path) -> std::path::PathBuf { + if let Ok(env_root) = std::env::var("MAKEPAD_WORKSPACE_ROOT") { + let root = std::path::PathBuf::from(env_root); + if root.join("Cargo.toml").exists() { + return root; + } + } + let mut dir = manifest_dir.to_path_buf(); + loop { + let cargo_toml = dir.join("Cargo.toml"); + if cargo_toml.exists() { + if let Ok(content) = std::fs::read_to_string(&cargo_toml) { + if content.contains("tools/cargo_makepad") { + // skip Cargo.toml files that reference the tool as a path + } + let has_workspace = content.contains("[workspace]") + || content.contains("workspace.members") + || content.contains("workspace.package"); + if has_workspace && dir.join("tools/cargo_makepad").exists() { + return dir; + } + } + } + if !dir.pop() { + break; + } + } + manifest_dir.to_path_buf() +} + +fn build_android_apk(config: &TestConfig) -> TestResult { + let workspace_root = resolve_workspace_root(&config.manifest_dir); + let cargo_makepad = workspace_root + .join("target") + .join("release") + .join("cargo-makepad"); + if !cargo_makepad.exists() { + return Err(TestError::new(format!( + "cargo-makepad not found at {}. Run: cargo build --release -p cargo-makepad", + cargo_makepad.display() + ))); + } + let mut args = vec!["android"]; + if config.android_native_activity { + args.push("--native-activity"); + } + args.extend_from_slice(&["build", "-p", &config.package_name]); + let output = std::process::Command::new(&cargo_makepad) + .args(&args) + .current_dir(&workspace_root) + .output() + .map_err(|err| TestError::new(format!("failed to run cargo makepad: {err}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(TestError::new(format!( + "android build failed:\nstdout: {}\nstderr: {}", + stdout.trim(), + stderr.trim() + ))); + } + let apk_dir_name = config.package_name.replace('-', "_"); + let apk_dir = workspace_root + .join("target") + .join("android") + .join("makepad-android-apk") + .join(&apk_dir_name) + .join("apk"); + let apk_name = format!("{}.apk", &apk_dir_name); + let apk_path = apk_dir.join(&apk_name); + if !apk_path.exists() { + return Err(TestError::new(format!( + "APK not found at {}", + apk_path.display() + ))); + } + Ok(apk_path) +} + +fn wait_for_android_app_started( + connection: &TestConnection, + build_id: QueryId, + timeout: Duration, +) -> TestResult<()> { + let deadline = Instant::now() + timeout; + loop { + if Instant::now() >= deadline { + return Err(TestError::new( + "timed out waiting for Android app to connect to hub", + )); + } + let slice = cmp::min( + POLL_INTERVAL, + deadline.saturating_duration_since(Instant::now()), + ); + let Some(msg) = connection.recv_timeout(slice) else { + continue; + }; + match msg { + HubToClient::AppStarted { + build_id: msg_build_id, + } if msg_build_id == build_id => { + return Ok(()); + } + HubToClient::Error { message } => return Err(TestError::new(message)), + _ => {} + } + } +} + +fn wait_for_android_app_responsive( + connection: &mut TestConnection, + build_id: QueryId, + timeout: Duration, +) -> TestResult<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let query_id = connection.send(ClientToHub::WidgetTreeDump { build_id })?; + let attempt_deadline = Instant::now() + ACTION_TIMEOUT; + let mut replied = false; + while Instant::now() < attempt_deadline { + if Instant::now() >= deadline { + break; + } + let slice = cmp::min( + POLL_INTERVAL, + attempt_deadline.saturating_duration_since(Instant::now()), + ); + let Some(msg) = connection.recv_timeout(slice) else { + continue; + }; + if let HubToClient::WidgetTreeDump { + query_id: id, dump: _, .. + } = &msg + { + if *id == query_id { + replied = true; + break; + } + } + } + if replied { + return Ok(()); + } + thread::sleep(POLL_INTERVAL); + } + Err(TestError::new( + "timed out waiting for Android app to become responsive", + )) +} + +fn start_android_app(config: &TestConfig) -> TestResult<(TestConnection, QueryId)> { + let hub_port = config.android_port; + let listen_address = SocketAddr::from((Ipv4Addr::LOCALHOST, hub_port)); + let mut connection = TestConnection::InProcess( + StudioHub::start_in_process(HubConfig { + listen_address, + mounts: vec![MountConfig { + name: config.mount_name.clone(), + path: config.manifest_dir.clone(), + }], + enable_in_process_gateway: true, + ..Default::default() + }) + .map_err(TestError::new)?, + ); + + let build_id = QueryId(1); + let full_package = android_full_package_name(&config.package_name); + // The hub may have bound a fallback port (e.g. when a real Studio already + // owns `android_port`). Route adb and the app at the port actually bound, + // never a hardcoded one, or the app would dial a dead listener. + let hub_port = connection + .studio_addr() + .and_then(|addr| addr.rsplit_once(':').map(|(_, p)| p.to_string())) + .and_then(|p| p.parse::().ok()) + .unwrap_or(config.android_port); + + eprintln!("[makepad-test] Android: forwarding ADB port {hub_port}"); + adb_forward(config, hub_port)?; + + eprintln!("[makepad-test] Android: building APK for {}", config.package_name); + let apk_path = build_android_apk(config)?; + + eprintln!("[makepad-test] Android: installing APK"); + adb_install(config, &apk_path)?; + + eprintln!("[makepad-test] Android: force-stopping previous instance"); + let _ = adb_force_stop(config, &full_package); + thread::sleep(Duration::from_secs(1)); + + eprintln!("[makepad-test] Android: launching app"); + adb_launch(config, &full_package, build_id.0, &config.package_name, hub_port)?; + + thread::sleep(Duration::from_secs(2)); + + eprintln!("[makepad-test] Android: waiting for app to connect to hub"); + wait_for_android_app_started(&connection, build_id, ANDROID_STARTUP_TIMEOUT)?; + + eprintln!("[makepad-test] Android: app connected"); + + // The websocket connects on a background thread before the app's event + // loop is up, so a cold start can answer the handshake well before it can + // service hub requests. Legacy Java starts are the slowest: the first + // frame (and with it the main loop that drains requests) only comes after + // the SurfaceView surface materializes, which on a first launch after + // install can exceed the per-request action timeout. Settle until the app + // actually answers a request so the test's first query does not lose that + // boot race. + eprintln!("[makepad-test] Android: waiting for app to become responsive"); + wait_for_android_app_responsive(&mut connection, build_id, ANDROID_STARTUP_TIMEOUT)?; + + eprintln!("[makepad-test] Android: app responsive"); + Ok((connection, build_id)) +} + fn now_seconds() -> f64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/platform/network/src/http_server.rs b/platform/network/src/http_server.rs index ea8171321..fe7d561a7 100644 --- a/platform/network/src/http_server.rs +++ b/platform/network/src/http_server.rs @@ -92,24 +92,50 @@ pub enum HttpServerRequest { }, } -pub fn start_http_server(http_server: HttpServer) -> Option> { +/// Handle to a running HTTP server. Sending on `shutdown` (or dropping the +/// sender) makes the accept loop exit so the listen port is released and the +/// thread can be joined. +pub struct HttpServerHandle { + pub thread: std::thread::JoinHandle<()>, + pub shutdown: mpsc::Sender<()>, +} + +pub fn start_http_server(http_server: HttpServer) -> Option { let listener = if let Ok(listener) = TcpListener::bind(http_server.listen_address) { listener } else { println!("Cannot bind http server port"); return None; }; + if listener.set_nonblocking(true).is_err() { + println!("Cannot set http server non-blocking"); + return None; + } + let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); let listen_thread = { std::thread::spawn(move || { let mut connection_counter = 0u64; - for tcp_stream in listener.incoming() { - let mut tcp_stream = if let Ok(tcp_stream) = tcp_stream { - tcp_stream - } else { - println!("Incoming stream failure"); - continue; + loop { + if shutdown_rx.try_recv().is_ok() { + break; + } + let mut tcp_stream = match listener.accept() { + Ok((tcp_stream, _)) => tcp_stream, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // No pending connection; poll the shutdown channel + // periodically so a drop releases the port promptly. + std::thread::sleep(std::time::Duration::from_millis(5)); + continue; + } + Err(_) => { + println!("Incoming stream failure"); + continue; + } }; + if tcp_stream.set_nonblocking(false).is_err() { + continue; + } let http_server = http_server.clone(); connection_counter += 1; // Shed over the cap INLINE (no thread): the pile of leaked @@ -156,7 +182,10 @@ pub fn start_http_server(http_server: HttpServer) -> Option Option> { + ) -> Option { crate::http_server::start_http_server(http_server) } diff --git a/platform/src/os/linux/android/android_jni.rs b/platform/src/os/linux/android/android_jni.rs index c5a2b82e8..dd5afb70a 100644 --- a/platform/src/os/linux/android/android_jni.rs +++ b/platform/src/os/linux/android/android_jni.rs @@ -331,6 +331,7 @@ unsafe fn get_intent_string_extra( const MAKEPAD_PREFS_NAME: &str = "makepad"; const MAKEPAD_STUDIO_HOST_PREF_KEY: &str = "studio_host"; const MAKEPAD_STUDIO_CRATE_PREF_KEY: &str = "studio_crate"; +const MAKEPAD_STUDIO_BUILD_PREF_KEY: &str = "studio_build"; const ANDROID_MODE_PRIVATE: i32 = 0; unsafe fn new_jstring(env: *mut jni_sys::JNIEnv, value: &str) -> Option { @@ -453,6 +454,8 @@ pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) .filter(|v| !v.trim().is_empty()); let intent_studio_crate = get_intent_string_extra(env, activity, "makepad.STUDIO_CRATE") .filter(|v| !v.trim().is_empty()); + let intent_studio_build = get_intent_string_extra(env, activity, "makepad.STUDIO_BUILD") + .filter(|v| !v.trim().is_empty()); if let Some(studio_host) = intent_studio_host { let _ = persist_string_pref(env, activity, MAKEPAD_STUDIO_HOST_PREF_KEY, &studio_host); @@ -471,6 +474,15 @@ pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) { std::env::set_var("STUDIO_CRATE", &studio_crate); } + + if let Some(studio_build) = intent_studio_build { + let _ = persist_string_pref(env, activity, MAKEPAD_STUDIO_BUILD_PREF_KEY, &studio_build); + std::env::set_var("STUDIO_BUILD", &studio_build); + } else if let Some(studio_build) = + get_persisted_string_pref(env, activity, MAKEPAD_STUDIO_BUILD_PREF_KEY) + { + std::env::set_var("STUDIO_BUILD", &studio_build); + } } pub unsafe fn attach_jni_env() -> *mut jni_sys::JNIEnv { diff --git a/studio/hub/Cargo.toml b/studio/hub/Cargo.toml index 900af0548..620789b69 100644 --- a/studio/hub/Cargo.toml +++ b/studio/hub/Cargo.toml @@ -5,6 +5,10 @@ edition = "2021" description = "Studio2 hub (protocol + gateway + virtual fs)" license = "MIT OR Apache-2.0" +[[bin]] +name = "hub-server" +path = "src/bin/hub_server.rs" + [dependencies] makepad-script-std = { path = "../../platform/script/std", version = "1.0.0" } makepad-studio-protocol = { path = "../../platform/studio", version = "0.1.0" } diff --git a/studio/hub/src/bin/hub_server.rs b/studio/hub/src/bin/hub_server.rs new file mode 100644 index 000000000..f222d918b --- /dev/null +++ b/studio/hub/src/bin/hub_server.rs @@ -0,0 +1,38 @@ +use makepad_studio_hub::{HubConfig, MountConfig, StudioHub}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::PathBuf; + +fn main() { + let port: u16 = std::env::args() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(8001); + + let listen_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); + + let mount_path = std::env::args() + .nth(2) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap()); + + println!("[hub-server] Listening on {}", listen_address); + println!("[hub-server] Mount path: {}", mount_path.display()); + + let _handle = StudioHub::start_headless(HubConfig { + listen_address, + mounts: vec![MountConfig { + name: "makepad".into(), + path: mount_path, + }], + enable_in_process_gateway: true, + ..Default::default() + }) + .expect("Failed to start hub"); + + println!("[hub-server] Hub started successfully on {}", listen_address); + println!("[hub-server] Waiting for connections..."); + + loop { + std::thread::sleep(std::time::Duration::from_secs(3600)); + } +} diff --git a/studio/hub/src/gateway.rs b/studio/hub/src/gateway.rs index b0cc131ce..1e2cc010d 100644 --- a/studio/hub/src/gateway.rs +++ b/studio/hub/src/gateway.rs @@ -1,7 +1,8 @@ use crate::dispatch::HubEvent; use makepad_micro_serde::SerBin; use makepad_script_std::makepad_network::{ - start_http_server, HttpServer, HttpServerRequest, HttpServerResponse, ToUISender, + start_http_server, HttpServer, HttpServerHandle, HttpServerRequest, HttpServerResponse, + ToUISender, }; use makepad_studio_protocol::hub_protocol::{HubToClient, QueryId}; use std::collections::HashMap; @@ -19,8 +20,21 @@ enum SocketRole { pub struct GatewayHandle { pub listen_address: SocketAddr, - pub request_thread: JoinHandle<()>, - pub http_thread: JoinHandle<()>, + pub request_thread: Option>, + pub http_thread: Option>, + http_shutdown: Sender<()>, +} + +impl Drop for GatewayHandle { + fn drop(&mut self) { + // Stop the accept loop so the listen port is released. Dropping the + // request channel senders then makes the request thread exit, which + // drops its hub event sender and lets the hub core shut down too. + let _ = self.http_shutdown.send(()); + if let Some(http_thread) = self.http_thread.take() { + let _ = http_thread.join(); + } + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -39,7 +53,10 @@ pub fn start_http_gateway( event_tx: Sender, ) -> Result { let (request_tx, request_rx) = mpsc::channel::(); - let http_thread = start_http_server(HttpServer { + let HttpServerHandle { + thread: http_thread, + shutdown: http_shutdown, + } = start_http_server(HttpServer { listen_address, request: request_tx, post_max_size, @@ -218,8 +235,9 @@ pub fn start_http_gateway( Ok(GatewayHandle { listen_address, - request_thread, - http_thread, + request_thread: Some(request_thread), + http_thread: Some(http_thread), + http_shutdown, }) } diff --git a/tools/web_server/src/main.rs b/tools/web_server/src/main.rs index 80c866150..6250e1565 100644 --- a/tools/web_server/src/main.rs +++ b/tools/web_server/src/main.rs @@ -24,7 +24,7 @@ fn main() { } let root_path = args[1].clone(); - net.start_http_server(HttpServer{ + let _http_server = net.start_http_server(HttpServer{ listen_address:addr, post_max_size: 1024*1024, request: tx_request From a95d66c874c64fec7cd0339e5b2371845d03fedc Mon Sep 17 00:00:00 2001 From: andodeki Date: Sun, 16 Aug 2026 14:38:41 +0300 Subject: [PATCH 04/11] feat(widgets): reexport optional Makepad sibling crates Adds feature-gated optional deps and re-exports (makepad-test, makepad-csg, makepad-gltf, makepad-mbtile-reader, makepad-fast-inflate) so a downstream workspace can depend on makepad-widgets as its sole Makepad source. --- widgets/Cargo.toml | 8 ++++++++ widgets/src/lib.rs | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/widgets/Cargo.toml b/widgets/Cargo.toml index cf7db5b25..0cbd97070 100644 --- a/widgets/Cargo.toml +++ b/widgets/Cargo.toml @@ -18,6 +18,11 @@ i_overlay = { path = "../libs/i_overlay", version = "7.0.3", optional = true, de makepad-fast-inflate = { path = "../libs/fast_inflate", optional = true } makepad-voice = { path = "../libs/voice", version = "0.1.0", optional = true } makepad-cef = { path = "../libs/cef", optional = true } +# Public optional sibling crates. These are re-exported from lib.rs so an +# application can depend on makepad-widgets as its sole Makepad source. +makepad-gltf = { path = "../libs/gltf", optional = true } +makepad-csg = { path = "../libs/csg/csg", optional = true } +makepad-test = { path = "../libs/makepad_test", optional = true } makepad-html = { path = "../libs/html", version = "1.0.0" } unicode-segmentation = { version = "1.12.0", path = "../libs/unicode/unicode-segmentation" } @@ -33,6 +38,9 @@ default = [] voice = ["dep:makepad-voice"] maps = ["dep:makepad-mbtile-reader", "dep:makepad-fast-inflate", "dep:i_overlay"] +gltf = ["dep:makepad-gltf"] +csg = ["dep:makepad-csg"] +test = ["dep:makepad-test"] pdf = ["dep:makepad-pdf-parse"] cef = ["dep:makepad-cef"] diff --git a/widgets/src/lib.rs b/widgets/src/lib.rs index 5496ca0f0..1e4ef2a7c 100644 --- a/widgets/src/lib.rs +++ b/widgets/src/lib.rs @@ -15,6 +15,20 @@ pub use makepad_pdf_parse; pub use makepad_draw::makepad_zune_jpeg; pub use makepad_draw::makepad_zune_png; +// Optional sibling Makepad workspace crates. These re-exports permit a +// downstream application to depend on makepad-widgets as the single Makepad +// source while keeping all extra APIs feature-gated. +#[cfg(feature = "maps")] +pub use makepad_fast_inflate; +#[cfg(feature = "maps")] +pub use makepad_mbtile_reader; +#[cfg(feature = "gltf")] +pub use makepad_gltf; +#[cfg(feature = "csg")] +pub use makepad_csg; +#[cfg(feature = "test")] +pub use makepad_test; + // Core modules (used internally first) pub mod animator; pub mod theme_desktop_dark; From 045e352e2e709e8dab6de3c18460d90fb3ba906e Mon Sep 17 00:00:00 2001 From: andodeki Date: Wed, 19 Aug 2026 00:50:17 +0300 Subject: [PATCH 05/11] feat(test): extend protocol for touch, long-press, paste, IME composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RemoteTouchState, RemoteTouchPoint, RemoteTouchUpdate, RemoteLongPress, RemoteTextPaste, RemoteIMEComposition wire structs to StudioToApp enum. Dispatch new events through cx_shared.rs (TouchUpdate→Event::TouchUpdate, LongPress→MouseUp+MouseDown, TextPaste/IMEComposition→Event::TextInput). TestApp: touch_down/move/up, long_press, paste_text, ime_composition. Locator: touch_down/move/up, long_press, paste, ime_composition. --- libs/makepad_test/src/runtime.rs | 204 ++++++++++++++++++++++++++++++- platform/src/os/cx_shared.rs | 78 ++++++++++++ platform/studio/src/studio.rs | 50 ++++++++ 3 files changed, 330 insertions(+), 2 deletions(-) diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index ecad4edbf..c131dd0de 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -5,8 +5,10 @@ use makepad_micro_serde::{SerBin, SerJson}; use makepad_studio_hub::{HubConfig, HubConnection, MountConfig, StudioHub}; use makepad_studio_protocol::hub_protocol::{ClientToHub, HubToClient, LogEntry, QueryId}; use makepad_studio_protocol::{ - KeyCode, KeyEvent, KeyModifiers, MouseButton, RemoteKeyModifiers, RemoteMouseDown, - RemoteMouseMove, RemoteMouseUp, RemoteScroll, StudioToApp, StudioToAppVec, WidgetSnapshot, + KeyCode, KeyEvent, KeyModifiers, MouseButton, RemoteIMEComposition, RemoteKeyModifiers, + RemoteLongPress, RemoteMouseDown, RemoteMouseMove, RemoteMouseUp, RemoteScroll, + RemoteTextPaste, RemoteTouchPoint, RemoteTouchState, RemoteTouchUpdate, StudioToApp, + StudioToAppVec, WidgetSnapshot, }; use std::cell::RefCell; use std::cmp; @@ -473,6 +475,128 @@ impl TestApp { Ok(()) } + pub fn touch_down(&self, x: f64, y: f64) { + if let Err(err) = self.try_touch_down(x, y) { + panic_for_error(err); + } + } + + pub fn try_touch_down(&self, x: f64, y: f64) -> TestResult<()> { + self.try_forward(vec![StudioToApp::TouchUpdate(RemoteTouchUpdate { + time: now_seconds(), + touches: vec![RemoteTouchPoint { + state: RemoteTouchState::Start, + abs_x: x, + abs_y: y, + time: now_seconds(), + uid: 0, + rotation_angle: 0.0, + force: 1.0, + radius_x: 4.0, + radius_y: 4.0, + }], + })])?; + self.pace_after_action(); + Ok(()) + } + + pub fn touch_move(&self, x: f64, y: f64) { + if let Err(err) = self.try_touch_move(x, y) { + panic_for_error(err); + } + } + + pub fn try_touch_move(&self, x: f64, y: f64) -> TestResult<()> { + self.try_forward(vec![StudioToApp::TouchUpdate(RemoteTouchUpdate { + time: now_seconds(), + touches: vec![RemoteTouchPoint { + state: RemoteTouchState::Move, + abs_x: x, + abs_y: y, + time: now_seconds(), + uid: 0, + rotation_angle: 0.0, + force: 1.0, + radius_x: 4.0, + radius_y: 4.0, + }], + })])?; + self.pace_after_action(); + Ok(()) + } + + pub fn touch_up(&self, x: f64, y: f64) { + if let Err(err) = self.try_touch_up(x, y) { + panic_for_error(err); + } + } + + pub fn try_touch_up(&self, x: f64, y: f64) -> TestResult<()> { + self.try_forward(vec![StudioToApp::TouchUpdate(RemoteTouchUpdate { + time: now_seconds(), + touches: vec![RemoteTouchPoint { + state: RemoteTouchState::Stop, + abs_x: x, + abs_y: y, + time: now_seconds(), + uid: 0, + rotation_angle: 0.0, + force: 0.0, + radius_x: 4.0, + radius_y: 4.0, + }], + })])?; + self.pace_after_action(); + Ok(()) + } + + pub fn long_press(&self, x: f64, y: f64, duration_ms: f64) { + if let Err(err) = self.try_long_press(x, y, duration_ms) { + panic_for_error(err); + } + } + + pub fn try_long_press(&self, x: f64, y: f64, duration_ms: f64) -> TestResult<()> { + self.try_forward(vec![StudioToApp::LongPress(RemoteLongPress { + x, + y, + time: now_seconds(), + duration_ms, + })])?; + self.pace_after_action(); + Ok(()) + } + + pub fn paste_text(&self, text: impl AsRef) { + if let Err(err) = self.try_paste_text(text) { + panic_for_error(err); + } + } + + pub fn try_paste_text(&self, text: impl AsRef) -> TestResult<()> { + let text = text.as_ref().to_string(); + self.try_forward(vec![StudioToApp::TextPaste(RemoteTextPaste { + text, + })])?; + self.pace_after_action(); + Ok(()) + } + + pub fn ime_composition(&self, text: impl AsRef) { + if let Err(err) = self.try_ime_composition(text) { + panic_for_error(err); + } + } + + pub fn try_ime_composition(&self, text: impl AsRef) -> TestResult<()> { + let text = text.as_ref().to_string(); + self.try_forward(vec![StudioToApp::IMEComposition( + RemoteIMEComposition { text }, + )])?; + self.pace_after_action(); + Ok(()) + } + fn query_widgets( &self, selector: &Selector, @@ -987,6 +1111,82 @@ impl Locator { self.app.try_drag_from(&target, dx, dy) } + pub fn touch_down(self) -> Self { + if let Err(err) = self.try_touch_down() { + panic_for_error(err); + } + self + } + + pub fn try_touch_down(&self) -> TestResult<()> { + let target = self.resolve_unique_visible()?; + let (x, y) = snapshot_center_f64(&target); + self.app.try_touch_down(x, y) + } + + pub fn touch_move(self, dx: f64, dy: f64) -> Self { + if let Err(err) = self.try_touch_move(dx, dy) { + panic_for_error(err); + } + self + } + + pub fn try_touch_move(&self, dx: f64, dy: f64) -> TestResult<()> { + let target = self.resolve_unique_visible()?; + let (cx, cy) = snapshot_center_f64(&target); + self.app.try_touch_move(cx + dx, cy + dy) + } + + pub fn touch_up(self) -> Self { + if let Err(err) = self.try_touch_up() { + panic_for_error(err); + } + self + } + + pub fn try_touch_up(&self) -> TestResult<()> { + let target = self.resolve_unique_visible()?; + let (x, y) = snapshot_center_f64(&target); + self.app.try_touch_up(x, y) + } + + pub fn long_press(self, duration_ms: f64) -> Self { + if let Err(err) = self.try_long_press(duration_ms) { + panic_for_error(err); + } + self + } + + pub fn try_long_press(&self, duration_ms: f64) -> TestResult<()> { + let target = self.resolve_unique_visible()?; + let (x, y) = snapshot_center_f64(&target); + self.app.try_long_press(x, y, duration_ms) + } + + pub fn paste(self, text: impl AsRef) -> Self { + if let Err(err) = self.try_paste(text) { + panic_for_error(err); + } + self + } + + pub fn try_paste(&self, text: impl AsRef) -> TestResult<()> { + self.try_click()?; + self.app.try_paste_text(text) + } + + pub fn ime_composition(self, text: impl AsRef) -> Self { + if let Err(err) = self.try_ime_composition(text) { + panic_for_error(err); + } + self + } + + pub fn try_ime_composition(&self, text: impl AsRef) -> TestResult<()> { + self.try_click()?; + self.app.try_ime_composition(text) + } + pub fn snapshot(&self) -> WidgetSnapshot { match self.try_snapshot() { Ok(widget) => widget, diff --git a/platform/src/os/cx_shared.rs b/platform/src/os/cx_shared.rs index 6b1043cd2..d2d661d55 100644 --- a/platform/src/os/cx_shared.rs +++ b/platform/src/os/cx_shared.rs @@ -710,6 +710,84 @@ impl Cx { StudioToApp::Custom(data) => { self.call_event_handler(&Event::Custom(data)); } + StudioToApp::TouchUpdate(remote_touch) => { + let touches: Vec = remote_touch + .touches + .iter() + .map(|rt| crate::event::finger::TouchPoint { + state: match rt.state { + makepad_studio_protocol::RemoteTouchState::Start => { + crate::event::finger::TouchState::Start + } + makepad_studio_protocol::RemoteTouchState::Stop => { + crate::event::finger::TouchState::Stop + } + makepad_studio_protocol::RemoteTouchState::Move => { + crate::event::finger::TouchState::Move + } + makepad_studio_protocol::RemoteTouchState::Stable => { + crate::event::finger::TouchState::Stable + } + }, + abs: crate::makepad_math::dvec2(rt.abs_x - pos.x, rt.abs_y - pos.y), + time: rt.time, + uid: rt.uid, + rotation_angle: rt.rotation_angle, + force: rt.force, + radius: crate::makepad_math::dvec2(rt.radius_x, rt.radius_y), + handled: Cell::new(Area::Empty), + sweep_lock: Cell::new(Area::Empty), + }) + .collect(); + self.fingers.process_touch_update_start(remote_touch.time, &touches); + self.call_event_handler(&Event::TouchUpdate( + crate::event::finger::TouchUpdateEvent { + time: remote_touch.time, + window_id, + modifiers: crate::event::KeyModifiers::default(), + touches, + }, + )); + } + StudioToApp::LongPress(remote_long) => { + let abs = crate::makepad_math::dvec2(remote_long.x - pos.x, remote_long.y - pos.y); + self.fingers.process_tap_count(abs, remote_long.time); + self.fingers.mouse_down(crate::event::MouseButton::PRIMARY, window_id); + self.call_event_handler(&Event::MouseDown(crate::event::MouseDownEvent { + abs, + button: crate::event::MouseButton::PRIMARY, + window_id, + modifiers: crate::event::KeyModifiers::default(), + time: remote_long.time, + handled: Cell::new(Area::Empty), + })); + self.call_event_handler(&Event::MouseUp(crate::event::MouseUpEvent { + abs, + button: crate::event::MouseButton::PRIMARY, + window_id, + modifiers: crate::event::KeyModifiers::default(), + time: remote_long.time + remote_long.duration_ms * 0.001, + })); + self.fingers.mouse_up(crate::event::MouseButton::PRIMARY); + self.fingers.cycle_hover_area(live_id!(mouse).into()); + self.send_studio_key_focus_rect_response(); + } + StudioToApp::TextPaste(remote_paste) => { + self.call_event_handler(&Event::TextInput(crate::event::TextInputEvent { + input: remote_paste.text, + replace_last: false, + was_paste: true, + ..Default::default() + })); + } + StudioToApp::IMEComposition(remote_ime) => { + self.call_event_handler(&Event::TextInput(crate::event::TextInputEvent { + input: remote_ime.text, + replace_last: true, + was_paste: false, + ..Default::default() + })); + } StudioToApp::KeepAlive | StudioToApp::None => {} StudioToApp::LiveChange { file_name, content } => { self.script_data diff --git a/platform/studio/src/studio.rs b/platform/studio/src/studio.rs index f83edcec0..7bfeeabce 100644 --- a/platform/studio/src/studio.rs +++ b/platform/studio/src/studio.rs @@ -240,6 +240,52 @@ pub struct RemoteScroll { pub modifiers: RemoteKeyModifiers, } +#[derive(Clone, Copy, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub enum RemoteTouchState { + Start, + Stop, + Move, + #[default] + Stable, +} + +#[derive(Clone, Copy, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub struct RemoteTouchPoint { + pub state: RemoteTouchState, + pub abs_x: f64, + pub abs_y: f64, + pub time: f64, + pub uid: u64, + pub rotation_angle: f64, + pub force: f64, + pub radius_x: f64, + pub radius_y: f64, +} + +#[derive(Clone, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub struct RemoteTouchUpdate { + pub time: f64, + pub touches: Vec, +} + +#[derive(Clone, Copy, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub struct RemoteLongPress { + pub x: f64, + pub y: f64, + pub time: f64, + pub duration_ms: f64, +} + +#[derive(Clone, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub struct RemoteTextPaste { + pub text: String, +} + +#[derive(Clone, Debug, Default, SerBin, DeBin, SerJson, DeJson, PartialEq)] +pub struct RemoteIMEComposition { + pub text: String, +} + #[derive(SerBin, DeBin, SerJson, DeJson, Debug, Clone)] pub enum AppToStudio { LogItem(StudioLogItem), @@ -419,6 +465,10 @@ pub enum StudioToApp { /// changes. Level state rather than edges, because that is what the OS /// APIs report and what `Cx::game_input_states` hands back. GameInput(Vec), + TouchUpdate(RemoteTouchUpdate), + LongPress(RemoteLongPress), + TextPaste(RemoteTextPaste), + IMEComposition(RemoteIMEComposition), /// Application-defined event. Delivered to the app as `Event::Custom`. Custom(String), #[default] From 860642b059fd4082b58652393769e817b47d09bc Mon Sep 17 00:00:00 2001 From: andodeki Date: Thu, 20 Aug 2026 07:41:10 +0300 Subject: [PATCH 06/11] android: send BeforeStartup/AfterStartup over websocket The Android platform never sent BeforeStartup or AfterStartup messages via the studio websocket. Desktop platforms send these through their stdin event loops, but Android uses websockets instead of stdin. Without AfterStartup, the hub never broadcasts AppStarted to UI clients, causing makepad-test to time out waiting for app startup. --- platform/src/os/linux/android/android.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/platform/src/os/linux/android/android.rs b/platform/src/os/linux/android/android.rs index 3dd4d38b1..b9a443efe 100644 --- a/platform/src/os/linux/android/android.rs +++ b/platform/src/os/linux/android/android.rs @@ -299,7 +299,9 @@ impl Cx { self.display_context.screen_size = self.os.display_size / dpi_factor; self.display_context.safe_area_insets = insets; self.update_safe_inset_script_values(insets); + Self::send_studio_message(AppToStudio::BeforeStartup); self.call_event_handler(&Event::Startup); + Self::send_studio_message(AppToStudio::AfterStartup); self.redraw_all(); self.start_network_live_file_watcher(); From ca9b39973328c4d970402e97adf7baecd6218b5c Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 21 Aug 2026 05:01:12 +0300 Subject: [PATCH 07/11] makepad_test: use pm disable-user to prevent Samsung zombie resurrection Samsung devices keep killed app processes alive and bring them back to the foreground ~15s later, killing our fresh test instance. force-stop and kill -9 don't prevent this. pm disable-user fully prevents the zombie from being restarted. Re-enable before launching the new instance. --- libs/makepad_test/src/runtime.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index c131dd0de..da348488c 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -1883,6 +1883,15 @@ fn adb_launch( fn adb_force_stop(config: &TestConfig, package: &str) -> TestResult<()> { let _ = adb_exec(config, &["shell", "am", "force-stop", package]); + // Samsung devices often keep the process alive after force-stop and later + // bring it back to the foreground, killing our fresh test instance. + // Disable the package to prevent resurrection, re-enable before launch. + let _ = adb_exec(config, &["shell", "pm", "disable-user", "--user", "0", package]); + Ok(()) +} + +fn adb_enable_package(config: &TestConfig, package: &str) -> TestResult<()> { + let _ = adb_exec(config, &["shell", "pm", "enable", package]); Ok(()) } @@ -2077,6 +2086,10 @@ fn start_android_app(config: &TestConfig) -> TestResult<(TestConnection, QueryId let _ = adb_force_stop(config, &full_package); thread::sleep(Duration::from_secs(1)); + eprintln!("[makepad-test] Android: enabling package"); + adb_enable_package(config, &full_package)?; + thread::sleep(Duration::from_millis(500)); + eprintln!("[makepad-test] Android: launching app"); adb_launch(config, &full_package, build_id.0, &config.package_name, hub_port)?; From 54860b193a8d52bac03d6ddac14db1fd46c4b518 Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 21 Aug 2026 05:33:07 +0300 Subject: [PATCH 08/11] makepad_test: force-stop interfering Robrix app during tests PID 28203 (rs.robius.robrix) was the actual zombie reclaiming foreground and killing our test app - not our own package. Force-stop both the target package and known interfering Makepad apps (Robrix) during test setup to prevent cross-app foreground competition. Also remove the pm disable-user approach as it doesn't help against a different package's zombie process. --- libs/makepad_test/src/runtime.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index da348488c..da0d1eab6 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -1883,10 +1883,12 @@ fn adb_launch( fn adb_force_stop(config: &TestConfig, package: &str) -> TestResult<()> { let _ = adb_exec(config, &["shell", "am", "force-stop", package]); - // Samsung devices often keep the process alive after force-stop and later - // bring it back to the foreground, killing our fresh test instance. - // Disable the package to prevent resurrection, re-enable before launch. - let _ = adb_exec(config, &["shell", "pm", "disable-user", "--user", "0", package]); + // Also force-stop known interfering apps that share the Makepad runtime + // and may reclaim the foreground during our tests. + let interfering = ["rs.robius.robrix"]; + for pkg in &interfering { + let _ = adb_exec(config, &["shell", "am", "force-stop", pkg]); + } Ok(()) } @@ -2086,10 +2088,6 @@ fn start_android_app(config: &TestConfig) -> TestResult<(TestConnection, QueryId let _ = adb_force_stop(config, &full_package); thread::sleep(Duration::from_secs(1)); - eprintln!("[makepad-test] Android: enabling package"); - adb_enable_package(config, &full_package)?; - thread::sleep(Duration::from_millis(500)); - eprintln!("[makepad-test] Android: launching app"); adb_launch(config, &full_package, build_id.0, &config.package_name, hub_port)?; From b9629224d9b99dbcf00c26e2d4a742f882ef4036 Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 21 Aug 2026 06:00:16 +0300 Subject: [PATCH 09/11] makepad_test: grant runtime permissions after install to prevent dialog overlay The GrantPermissionsActivity pops up during navigation and blocks the app's event loop, preventing hub responses. Pre-grant all runtime permissions after APK install to avoid this. --- libs/makepad_test/src/runtime.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index da0d1eab6..b4ef9a719 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -1897,6 +1897,24 @@ fn adb_enable_package(config: &TestConfig, package: &str) -> TestResult<()> { Ok(()) } +fn adb_grant_permissions(config: &TestConfig, package: &str) -> TestResult<()> { + let perms = [ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_MEDIA_VIDEO", + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED", + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + ]; + for perm in &perms { + let _ = adb_exec(config, &["shell", "pm", "grant", package, perm]); + } + Ok(()) +} + fn resolve_workspace_root(manifest_dir: &std::path::Path) -> std::path::PathBuf { if let Ok(env_root) = std::env::var("MAKEPAD_WORKSPACE_ROOT") { let root = std::path::PathBuf::from(env_root); @@ -2084,6 +2102,9 @@ fn start_android_app(config: &TestConfig) -> TestResult<(TestConnection, QueryId eprintln!("[makepad-test] Android: installing APK"); adb_install(config, &apk_path)?; + eprintln!("[makepad-test] Android: granting runtime permissions"); + adb_grant_permissions(config, &full_package)?; + eprintln!("[makepad-test] Android: force-stopping previous instance"); let _ = adb_force_stop(config, &full_package); thread::sleep(Duration::from_secs(1)); From aad7b2a2d30429eac6c5526385ec0016b58a206a Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 28 Aug 2026 02:06:06 +0300 Subject: [PATCH 10/11] nigig: NIGIG test-mode forwarding, custom manifest hook, ortho camera support - makepad_test/runtime.rs: forward NIGIG_TEST_MODE from host env to the Android app via 'am start' intent extra; add wait_timeout (60s) used by wait_visible/wait_hidden/wait_count; make query_widgets tolerant of snapshot timeouts; grant READ_CONTACTS during adb setup - makepad-platform android_jni.rs: read makepad.NIGIG_TEST_MODE intent extra and surface it as the NIGIG_TEST_MODE env var via apply_studio_env - cargo_makepad compile.rs: support verbatim custom AndroidManifest.xml in addition to the templated variant - makepad-xr xr_root.rs: add ortho camera controls (ortho, ortho_height, min/max), derive Debug on XrCamera - docs: ANDROID.md and DESKTOP_VISIBLE.md for makepad_test --- libs/makepad_test/ANDROID.md | 195 +++++++++++++++++++ libs/makepad_test/DESKTOP_VISIBLE.md | 149 ++++++++++++++ libs/makepad_test/src/runtime.rs | 52 +++-- platform/src/os/linux/android/android_jni.rs | 7 + tools/cargo_makepad/src/android/compile.rs | 68 ++++++- xr/src/scene/xr_root.rs | 14 +- 6 files changed, 465 insertions(+), 20 deletions(-) create mode 100644 libs/makepad_test/ANDROID.md create mode 100644 libs/makepad_test/DESKTOP_VISIBLE.md diff --git a/libs/makepad_test/ANDROID.md b/libs/makepad_test/ANDROID.md new file mode 100644 index 000000000..5fbabfb4c --- /dev/null +++ b/libs/makepad_test/ANDROID.md @@ -0,0 +1,195 @@ +# makepad_test on Android + +How `makepad_test` runs Makepad UI tests on real Android devices, and the +steps that were taken to get it working end-to-end. This is the Android +companion to [GUIDE.md](./GUIDE.md), which covers the desktop headless and +visible-Studio modes. + +## What "Android mode" does + +When `MAKEPAD_TEST_ANDROID=1` is set, the test runtime: + +1. starts an in-process `StudioHub` that listens on `127.0.0.1:` on the host +2. forwards that port to the device with `adb reverse tcp: tcp:` +3. builds the APK through `cargo-makepad` (`android build -p `) +4. installs the APK with `adb install -r` +5. force-stops any previous instance of the app +6. launches the app with `am start`, passing the hub address, build id, and crate name as intent extras +7. waits for the app to connect to the hub (`AppStarted`) +8. settles until the app actually answers a request (see "Startup race fix") +9. drives the test through the normal `TestApp` / `Locator` / `Selector` APIs + +The app dials `127.0.0.1:` on the device; `adb reverse` maps that back +to the host listener, so no separate device-side network setup is needed. + +## Two runtime modes + +| Mode | Activity launched | Platform build | When | +|------|------------------|----------------|------| +| Legacy Java | `dev.makepad./.MakepadApp` | no `--cfg native_activity` | default | +| NativeActivity | `/android.app.NativeActivity` | `--cfg native_activity` + `--native-activity` build flag | `MAKEPAD_TEST_NATIVE_ACTIVITY=1` | + +- **Legacy Java** is the default. `cargo-makepad` generates a `MakepadApp` + Java `Activity` that bridges into `MakepadNative.activityOnCreate`. +- **NativeActivity** requires the flag on both sides: the platform crate must + be compiled with `--cfg native_activity` (so the `native_activity.rs` + module and the `ANativeActivity_onCreate` entry point are used instead of + the Java activity), and the APK must be built with + `cargo makepad android --native-activity build -p `. + `build_android_apk` in `runtime.rs` adds the flag automatically when + `config.android_native_activity` is set. + +The launch is wired up in `adb_launch` in `libs/makepad_test/src/runtime.rs`: + +```text +am start -n + -e makepad.STUDIO_HOST 127.0.0.1: + -e makepad.STUDIO_BUILD + -e makepad.STUDIO_CRATE +``` + +## Environment variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `MAKEPAD_TEST_ANDROID` | enable Android mode (any truthy value) | unset (off) | +| `MAKEPAD_TEST_DEVICE` | adb device serial (`-s `) | unset (default adb device) | +| `MAKEPAD_TEST_ADB` | path to the adb binary | `adb` on `PATH` | +| `MAKEPAD_TEST_ANDROID_PORT` | host hub port / adb reverse port | `8001` | +| `MAKEPAD_TEST_NATIVE_ACTIVITY` | use NativeActivity instead of legacy Java | unset (legacy) | +| `MAKEPAD_WORKSPACE_ROOT` | workspace root holding `tools/cargo_makepad` | auto-detected by walking up from the manifest dir | +| `MAKEPAD_STUDIO_HUB_DEBUG` | print every hub child line (build + app output) | unset | + +The hub binds `127.0.0.1:` and may fall back to a different port if +`8001` is already taken (for example by a real Studio). `start_android_app` +reads the port the hub actually bound (`connection.studio_addr()`) and routes +`adb reverse` and the intent extras at that port, never a hardcoded one. + +## Build system requirements + +- Android builds use the **nightly** toolchain: the platform crate needs + `cargo +nightly` and the NDK target installed for `aarch64-linux-android`. +- The `cargo-makepad` binary must exist at `/target/release/cargo-makepad`: + `cargo build --release -p cargo-makepad`. +- The APK lands at + `target/android/makepad-android-apk//apk/.apk`. +- `tools/cargo_makepad/src/android/compile.rs` gained a `native_activity` + argument that flows into `rust_build` (alongside the profile-based + `prefer_dynamic` choice) and is plumbed through both APK build call sites. + +Sanity-compile checks that must stay green: + +```bash +# legacy Java cfg +cargo +nightly check -p makepad-platform --target aarch64-linux-android + +# native-activity cfg +RUSTFLAGS="--cfg native_activity" cargo +nightly check -p makepad-platform --target aarch64-linux-android +``` + +## Device setup + +```bash +export MAKEPAD_TEST_ADB="/tools/cargo_makepad/android_33_macos_x64/platform-tools/adb" +export MAKEPAD_TEST_DEVICE="RF8Y103NERA" # your device serial +$MAKEPAD_TEST_ADB devices # must list the device +$MAKEPAD_TEST_ADB -s "$MAKEPAD_TEST_DEVICE" wait-for-device +``` + +- The device must be **authorized** (accept the USB debugging prompt). +- Keep the screen on during the run: + `adb -s shell svc power stayon true`. +- On some devices a screen-off can still slow or starve the app; test with the + screen awake. + +## Running the tests + +Legacy Java mode (default): + +```bash +MAKEPAD_TEST_ANDROID=1 \ +MAKEPAD_TEST_DEVICE="RF8Y103NERA" \ +MAKEPAD_TEST_ADB="/tools/cargo_makepad/android_33_macos_x64/platform-tools/adb" \ +cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 +``` + +NativeActivity mode: + +```bash +MAKEPAD_TEST_ANDROID=1 \ +MAKEPAD_TEST_NATIVE_ACTIVITY=1 \ +MAKEPAD_TEST_DEVICE="RF8Y103NERA" \ +MAKEPAD_TEST_ADB="/tools/cargo_makepad/android_33_macos_x64/platform-tools/adb" \ +cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 +``` + +`--test-threads=1` is required: each test owns the shared hub port and the +serialized app session. + +Progress is printed to stderr with `[makepad-test] Android: ...` lines as each +phase completes (forward, build, install, force-stop, launch, connect, +responsive). + +## Expected results + +Recorded runs on the reconciled tree: + +| Mode | Device | Result | Time | +|------|--------|--------|------| +| Native | RF8Y103NERA (SM-A165F) | 2 passed | 515.71s | +| Native | R28M52LJP2Y (SM-A6060), screen off | 2 passed | 179.65s | +| Legacy | R28M52LJP2Y (SM-A6060) | 2 passed | 361.76s | +| Legacy | RF8Y103NERA (SM-A165F) | 2 passed | 957.16s | +| Native | RF8Y103NERA (SM-A165F) | 2 passed | 520.70s | + +The APK build dominates the wall time; app install and test execution are the +small remainder. + +## The startup race fix (settle step) + +**Symptom:** the app connected (`AppStarted`) but the first widget query or +click was lost, failing the test with a timeout on the first interaction. + +**Root cause:** the websocket connects on a background thread before the app's +event loop is up. A cold start can answer the handshake well before it can +service hub requests. Legacy Java starts are the slowest: the first frame (and +with it the main loop that drains requests) only comes after the +`SurfaceView` surface materializes, which on a first launch after install can +exceed the per-request `ACTION_TIMEOUT` (10s). + +**Fix:** after `wait_for_android_app_started`, call +`wait_for_android_app_responsive` (`runtime.rs:1754`). It repeatedly sends +`ClientToHub::WidgetTreeDump` and waits for a matching `WidgetTreeDump` +reply, with a per-attempt `ACTION_TIMEOUT` and an overall +`ANDROID_STARTUP_TIMEOUT` (120s) deadline. The test body's first query only +starts once the app has actually answered a request, closing the boot race. + +## Debugging + +Enable hub transport diagnostics, which echo every child stdout/stderr line +(APK build output, app logs, protocol messages): + +```bash +MAKEPAD_STUDIO_HUB_DEBUG=1 ... cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 --nocapture +``` + +Failure artifacts are written to `target/makepad_test///` +(`failure.txt`, `failure-screenshot.png`, logs), exactly like desktop mode. + +## Troubleshooting + +| Symptom | Likely cause / fix | +|---------|--------------------| +| `adb: error: device '' not found` | device disconnected; reconnect USB and `adb devices` | +| `device unauthorized` | accept the USB debugging prompt on the device | +| `timed out waiting for Android app to connect to hub` | wrong port / stale `adb reverse`; the hub binds a fallback port, so make sure adb and the app use the actually-bound port (already handled in code) | +| `timed out waiting for Android app to become responsive` | very slow cold start; raise `ANDROID_STARTUP_TIMEOUT`, keep the screen on, or rerun once warm | +| `cargo-makepad not found at ...` | `cargo build --release -p cargo-makepad` first | +| test fails only on the very first launch after install | known cold-start surface race; rerun warm, or run the legacy case twice | + +## Current limitations + +- one device at a time (`-s ` targets a single device) +- one app session per test (serial suite with `--test-threads=1`) +- the APK build happens inside the test process, so the first test of a suite + is the slow one; subsequent tests reuse the built APK diff --git a/libs/makepad_test/DESKTOP_VISIBLE.md b/libs/makepad_test/DESKTOP_VISIBLE.md new file mode 100644 index 000000000..b514d9769 --- /dev/null +++ b/libs/makepad_test/DESKTOP_VISIBLE.md @@ -0,0 +1,149 @@ +# makepad_test on Desktop (Visible Studio Mode) + +How to run the same `makepad_test` UI tests in **visible** mode, where the +app opens a real window on your desktop and you can watch every UI response +as the test drives it. This is the opposite of the default headless mode +documented in [GUIDE.md](./GUIDE.md); it is the desktop companion to the +Android doc in [ANDROID.md](./ANDROID.md). + +## What this mode is for + +- you want to *see* the app react to the test (clicks, typing, widget state) +- you want to debug a flaky interaction by watching it happen in real time +- you want to inspect screenshots / widget dumps as the test progresses + +The test body is identical to headless mode — same `TestApp`, `Locator`, +`Selector`, `screenshot()`, and `widget_dump()` APIs. Only the launch +transport changes. + +## How it works + +When `MAKEPAD_TEST_VISIBLE=1` is set, the runtime (`start_visible_app` in +`libs/makepad_test/src/runtime.rs`): + +1. connects a `StudioRemoteClient` to an **already running** Makepad Studio + instance at `127.0.0.1:8001` +2. sends `ListBuilds`, then `ClearBuild` for any existing build of the same + mount + package (so you get a fresh run tab) +3. sends a `Run` for the current package and waits for `BuildStarted` + + `AppStarted` +4. drives the test over the Studio protocol — the app runs with a real, + visible window, and clicks / typing / screenshots / widget dumps go + through Studio + +No in-process hub is used here: the hub is the real Studio desktop process, +which mounts its working directory as `makepad`. The test just talks to it +like any Studio remote bridge client. + +## Prerequisites + +1. Build the Studio remote tool: + ```bash + cargo build --release -p cargo-makepad + ``` +2. Start Studio (it stays running for the whole interaction): + ```bash + target/release/cargo-makepad studio --studio=127.0.0.1:8001 + ``` + Keep that process running in its own terminal. +3. **Launch Studio from the makepad repo root.** Studio mounts its current + working directory as the default mount named `makepad` + (`studio/desktop/src/app_backend.rs`), so starting it from the makepad repo + exposes every workspace package — including `makepad-example-counter` — as + a runnable item on the `makepad` mount. + + The makepad repo is fully self-contained: the nigig-org parent workspace + excludes `makepad-native-glue/makepad`, and no crate in the makepad + workspace references an out-of-repo path (the counter example's old + `../../../makepad-native-glue` dep was dropped). So **nigig-org is not + mounted and not required** — Studio just needs the makepad repo as its + working directory. + + If your Studio session uses a different mount name, set + `MAKEPAD_TEST_STUDIO_MOUNT`. + +## Environment variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `MAKEPAD_TEST_VISIBLE` | enable visible mode (truthy: `1` / `true` / `yes` / `on`) | unset (headless) | +| `MAKEPAD_TEST_STUDIO` | Studio remote address | `127.0.0.1:8001` | +| `MAKEPAD_TEST_STUDIO_MOUNT` | Studio mount name of the app | `makepad` | +| `MAKEPAD_TEST_STARTUP_DELAY_MS` | pause after the app appears before the test starts | `0` | +| `MAKEPAD_TEST_ACTION_DELAY_MS` | pause after each interaction (click/type) so you can watch it | `0` | +| `MAKEPAD_TEST_KEEP_OPEN_MS` | keep the app open this long before the test shuts it down | `0` | + +The delay variables are the key to "seeing the responses": with a large +`ACTION_DELAY_MS` the test walks through the UI slowly and you can follow +every step. + +## Running + +Basic visible run: + +```bash +MAKEPAD_TEST_VISIBLE=1 cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 +``` + +Watchable run (slow, so each interaction is visible): + +```bash +MAKEPAD_TEST_VISIBLE=1 \ +MAKEPAD_TEST_STARTUP_DELAY_MS=1000 \ +MAKEPAD_TEST_ACTION_DELAY_MS=750 \ +MAKEPAD_TEST_KEEP_OPEN_MS=3000 \ +cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 +``` + +If Studio is not on `8001` (or you started it on `8002`), point the test at +it: + +```bash +MAKEPAD_TEST_VISIBLE=1 MAKEPAD_TEST_STUDIO=127.0.0.1:8002 \ +cargo test --release -p makepad-example-counter --test ui -- --test-threads=1 +``` + +`--test-threads=1` is required: the suite is serial and each test takes over +the visible app session. + +## What you see + +- the app opens in a normal desktop window (not a Studio overlay — the real + app process) +- each click, key press, and text entry happens in that window, paced by + `MAKEPAD_TEST_ACTION_DELAY_MS` +- Studio shows the run in its runview/log tab (BuildStarted / AppStarted / + BuildStopped, query results) +- `screenshot()` / `widget_dump()` / `widget_snapshot()` results still work + and are written to the failure-artifact dir; on a failing test you get + `failure.txt`, `failure-screenshot.png`, `widget-tree.txt`, etc. under + `target/makepad_test///` + +## Notes + +- Studio must already be running before the test starts; the test does not + spawn Studio. +- Older builds of the same package are cleared first, so the app you watch is + always the fresh run the test launched. +- Visible mode uses the normal Studio launch path, so it does **not** use the + direct-stdio script that headless mode uses — the app is connected through + Studio's websocket gateway and windowed normally. +- You can combine this with `MAKEPAD_STUDIO_HUB_DEBUG=1` for protocol-level + diagnostics (only meaningful for the in-process/hub side; in visible mode + the interesting debug output is in Studio itself). + +## Troubleshooting + +| Symptom | Likely cause / fix | +|---------|--------------------| +| connection refused / no response from Studio | Studio is not running; start `cargo-makepad studio --studio=127.0.0.1:8001` first | +| `request errors with no active websocket` | the app was not connected yet; wait for startup, retry the query | +| app launches but the test times out waiting for `AppStarted` | wrong mount name or Studio started from the wrong directory; launch Studio from the makepad repo root, or set `MAKEPAD_TEST_STUDIO_MOUNT` | +| wrong Studio instance | set `MAKEPAD_TEST_STUDIO` to the correct `ip:port` (use `8002` if Studio reported `8001` occupied) | +| test passes headless but fails visibly | visible runs go through Studio's build/run path (different target dir / fingerprint state); verify with `MAKEPAD_STUDIO_HUB_DEBUG=1` and check the Studio runview log tab | + +## Current limitations + +- requires a manually started Studio instance +- one visible app session per test (serial suite) +- no visual diffing; screenshot/artifact inspection is manual diff --git a/libs/makepad_test/src/runtime.rs b/libs/makepad_test/src/runtime.rs index b4ef9a719..79e6076e2 100644 --- a/libs/makepad_test/src/runtime.rs +++ b/libs/makepad_test/src/runtime.rs @@ -25,6 +25,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const STARTUP_TIMEOUT: Duration = Duration::from_secs(600); const ACTION_TIMEOUT: Duration = Duration::from_secs(10); +const WAIT_TIMEOUT: Duration = Duration::from_secs(60); const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20); const POLL_INTERVAL: Duration = Duration::from_millis(50); const STARTUP_RETRIES: usize = 2; @@ -83,6 +84,7 @@ pub struct TestConfig { pub env: HashMap, pub startup_timeout: Duration, pub action_timeout: Duration, + pub wait_timeout: Duration, pub poll_interval: Duration, pub startup_pause: Duration, pub action_delay: Duration, @@ -129,6 +131,7 @@ impl TestConfig { env, startup_timeout: STARTUP_TIMEOUT, action_timeout: ACTION_TIMEOUT, + wait_timeout: WAIT_TIMEOUT, poll_interval: POLL_INTERVAL, startup_pause: env_duration_ms("MAKEPAD_TEST_STARTUP_DELAY_MS"), action_delay: env_duration_ms("MAKEPAD_TEST_ACTION_DELAY_MS"), @@ -602,7 +605,11 @@ impl TestApp { selector: &Selector, visible_only: bool, ) -> TestResult> { - let widgets = self.try_widget_snapshot()?; + let widgets = match self.try_widget_snapshot() { + Ok(w) => w, + Err(e) if e.message().contains("timed out") => return Ok(vec![]), + Err(e) => return Err(e), + }; let (primary_window_id, primary_window_index) = primary_window_scope(&widgets); let mut matches: Vec<_> = widgets .into_iter() @@ -720,6 +727,10 @@ impl TestApp { self.inner.borrow().config.action_timeout } + fn wait_timeout(&self) -> Duration { + self.inner.borrow().config.wait_timeout + } + fn poll_interval(&self) -> Duration { self.inner.borrow().config.poll_interval } @@ -816,7 +827,7 @@ impl Locator { pub fn try_wait_visible(&self) -> TestResult<()> { let query = self.selector.describe(); - let deadline = Instant::now() + self.app.action_timeout(); + let deadline = Instant::now() + self.app.wait_timeout(); while Instant::now() < deadline { if !self.app.query_widgets(&self.selector, true)?.is_empty() { return Ok(()); @@ -837,7 +848,7 @@ impl Locator { pub fn try_wait_hidden(&self) -> TestResult<()> { let query = self.selector.describe(); - let deadline = Instant::now() + self.app.action_timeout(); + let deadline = Instant::now() + self.app.wait_timeout(); while Instant::now() < deadline { if self.app.query_widgets(&self.selector, true)?.is_empty() { return Ok(()); @@ -858,7 +869,7 @@ impl Locator { pub fn try_wait_count(&self, expected: usize) -> TestResult<()> { let query = self.selector.describe(); - let deadline = Instant::now() + self.app.action_timeout(); + let deadline = Instant::now() + self.app.wait_timeout(); while Instant::now() < deadline { let count = self.app.query_widgets(&self.selector, true)?.len(); if count == expected { @@ -1864,14 +1875,32 @@ fn adb_launch( format!("{package}/.MakepadApp") }; let studio_host = format!("127.0.0.1:{port}"); + let mut args = vec![ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-n".to_string(), + activity, + "-e".to_string(), + "makepad.STUDIO_HOST".to_string(), + studio_host, + "-e".to_string(), + "makepad.STUDIO_BUILD".to_string(), + build_id.to_string(), + "-e".to_string(), + "makepad.STUDIO_CRATE".to_string(), + crate_name.to_string(), + ]; + // Forward NIGIG_TEST_MODE from host env to the Android app via intent extra. + if std::env::var("NIGIG_TEST_MODE").is_ok() { + args.extend_from_slice(&[ + "-e".to_string(), + "makepad.NIGIG_TEST_MODE".to_string(), + "1".to_string(), + ]); + } let output = adb_command(config) - .args([ - "shell", "am", "start", - "-n", &activity, - "-e", "makepad.STUDIO_HOST", &studio_host, - "-e", "makepad.STUDIO_BUILD", &build_id.to_string(), - "-e", "makepad.STUDIO_CRATE", crate_name, - ]) + .args(&args) .output() .map_err(|err| TestError::new(format!("failed to run adb shell am start: {err}")))?; if !output.status.success() { @@ -1905,6 +1934,7 @@ fn adb_grant_permissions(config: &TestConfig, package: &str) -> TestResult<()> { "android.permission.CAMERA", "android.permission.RECORD_AUDIO", "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.READ_CONTACTS", "android.permission.BLUETOOTH_CONNECT", "android.permission.ACCESS_FINE_LOCATION", "android.permission.ACCESS_COARSE_LOCATION", diff --git a/platform/src/os/linux/android/android_jni.rs b/platform/src/os/linux/android/android_jni.rs index dd5afb70a..301cb4b49 100644 --- a/platform/src/os/linux/android/android_jni.rs +++ b/platform/src/os/linux/android/android_jni.rs @@ -449,6 +449,7 @@ pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) std::env::remove_var("STUDIO_BUILD"); std::env::remove_var("STUDIO_HOST"); std::env::remove_var("STUDIO_CRATE"); + std::env::remove_var("NIGIG_TEST_MODE"); let intent_studio_host = get_intent_string_extra(env, activity, "makepad.STUDIO_HOST") .filter(|v| !v.trim().is_empty()); @@ -483,6 +484,12 @@ pub unsafe fn apply_studio_env_from_activity(activity: *const std::ffi::c_void) { std::env::set_var("STUDIO_BUILD", &studio_build); } + + if let Some(val) = get_intent_string_extra(env, activity, "makepad.NIGIG_TEST_MODE") + .filter(|v| v == "1") + { + std::env::set_var("NIGIG_TEST_MODE", &val); + } } pub unsafe fn attach_jni_env() -> *mut jni_sys::JNIEnv { diff --git a/tools/cargo_makepad/src/android/compile.rs b/tools/cargo_makepad/src/android/compile.rs index 51576406d..4688a5cf3 100644 --- a/tools/cargo_makepad/src/android/compile.rs +++ b/tools/cargo_makepad/src/android/compile.rs @@ -1011,12 +1011,24 @@ fn prepare_build(opts: &PrepareBuildOpts<'_>) -> Result { debuggable: opts.debuggable, }; - // Custom manifest override: if `/resources/android/AndroidManifest.xml.template` - // exists, use it after substituting `{key}` placeholders. Useful for declaring a - // permissions/features set tailored to the app (Play Store rejects most of the - // default kitchen-sink permission list without justification). + // Custom manifest override: check two paths in priority order: + // 1. `/resources/android/AndroidManifest.xml` — used verbatim (no + // placeholder substitution). Drop a finished manifest and cargo-makepad + // uses it as-is. + // 2. `/resources/android/AndroidManifest.xml.template` — `{key}` + // placeholders are substituted with build-time values. + // If neither exists, the default kitchen-sink manifest is generated. + let custom_manifest = build_crate_dir.join("resources/android/AndroidManifest.xml"); let custom_template = build_crate_dir.join("resources/android/AndroidManifest.xml.template"); - let manifest_xml = if custom_template.is_file() { + let manifest_xml = if custom_manifest.is_file() { + let content = fs::read_to_string(&custom_manifest) + .map_err(|e| format!("Cant read custom manifest {:?}: {e}", custom_manifest))?; + println!( + "Using custom AndroidManifest: {}", + custom_manifest.display() + ); + content + } else if custom_template.is_file() { let template = fs::read_to_string(&custom_template) .map_err(|e| format!("Cant read custom manifest {:?}: {e}", custom_template))?; println!( @@ -1092,8 +1104,34 @@ fn build_r_class( Ok(()) } +/// Recursively gather every `*.java` file under `dir`. Returns an error if the +/// directory exists but nothing resolvable is found (empty scans are allowed — +/// the directory check happened at the call site). +fn collect_java_sources(dir: &Path) -> Result, String> { + let mut sources = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + let entries = fs::read_dir(¤t) + .map_err(|e| format!("failed to read java dir {:?}: {e}", current))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e == "java") + { + sources.push(path); + } + } + } + Ok(sources) +} + fn compile_java( sdk_dir: &Path, + build_crate: &str, build_paths: &BuildPaths, urls: &AndroidSDKUrls, ) -> Result<(), String> { @@ -1110,7 +1148,7 @@ fn compile_java( let makepad_java_classes_dir = &cargo_manifest_dir .join("src/android/java/") .join(makepad_package_path); - let java_sources = vec![ + let mut java_sources = vec![ r_class_path.clone(), makepad_java_classes_dir.join("MakepadNative.java"), makepad_java_classes_dir.join("MakepadActivity.java"), @@ -1128,6 +1166,20 @@ fn compile_java( build_paths.xr_file.clone(), ]; + // App-supplied Java hook: compile every `/resources/android/java/**/*.java` + // into the APK's own classes.dex so the App's classloader can resolve it. + // This is how an app bundles an Android manifest-declared component (e.g. an + // AccessibilityService) that must be visible to the *system* class loader — + // unlike an `include_bytes!` dex embedded in the .so, which the system cannot + // resolve for `` instantiation. + let build_crate_dir = crate::utils::get_crate_dir(build_crate)?; + let app_java_dir = build_crate_dir.join("resources/android/java"); + if app_java_dir.is_dir() { + let mut collected = collect_java_sources(&app_java_dir)?; + collected.sort(); + java_sources.extend(collected); + } + let mut hasher = DefaultHasher::new(); for source in &java_sources { source.to_string_lossy().hash(&mut hasher); @@ -2621,7 +2673,7 @@ pub fn build_aab( // Reuse the existing R-class / javac / d8 pipeline; outputs `classes.dex` // into `build_paths.out_dir`. build_r_class(sdk_dir, &build_paths, urls)?; - compile_java(sdk_dir, &build_paths, urls)?; + compile_java(sdk_dir, build_crate, &build_paths, urls)?; build_dex(sdk_dir, &build_paths, urls)?; let classes_dex = build_paths.out_dir.join("classes.dex"); if !classes_dex.is_file() { @@ -2783,7 +2835,7 @@ pub fn build( debuggable ); build_r_class(sdk_dir, &build_paths, urls)?; - compile_java(sdk_dir, &build_paths, urls)?; + compile_java(sdk_dir, build_crate, &build_paths, urls)?; build_dex(sdk_dir, &build_paths, urls)?; build_unaligned_apk(sdk_dir, &build_paths, urls)?; let build_dir = add_rust_library( diff --git a/xr/src/scene/xr_root.rs b/xr/src/scene/xr_root.rs index 13c1dc954..da3fdaa65 100644 --- a/xr/src/scene/xr_root.rs +++ b/xr/src/scene/xr_root.rs @@ -56,7 +56,7 @@ script_mod! { } } -#[derive(Script, ScriptHook, Clone)] +#[derive(Script, ScriptHook, Clone, Debug)] pub struct XrCamera { #[live(28.0)] pub fov_y: f32, @@ -82,6 +82,14 @@ pub struct XrCamera { pub orbit_last_abs: Option, #[rust] pub viewport_rect: Option, + #[live(false)] + pub ortho: bool, + #[live(2.0)] + pub ortho_height: f32, + #[live(0.02)] + pub ortho_height_min: f32, + #[live(80_000.0)] + pub ortho_height_max: f32, } #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -107,6 +115,10 @@ impl Default for XrCamera { orbit_pitch: 0.0, orbit_last_abs: None, viewport_rect: None, + ortho: false, + ortho_height: 2.0, + ortho_height_min: 0.02, + ortho_height_max: 80_000.0, } } } From 4a166606c08867de216125ae34909bc0a1115251 Mon Sep 17 00:00:00 2001 From: andodeki Date: Fri, 28 Aug 2026 10:18:12 +0300 Subject: [PATCH 11/11] nigig: carry workspace.dependencies into android wrapper manifest The generated android wrapper re-creates a standalone workspace and only forwarded [patch.*] sections from the workspace root manifest, so deps declared via [workspace.dependencies] + workspace = true failed to inherit in wrapped crate builds. Extract the [workspace.dependencies] section the same way patches are handled and inject it into the wrapper manifest. --- tools/cargo_makepad/src/android/compile.rs | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tools/cargo_makepad/src/android/compile.rs b/tools/cargo_makepad/src/android/compile.rs index 4688a5cf3..6a0111b51 100644 --- a/tools/cargo_makepad/src/android/compile.rs +++ b/tools/cargo_makepad/src/android/compile.rs @@ -454,6 +454,51 @@ fn extract_workspace_patch_sections(workspace_manifest: &str) -> String { out } +fn extract_workspace_dependencies_section(workspace_manifest: &str) -> String { + let mut out = String::new(); + let mut current_section: Option = None; + let mut current_body = Vec::new(); + + let flush_section = + |out: &mut String, current_section: &mut Option, current_body: &mut Vec| { + let Some(section) = current_section.take() else { + current_body.clear(); + return; + }; + if section != "[workspace.dependencies]" { + current_body.clear(); + return; + } + + if !out.is_empty() { + out.push('\n'); + } + out.push_str(§ion); + out.push('\n'); + for line in current_body.iter() { + out.push_str(line); + out.push('\n'); + } + current_body.clear(); + }; + + for raw_line in workspace_manifest.lines() { + let trimmed = raw_line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') && !raw_line.starts_with(' ') { + flush_section(&mut out, &mut current_section, &mut current_body); + current_section = Some(trimmed.to_string()); + continue; + } + + if current_section.is_some() { + current_body.push(raw_line.to_string()); + } + } + + flush_section(&mut out, &mut current_section, &mut current_body); + out +} + fn strip_generated_wrapper_args(args: &[String], build_crate: &str) -> Vec { let mut out = Vec::new(); let mut skip_next = false; @@ -556,6 +601,15 @@ fn generate_android_wrapper_manifest( &workspace_root, )); } + + let workspace_deps = extract_workspace_dependencies_section(&workspace_manifest); + if !workspace_deps.trim().is_empty() { + wrapper_manifest.push('\n'); + wrapper_manifest.push_str(&rewrite_wrapper_manifest_paths( + &workspace_deps, + &workspace_root, + )); + } } let wrapper_manifest_path = wrapper_dir.join("Cargo.toml");