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/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/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/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). 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) 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");