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/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 13c28ed75..79e6076e2 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; @@ -23,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; @@ -81,10 +84,16 @@ 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, 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 { @@ -122,10 +131,16 @@ 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"), 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 +172,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 +230,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)? @@ -454,12 +478,138 @@ 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, 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() @@ -577,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 } @@ -623,6 +777,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 }); } @@ -669,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(()); @@ -690,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(()); @@ -711,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 { @@ -964,6 +1122,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, @@ -1537,6 +1771,399 @@ 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 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(&args) + .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]); + // 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(()) +} + +fn adb_enable_package(config: &TestConfig, package: &str) -> TestResult<()> { + let _ = adb_exec(config, &["shell", "pm", "enable", package]); + 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.READ_CONTACTS", + "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); + 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: 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)); + + 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/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/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/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/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(); diff --git a/platform/src/os/linux/android/android_jni.rs b/platform/src/os/linux/android/android_jni.rs index c5a2b82e8..301cb4b49 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 { @@ -448,11 +449,14 @@ 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()); 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 +475,21 @@ 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); + } + + 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/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/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] 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/cargo_makepad/src/android/compile.rs b/tools/cargo_makepad/src/android/compile.rs index 51576406d..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"); @@ -1011,12 +1065,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 +1158,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 +1202,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 +1220,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 +2727,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 +2889,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/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 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; 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, } } }