Compare commits
11 commits
be57d63b48
...
4a166606c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a166606c0 | |||
| aad7b2a2d3 | |||
| b9629224d9 | |||
| 54860b193a | |||
| ca9b399733 | |||
| 860642b059 | |||
| 045e352e2e | |||
| a95d66c874 | |||
| db2f5e18e9 | |||
|
|
e40a5318f7 |
||
|
|
dd4c8309d9 |
37 changed files with 2610 additions and 139 deletions
|
|
@ -6,15 +6,8 @@ app_main!(App);
|
||||||
|
|
||||||
script_mod! {
|
script_mod! {
|
||||||
use mod.prelude.widgets.*
|
use mod.prelude.widgets.*
|
||||||
let state = {
|
|
||||||
counter: 0
|
|
||||||
}
|
|
||||||
mod.state = state
|
|
||||||
startup() do #(App::script_component(vm)){
|
startup() do #(App::script_component(vm)){
|
||||||
ui: Root{
|
ui: Root{
|
||||||
on_startup:||{ // right now render isnt called automatically yet
|
|
||||||
ui.main_view.render()
|
|
||||||
}
|
|
||||||
main_window := Window{
|
main_window := Window{
|
||||||
window.inner_size: vec2(420, 220)
|
window.inner_size: vec2(420, 220)
|
||||||
body +: {
|
body +: {
|
||||||
|
|
@ -24,11 +17,9 @@ script_mod! {
|
||||||
flow: Down
|
flow: Down
|
||||||
spacing: 12
|
spacing: 12
|
||||||
align: Center
|
align: Center
|
||||||
on_render: ||{
|
counter_label := Label{
|
||||||
counter_label := Label{
|
text: "Count: 0"
|
||||||
text: "Count: " + state.counter
|
draw_text.text_style.font_size: 24
|
||||||
draw_text.text_style.font_size: 24
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
increment_button := Button{
|
increment_button := Button{
|
||||||
|
|
@ -44,15 +35,15 @@ script_mod! {
|
||||||
pub struct App {
|
pub struct App {
|
||||||
#[live]
|
#[live]
|
||||||
ui: WidgetRef,
|
ui: WidgetRef,
|
||||||
|
#[rust]
|
||||||
|
counter: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MatchEvent for App {
|
impl MatchEvent for App {
|
||||||
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
|
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
|
||||||
if self.ui.button(cx, ids!(increment_button)).clicked(actions) {
|
if self.ui.button(cx, ids!(increment_button)).clicked(actions) {
|
||||||
script_eval!(cx,{
|
self.counter += 1;
|
||||||
mod.state.counter += 1
|
self.ui.label(cx, ids!(counter_label)).set_text(cx, &format!("Count: {}", self.counter));
|
||||||
ui.main_view.render()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
195
libs/makepad_test/ANDROID.md
Normal file
195
libs/makepad_test/ANDROID.md
Normal file
|
|
@ -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:<port>` on the host
|
||||||
|
2. forwards that port to the device with `adb reverse tcp:<port> tcp:<port>`
|
||||||
|
3. builds the APK through `cargo-makepad` (`android build -p <package>`)
|
||||||
|
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:<port>` 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.<pkg>/.MakepadApp` | no `--cfg native_activity` | default |
|
||||||
|
| NativeActivity | `<pkg>/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 <pkg>`.
|
||||||
|
`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 <activity>
|
||||||
|
-e makepad.STUDIO_HOST 127.0.0.1:<port>
|
||||||
|
-e makepad.STUDIO_BUILD <build_id>
|
||||||
|
-e makepad.STUDIO_CRATE <package>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Purpose | Default |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `MAKEPAD_TEST_ANDROID` | enable Android mode (any truthy value) | unset (off) |
|
||||||
|
| `MAKEPAD_TEST_DEVICE` | adb device serial (`-s <serial>`) | 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:<port>` 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 `<workspace>/target/release/cargo-makepad`:
|
||||||
|
`cargo build --release -p cargo-makepad`.
|
||||||
|
- The APK lands at
|
||||||
|
`target/android/makepad-android-apk/<pkg_underscored>/apk/<pkg_underscored>.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="<repo>/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 <serial> 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="<repo>/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="<repo>/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/<package>/<test>/`
|
||||||
|
(`failure.txt`, `failure-screenshot.png`, logs), exactly like desktop mode.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause / fix |
|
||||||
|
|---------|--------------------|
|
||||||
|
| `adb: error: device '<serial>' 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 <serial>` 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
|
||||||
149
libs/makepad_test/DESKTOP_VISIBLE.md
Normal file
149
libs/makepad_test/DESKTOP_VISIBLE.md
Normal file
|
|
@ -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/<package>/<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
|
||||||
|
|
@ -5,8 +5,10 @@ use makepad_micro_serde::{SerBin, SerJson};
|
||||||
use makepad_studio_hub::{HubConfig, HubConnection, MountConfig, StudioHub};
|
use makepad_studio_hub::{HubConfig, HubConnection, MountConfig, StudioHub};
|
||||||
use makepad_studio_protocol::hub_protocol::{ClientToHub, HubToClient, LogEntry, QueryId};
|
use makepad_studio_protocol::hub_protocol::{ClientToHub, HubToClient, LogEntry, QueryId};
|
||||||
use makepad_studio_protocol::{
|
use makepad_studio_protocol::{
|
||||||
KeyCode, KeyEvent, KeyModifiers, MouseButton, RemoteKeyModifiers, RemoteMouseDown,
|
KeyCode, KeyEvent, KeyModifiers, MouseButton, RemoteIMEComposition, RemoteKeyModifiers,
|
||||||
RemoteMouseMove, RemoteMouseUp, RemoteScroll, StudioToApp, StudioToAppVec, WidgetSnapshot,
|
RemoteLongPress, RemoteMouseDown, RemoteMouseMove, RemoteMouseUp, RemoteScroll,
|
||||||
|
RemoteTextPaste, RemoteTouchPoint, RemoteTouchState, RemoteTouchUpdate, StudioToApp,
|
||||||
|
StudioToAppVec, WidgetSnapshot,
|
||||||
};
|
};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::cmp;
|
use std::cmp;
|
||||||
|
|
@ -23,6 +25,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(600);
|
const STARTUP_TIMEOUT: Duration = Duration::from_secs(600);
|
||||||
const ACTION_TIMEOUT: Duration = Duration::from_secs(10);
|
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 SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
const STARTUP_RETRIES: usize = 2;
|
const STARTUP_RETRIES: usize = 2;
|
||||||
|
|
@ -81,10 +84,16 @@ pub struct TestConfig {
|
||||||
pub env: HashMap<String, String>,
|
pub env: HashMap<String, String>,
|
||||||
pub startup_timeout: Duration,
|
pub startup_timeout: Duration,
|
||||||
pub action_timeout: Duration,
|
pub action_timeout: Duration,
|
||||||
|
pub wait_timeout: Duration,
|
||||||
pub poll_interval: Duration,
|
pub poll_interval: Duration,
|
||||||
pub startup_pause: Duration,
|
pub startup_pause: Duration,
|
||||||
pub action_delay: Duration,
|
pub action_delay: Duration,
|
||||||
pub keep_open: Duration,
|
pub keep_open: Duration,
|
||||||
|
pub android: bool,
|
||||||
|
pub device_serial: Option<String>,
|
||||||
|
pub adb_path: Option<String>,
|
||||||
|
pub android_port: u16,
|
||||||
|
pub android_native_activity: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestConfig {
|
impl TestConfig {
|
||||||
|
|
@ -122,10 +131,16 @@ impl TestConfig {
|
||||||
env,
|
env,
|
||||||
startup_timeout: STARTUP_TIMEOUT,
|
startup_timeout: STARTUP_TIMEOUT,
|
||||||
action_timeout: ACTION_TIMEOUT,
|
action_timeout: ACTION_TIMEOUT,
|
||||||
|
wait_timeout: WAIT_TIMEOUT,
|
||||||
poll_interval: POLL_INTERVAL,
|
poll_interval: POLL_INTERVAL,
|
||||||
startup_pause: env_duration_ms("MAKEPAD_TEST_STARTUP_DELAY_MS"),
|
startup_pause: env_duration_ms("MAKEPAD_TEST_STARTUP_DELAY_MS"),
|
||||||
action_delay: env_duration_ms("MAKEPAD_TEST_ACTION_DELAY_MS"),
|
action_delay: env_duration_ms("MAKEPAD_TEST_ACTION_DELAY_MS"),
|
||||||
keep_open: env_duration_ms("MAKEPAD_TEST_KEEP_OPEN_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),
|
Self::Remote(connection) => connection.recv_timeout(timeout),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn studio_addr(&self) -> Option<String> {
|
||||||
|
match self {
|
||||||
|
Self::InProcess(connection) => connection.studio_addr(),
|
||||||
|
Self::Remote(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TestAppInner {
|
struct TestAppInner {
|
||||||
|
|
@ -208,7 +230,9 @@ impl TestApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_once(config: TestConfig) -> TestResult<Self> {
|
fn start_once(config: TestConfig) -> TestResult<Self> {
|
||||||
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)?
|
start_visible_app(&config)?
|
||||||
} else {
|
} else {
|
||||||
start_headless_app(&config)?
|
start_headless_app(&config)?
|
||||||
|
|
@ -454,12 +478,138 @@ impl TestApp {
|
||||||
Ok(())
|
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<str>) {
|
||||||
|
if let Err(err) = self.try_paste_text(text) {
|
||||||
|
panic_for_error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_paste_text(&self, text: impl AsRef<str>) -> 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<str>) {
|
||||||
|
if let Err(err) = self.try_ime_composition(text) {
|
||||||
|
panic_for_error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_ime_composition(&self, text: impl AsRef<str>) -> TestResult<()> {
|
||||||
|
let text = text.as_ref().to_string();
|
||||||
|
self.try_forward(vec![StudioToApp::IMEComposition(
|
||||||
|
RemoteIMEComposition { text },
|
||||||
|
)])?;
|
||||||
|
self.pace_after_action();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn query_widgets(
|
fn query_widgets(
|
||||||
&self,
|
&self,
|
||||||
selector: &Selector,
|
selector: &Selector,
|
||||||
visible_only: bool,
|
visible_only: bool,
|
||||||
) -> TestResult<Vec<WidgetSnapshot>> {
|
) -> TestResult<Vec<WidgetSnapshot>> {
|
||||||
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 (primary_window_id, primary_window_index) = primary_window_scope(&widgets);
|
||||||
let mut matches: Vec<_> = widgets
|
let mut matches: Vec<_> = widgets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -577,6 +727,10 @@ impl TestApp {
|
||||||
self.inner.borrow().config.action_timeout
|
self.inner.borrow().config.action_timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wait_timeout(&self) -> Duration {
|
||||||
|
self.inner.borrow().config.wait_timeout
|
||||||
|
}
|
||||||
|
|
||||||
fn poll_interval(&self) -> Duration {
|
fn poll_interval(&self) -> Duration {
|
||||||
self.inner.borrow().config.poll_interval
|
self.inner.borrow().config.poll_interval
|
||||||
}
|
}
|
||||||
|
|
@ -623,6 +777,10 @@ impl TestApp {
|
||||||
if inner.build_stopped.is_some() {
|
if inner.build_stopped.is_some() {
|
||||||
return;
|
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 build_id = inner.build_id;
|
||||||
let _ = inner.connection.send(ClientToHub::ClearBuild { build_id });
|
let _ = inner.connection.send(ClientToHub::ClearBuild { build_id });
|
||||||
}
|
}
|
||||||
|
|
@ -669,7 +827,7 @@ impl Locator {
|
||||||
|
|
||||||
pub fn try_wait_visible(&self) -> TestResult<()> {
|
pub fn try_wait_visible(&self) -> TestResult<()> {
|
||||||
let query = self.selector.describe();
|
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 {
|
while Instant::now() < deadline {
|
||||||
if !self.app.query_widgets(&self.selector, true)?.is_empty() {
|
if !self.app.query_widgets(&self.selector, true)?.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -690,7 +848,7 @@ impl Locator {
|
||||||
|
|
||||||
pub fn try_wait_hidden(&self) -> TestResult<()> {
|
pub fn try_wait_hidden(&self) -> TestResult<()> {
|
||||||
let query = self.selector.describe();
|
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 {
|
while Instant::now() < deadline {
|
||||||
if self.app.query_widgets(&self.selector, true)?.is_empty() {
|
if self.app.query_widgets(&self.selector, true)?.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -711,7 +869,7 @@ impl Locator {
|
||||||
|
|
||||||
pub fn try_wait_count(&self, expected: usize) -> TestResult<()> {
|
pub fn try_wait_count(&self, expected: usize) -> TestResult<()> {
|
||||||
let query = self.selector.describe();
|
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 {
|
while Instant::now() < deadline {
|
||||||
let count = self.app.query_widgets(&self.selector, true)?.len();
|
let count = self.app.query_widgets(&self.selector, true)?.len();
|
||||||
if count == expected {
|
if count == expected {
|
||||||
|
|
@ -964,6 +1122,82 @@ impl Locator {
|
||||||
self.app.try_drag_from(&target, dx, dy)
|
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<str>) -> Self {
|
||||||
|
if let Err(err) = self.try_paste(text) {
|
||||||
|
panic_for_error(err);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_paste(&self, text: impl AsRef<str>) -> TestResult<()> {
|
||||||
|
self.try_click()?;
|
||||||
|
self.app.try_paste_text(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ime_composition(self, text: impl AsRef<str>) -> Self {
|
||||||
|
if let Err(err) = self.try_ime_composition(text) {
|
||||||
|
panic_for_error(err);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_ime_composition(&self, text: impl AsRef<str>) -> TestResult<()> {
|
||||||
|
self.try_click()?;
|
||||||
|
self.app.try_ime_composition(text)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn snapshot(&self) -> WidgetSnapshot {
|
pub fn snapshot(&self) -> WidgetSnapshot {
|
||||||
match self.try_snapshot() {
|
match self.try_snapshot() {
|
||||||
Ok(widget) => widget,
|
Ok(widget) => widget,
|
||||||
|
|
@ -1537,6 +1771,399 @@ fn env_duration_ms(name: &str) -> Duration {
|
||||||
.unwrap_or(Duration::ZERO)
|
.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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<std::path::PathBuf> {
|
||||||
|
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::<u16>().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 {
|
fn now_seconds() -> f64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|
|
||||||
|
|
@ -92,24 +92,50 @@ pub enum HttpServerRequest {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_http_server(http_server: HttpServer) -> Option<std::thread::JoinHandle<()>> {
|
/// 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<HttpServerHandle> {
|
||||||
let listener = if let Ok(listener) = TcpListener::bind(http_server.listen_address) {
|
let listener = if let Ok(listener) = TcpListener::bind(http_server.listen_address) {
|
||||||
listener
|
listener
|
||||||
} else {
|
} else {
|
||||||
println!("Cannot bind http server port");
|
println!("Cannot bind http server port");
|
||||||
return None;
|
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 = {
|
let listen_thread = {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut connection_counter = 0u64;
|
let mut connection_counter = 0u64;
|
||||||
for tcp_stream in listener.incoming() {
|
loop {
|
||||||
let mut tcp_stream = if let Ok(tcp_stream) = tcp_stream {
|
if shutdown_rx.try_recv().is_ok() {
|
||||||
tcp_stream
|
break;
|
||||||
} else {
|
}
|
||||||
println!("Incoming stream failure");
|
let mut tcp_stream = match listener.accept() {
|
||||||
continue;
|
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();
|
let http_server = http_server.clone();
|
||||||
connection_counter += 1;
|
connection_counter += 1;
|
||||||
// Shed over the cap INLINE (no thread): the pile of leaked
|
// Shed over the cap INLINE (no thread): the pile of leaked
|
||||||
|
|
@ -156,7 +182,10 @@ pub fn start_http_server(http_server: HttpServer) -> Option<std::thread::JoinHan
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
Some(listen_thread)
|
Some(HttpServerHandle {
|
||||||
|
thread: listen_thread,
|
||||||
|
shutdown: shutdown_tx,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_post(
|
fn handle_post(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pub mod web_socket_parser;
|
||||||
|
|
||||||
pub use crate::backend::{EventSink, NetworkBackend, UnsupportedBackend};
|
pub use crate::backend::{EventSink, NetworkBackend, UnsupportedBackend};
|
||||||
pub use crate::http_server::{
|
pub use crate::http_server::{
|
||||||
start_http_server, HttpServer, HttpServerRequest, HttpServerResponse,
|
start_http_server, HttpServer, HttpServerHandle, HttpServerRequest, HttpServerResponse,
|
||||||
};
|
};
|
||||||
pub use crate::runtime::{NetworkConfig, NetworkRuntime};
|
pub use crate::runtime::{NetworkConfig, NetworkRuntime};
|
||||||
pub use crate::socket_stream::SocketStream;
|
pub use crate::socket_stream::SocketStream;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use std::time::Duration;
|
||||||
use makepad_live_id::LiveId;
|
use makepad_live_id::LiveId;
|
||||||
|
|
||||||
use crate::backend::{default_backend, EventSink, NetworkBackend};
|
use crate::backend::{default_backend, EventSink, NetworkBackend};
|
||||||
use crate::http_server::HttpServer;
|
use crate::http_server::{HttpServer, HttpServerHandle};
|
||||||
use crate::types::{HttpRequest, NetworkError, NetworkResponse, WsSend};
|
use crate::types::{HttpRequest, NetworkError, NetworkResponse, WsSend};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|
@ -62,7 +62,7 @@ impl NetworkRuntime {
|
||||||
pub fn start_http_server(
|
pub fn start_http_server(
|
||||||
&self,
|
&self,
|
||||||
http_server: HttpServer,
|
http_server: HttpServer,
|
||||||
) -> Option<std::thread::JoinHandle<()>> {
|
) -> Option<HttpServerHandle> {
|
||||||
crate::http_server::start_http_server(http_server)
|
crate::http_server::start_http_server(http_server)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ mod performance_stats;
|
||||||
pub mod memory_watchdog;
|
pub mod memory_watchdog;
|
||||||
pub mod perf_monitor;
|
pub mod perf_monitor;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
|
mod screen;
|
||||||
mod texture;
|
mod texture;
|
||||||
mod uniform_buffer;
|
mod uniform_buffer;
|
||||||
mod window;
|
mod window;
|
||||||
|
|
@ -215,6 +216,7 @@ pub use {
|
||||||
unregister_media_playback_session, MediaPlaybackSessionId,
|
unregister_media_playback_session, MediaPlaybackSessionId,
|
||||||
},
|
},
|
||||||
script::vm::*,
|
script::vm::*,
|
||||||
|
screen::{fit_window_rect_to_screens, ScreenGeom, MIN_WINDOW_SIZE},
|
||||||
shared_bytes::{MappedBytes, SharedBytes, SharedBytesStats},
|
shared_bytes::{MappedBytes, SharedBytes, SharedBytesStats},
|
||||||
texture::{
|
texture::{
|
||||||
image_cache_use_mipmaps, Texture, TextureAnimation, TextureFormat, TextureId,
|
image_cache_use_mipmaps, Texture, TextureAnimation, TextureFormat, TextureId,
|
||||||
|
|
|
||||||
|
|
@ -1539,11 +1539,12 @@ impl Cx {
|
||||||
match op {
|
match op {
|
||||||
CxOsOp::CreateWindow(window_id) => {
|
CxOsOp::CreateWindow(window_id) => {
|
||||||
let window = &mut self.windows[window_id];
|
let window = &mut self.windows[window_id];
|
||||||
|
let (create_position, create_inner_size) = window.create_geom();
|
||||||
let mut metal_window = MetalWindow::new(
|
let mut metal_window = MetalWindow::new(
|
||||||
window_id,
|
window_id,
|
||||||
&metal_cx,
|
&metal_cx,
|
||||||
window.create_inner_size.unwrap_or(dvec2(800., 600.)),
|
create_inner_size,
|
||||||
window.create_position,
|
create_position,
|
||||||
&window.create_title,
|
&window.create_title,
|
||||||
window.is_fullscreen,
|
window.is_fullscreen,
|
||||||
window.macos,
|
window.macos,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use {
|
||||||
MouseUpEvent, ScrollEvent, ScrollPhase, TextInputEvent, WindowCloseRequestedEvent,
|
MouseUpEvent, ScrollEvent, ScrollPhase, TextInputEvent, WindowCloseRequestedEvent,
|
||||||
WindowDragQueryEvent, WindowDragQueryResponse, WindowGeom, WindowGeomChangeEvent,
|
WindowDragQueryEvent, WindowDragQueryResponse, WindowGeom, WindowGeomChangeEvent,
|
||||||
},
|
},
|
||||||
makepad_math::{Rect, Vec2d},
|
makepad_math::{dvec2, Rect, Vec2d},
|
||||||
os::{
|
os::{
|
||||||
apple::apple_sys::*,
|
apple::apple_sys::*,
|
||||||
apple::apple_util::str_to_nsstring,
|
apple::apple_util::str_to_nsstring,
|
||||||
|
|
@ -18,6 +18,7 @@ use {
|
||||||
macos_event::MacosEvent,
|
macos_event::MacosEvent,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
screen::{clamp_point_to_screens, fit_window_rect_to_screens, ScreenGeom},
|
||||||
window::{
|
window::{
|
||||||
MacosWindowChrome, MacosWindowConfig, MacosWindowKind, MacosWindowLevel,
|
MacosWindowChrome, MacosWindowConfig, MacosWindowKind, MacosWindowLevel,
|
||||||
WindowBackdrop, WindowId, WindowVisuals,
|
WindowBackdrop, WindowId, WindowVisuals,
|
||||||
|
|
@ -255,9 +256,13 @@ impl MacosWindow {
|
||||||
let () = msg_send![self.view, setAllowedTouchTypes: 2u64];
|
let () = msg_send![self.view, setAllowedTouchTypes: 2u64];
|
||||||
|
|
||||||
let left_top = if let Some(position) = position {
|
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 {
|
NSPoint {
|
||||||
x: position.x as f64,
|
x: pinned.x,
|
||||||
y: position.y as f64,
|
y: pinned.y,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
NSPoint { x: 0., y: 0. }
|
NSPoint { x: 0., y: 0. }
|
||||||
|
|
@ -350,6 +355,11 @@ impl MacosWindow {
|
||||||
if position.is_none() {
|
if position.is_none() {
|
||||||
let () = msg_send![self.window, center];
|
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 input_context: ObjcId = msg_send![self.view, inputContext];
|
||||||
let () = msg_send![input_context, invalidateCharacterCoordinates];
|
let () = msg_send![input_context, invalidateCharacterCoordinates];
|
||||||
|
|
@ -763,12 +773,34 @@ impl MacosWindow {
|
||||||
let mut window_frame: NSRect = unsafe { msg_send![self.window, frame] };
|
let mut window_frame: NSRect = unsafe { msg_send![self.window, frame] };
|
||||||
window_frame.origin.x = pos.x as f64;
|
window_frame.origin.x = pos.x as f64;
|
||||||
window_frame.origin.y = pos.y 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 {
|
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 {
|
pub fn get_position(&self) -> Vec2d {
|
||||||
let window_frame: NSRect = unsafe { msg_send![self.window, frame] };
|
let window_frame: NSRect = unsafe { msg_send![self.window, frame] };
|
||||||
Vec2d {
|
Vec2d {
|
||||||
|
|
@ -1183,3 +1215,52 @@ pub fn get_cocoa_window(this: &Object) -> &mut MacosWindow {
|
||||||
&mut *(ptr as *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<ScreenGeom> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
use {
|
use {
|
||||||
crate::cx::Cx,
|
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)]
|
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
|
||||||
|
|
@ -10,21 +17,58 @@ pub enum EventFlow {
|
||||||
Exit,
|
Exit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The directory holding the running executable, queried once.
|
||||||
|
fn exe_dir() -> Option<&'static Path> {
|
||||||
|
static EXE_DIR: OnceLock<Option<PathBuf>> = 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<Path>) -> Option<PathBuf> {
|
||||||
|
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<Path>) -> Option<Vec<u8>> {
|
||||||
|
fn read(path: &Path) -> Option<Vec<u8>> {
|
||||||
|
let mut buffer = Vec::<u8>::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
|
// lets start a websocket thread
|
||||||
|
|
||||||
impl Cx {
|
impl Cx {
|
||||||
pub fn native_load_dependencies(&mut self) {
|
pub fn native_load_dependencies(&mut self) {
|
||||||
for (path, dep) in &mut self.dependencies {
|
for (path, dep) in &mut self.dependencies {
|
||||||
if let Ok(mut file_handle) = File::open(path) {
|
if let Some(buffer) = read_file_cwd_or_exe_relative(path) {
|
||||||
let mut buffer = Vec::<u8>::new();
|
dep.data = Some(Ok(Rc::new(buffer)));
|
||||||
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()));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
println!("Could not load resource {}", path);
|
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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -710,6 +710,84 @@ impl Cx {
|
||||||
StudioToApp::Custom(data) => {
|
StudioToApp::Custom(data) => {
|
||||||
self.call_event_handler(&Event::Custom(data));
|
self.call_event_handler(&Event::Custom(data));
|
||||||
}
|
}
|
||||||
|
StudioToApp::TouchUpdate(remote_touch) => {
|
||||||
|
let touches: Vec<crate::event::finger::TouchPoint> = 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::KeepAlive | StudioToApp::None => {}
|
||||||
StudioToApp::LiveChange { file_name, content } => {
|
StudioToApp::LiveChange { file_name, content } => {
|
||||||
self.script_data
|
self.script_data
|
||||||
|
|
|
||||||
|
|
@ -633,10 +633,13 @@ impl Cx {
|
||||||
}
|
}
|
||||||
|
|
||||||
let window = &mut self.windows[window_id];
|
let window = &mut self.windows[window_id];
|
||||||
let inner_size = window
|
let (position, inner_size) = window.create_geom();
|
||||||
.create_inner_size
|
let inner_size = if window.create_inner_size.is_some() {
|
||||||
.unwrap_or_else(|| dvec2(1920.0, 1080.0));
|
inner_size
|
||||||
let position = window.create_position.unwrap_or_else(|| dvec2(0.0, 0.0));
|
} else {
|
||||||
|
dvec2(1920.0, 1080.0)
|
||||||
|
};
|
||||||
|
let position = position.unwrap_or_else(|| dvec2(0.0, 0.0));
|
||||||
let dpi_factor = configured_headless_dpi();
|
let dpi_factor = configured_headless_dpi();
|
||||||
|
|
||||||
let state = &mut windows[window_id.id()];
|
let state = &mut windows[window_id.id()];
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,9 @@ impl Cx {
|
||||||
self.display_context.screen_size = self.os.display_size / dpi_factor;
|
self.display_context.screen_size = self.os.display_size / dpi_factor;
|
||||||
self.display_context.safe_area_insets = insets;
|
self.display_context.safe_area_insets = insets;
|
||||||
self.update_safe_inset_script_values(insets);
|
self.update_safe_inset_script_values(insets);
|
||||||
|
Self::send_studio_message(AppToStudio::BeforeStartup);
|
||||||
self.call_event_handler(&Event::Startup);
|
self.call_event_handler(&Event::Startup);
|
||||||
|
Self::send_studio_message(AppToStudio::AfterStartup);
|
||||||
self.redraw_all();
|
self.redraw_all();
|
||||||
|
|
||||||
self.start_network_live_file_watcher();
|
self.start_network_live_file_watcher();
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,7 @@ unsafe fn get_intent_string_extra(
|
||||||
const MAKEPAD_PREFS_NAME: &str = "makepad";
|
const MAKEPAD_PREFS_NAME: &str = "makepad";
|
||||||
const MAKEPAD_STUDIO_HOST_PREF_KEY: &str = "studio_host";
|
const MAKEPAD_STUDIO_HOST_PREF_KEY: &str = "studio_host";
|
||||||
const MAKEPAD_STUDIO_CRATE_PREF_KEY: &str = "studio_crate";
|
const MAKEPAD_STUDIO_CRATE_PREF_KEY: &str = "studio_crate";
|
||||||
|
const MAKEPAD_STUDIO_BUILD_PREF_KEY: &str = "studio_build";
|
||||||
const ANDROID_MODE_PRIVATE: i32 = 0;
|
const ANDROID_MODE_PRIVATE: i32 = 0;
|
||||||
|
|
||||||
unsafe fn new_jstring(env: *mut jni_sys::JNIEnv, value: &str) -> Option<jni_sys::jstring> {
|
unsafe fn new_jstring(env: *mut jni_sys::JNIEnv, value: &str) -> Option<jni_sys::jstring> {
|
||||||
|
|
@ -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_BUILD");
|
||||||
std::env::remove_var("STUDIO_HOST");
|
std::env::remove_var("STUDIO_HOST");
|
||||||
std::env::remove_var("STUDIO_CRATE");
|
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")
|
let intent_studio_host = get_intent_string_extra(env, activity, "makepad.STUDIO_HOST")
|
||||||
.filter(|v| !v.trim().is_empty());
|
.filter(|v| !v.trim().is_empty());
|
||||||
let intent_studio_crate = get_intent_string_extra(env, activity, "makepad.STUDIO_CRATE")
|
let intent_studio_crate = get_intent_string_extra(env, activity, "makepad.STUDIO_CRATE")
|
||||||
.filter(|v| !v.trim().is_empty());
|
.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 {
|
if let Some(studio_host) = intent_studio_host {
|
||||||
let _ = persist_string_pref(env, activity, MAKEPAD_STUDIO_HOST_PREF_KEY, &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);
|
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 {
|
pub unsafe fn attach_jni_env() -> *mut jni_sys::JNIEnv {
|
||||||
|
|
|
||||||
|
|
@ -634,6 +634,7 @@ impl WaylandCx {
|
||||||
let compositor = state.compositor.as_ref().unwrap();
|
let compositor = state.compositor.as_ref().unwrap();
|
||||||
let wm_base = state.wm_base.as_ref().unwrap();
|
let wm_base = state.wm_base.as_ref().unwrap();
|
||||||
let window = &cx.windows[window_id];
|
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() {
|
let app_id = if window.create_app_id.is_empty() {
|
||||||
"Makepad"
|
"Makepad"
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -650,8 +651,8 @@ impl WaylandCx {
|
||||||
state.shm.as_ref(),
|
state.shm.as_ref(),
|
||||||
self.qhandle.as_ref().unwrap(),
|
self.qhandle.as_ref().unwrap(),
|
||||||
gl_cx,
|
gl_cx,
|
||||||
window.create_inner_size.unwrap_or(dvec2(800., 600.)),
|
create_inner_size,
|
||||||
window.create_position,
|
create_position,
|
||||||
&window.create_title,
|
&window.create_title,
|
||||||
app_id,
|
app_id,
|
||||||
window.is_fullscreen,
|
window.is_fullscreen,
|
||||||
|
|
@ -765,6 +766,9 @@ impl WaylandCx {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CxOsOp::ResizeWindow(window_id, size) => {}
|
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::RepositionWindow(window_id, size) => {}
|
||||||
CxOsOp::SetWindowVisuals(_window_id, visuals) => {
|
CxOsOp::SetWindowVisuals(_window_id, visuals) => {
|
||||||
if visuals.backdrop != crate::window::WindowBackdrop::None {
|
if visuals.backdrop != crate::window::WindowBackdrop::None {
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,19 @@ impl WaylandWindow {
|
||||||
}
|
}
|
||||||
base_surface.commit();
|
base_surface.commit();
|
||||||
|
|
||||||
let wl_egl_surface =
|
// `wl_egl_window_create` rejects a non-positive extent, and a float-to-int cast turns
|
||||||
WlEglSurface::new(base_surface.id(), inner_size.x as i32, inner_size.y as i32).unwrap();
|
// 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 {
|
let egl_surface = unsafe {
|
||||||
(opengl_cx.libegl.eglCreateWindowSurface.unwrap())(
|
(opengl_cx.libegl.eglCreateWindowSurface.unwrap())(
|
||||||
opengl_cx.egl_display,
|
opengl_cx.egl_display,
|
||||||
|
|
|
||||||
|
|
@ -550,11 +550,12 @@ impl X11Cx {
|
||||||
CxOsOp::CreateWindow(window_id) => {
|
CxOsOp::CreateWindow(window_id) => {
|
||||||
let gl_cx = cx.os.opengl_cx.as_ref().unwrap();
|
let gl_cx = cx.os.opengl_cx.as_ref().unwrap();
|
||||||
let window = &cx.windows[window_id];
|
let window = &cx.windows[window_id];
|
||||||
|
let (create_position, create_inner_size) = window.create_geom();
|
||||||
let opengl_window = OpenglWindow::new(
|
let opengl_window = OpenglWindow::new(
|
||||||
window_id,
|
window_id,
|
||||||
gl_cx,
|
gl_cx,
|
||||||
window.create_inner_size.unwrap_or(dvec2(800., 600.)),
|
create_inner_size,
|
||||||
window.create_position,
|
create_position,
|
||||||
&window.create_title,
|
&window.create_title,
|
||||||
&window.create_app_id,
|
&window.create_app_id,
|
||||||
window.is_fullscreen,
|
window.is_fullscreen,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
pub mod linux_x11;
|
pub mod linux_x11;
|
||||||
pub mod linux_x11_stdin;
|
pub mod linux_x11_stdin;
|
||||||
pub mod opengl_x11;
|
pub mod opengl_x11;
|
||||||
|
pub mod x11_screen;
|
||||||
pub mod x11_sys;
|
pub mod x11_sys;
|
||||||
pub mod xlib_app;
|
pub mod xlib_app;
|
||||||
pub mod xlib_event;
|
pub mod xlib_event;
|
||||||
|
|
|
||||||
127
platform/src/os/linux/x11/x11_screen.rs
Normal file
127
platform/src/os/linux/x11/x11_screen.rs
Normal file
|
|
@ -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<c_long> {
|
||||||
|
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<Rect> {
|
||||||
|
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::<x11_sys::XWindowAttributes>::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<ScreenGeom> {
|
||||||
|
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,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
use {
|
use {
|
||||||
self::super::{x11_sys, xlib_app::*, xlib_event::XlibEvent},
|
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::{
|
std::{
|
||||||
cell::Cell,
|
cell::Cell,
|
||||||
ffi::{CStr, CString},
|
ffi::{CStr, CString},
|
||||||
|
|
@ -110,22 +116,26 @@ impl XlibWindow {
|
||||||
| x11_sys::LeaveWindowMask) as c_long;
|
| x11_sys::LeaveWindowMask) as c_long;
|
||||||
|
|
||||||
let dpi_factor = self.get_dpi_factor();
|
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
|
// 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(
|
let window = x11_sys::XCreateWindow(
|
||||||
display,
|
display,
|
||||||
root_window,
|
root_window,
|
||||||
if position.is_some() {
|
create_x,
|
||||||
position.unwrap().x
|
create_y,
|
||||||
} else {
|
clamp_extent(size.x * dpi_factor),
|
||||||
150.0
|
clamp_extent(size.y * dpi_factor),
|
||||||
} 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,
|
|
||||||
0,
|
0,
|
||||||
visual_info.depth,
|
visual_info.depth,
|
||||||
x11_sys::InputOutput as u32,
|
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 {
|
pub fn get_position(&self) -> Vec2d {
|
||||||
unsafe {
|
unsafe {
|
||||||
let display = get_xlib_app_global().display;
|
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) {
|
pub fn set_position(&mut self, pos: Vec2d) {
|
||||||
unsafe {
|
unsafe {
|
||||||
let display = get_xlib_app_global().display;
|
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(
|
x11_sys::XMoveWindow(
|
||||||
display,
|
display,
|
||||||
self.window.unwrap(),
|
self.window.unwrap(),
|
||||||
(pos.x * dpi_factor) as i32,
|
clamp_coord(fitted.pos.x),
|
||||||
(pos.y * dpi_factor) as i32,
|
clamp_coord(fitted.pos.y),
|
||||||
);
|
);
|
||||||
x11_sys::XFlush(display);
|
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<Vec2d>,
|
||||||
|
size: Vec2d,
|
||||||
|
dpi_factor: f64,
|
||||||
|
) -> (Option<Vec2d>, 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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ pub mod video_file_decoder;
|
||||||
pub mod video_file_encoder;
|
pub mod video_file_encoder;
|
||||||
pub mod wasapi;
|
pub mod wasapi;
|
||||||
pub mod win32_event;
|
pub mod win32_event;
|
||||||
|
pub mod win32_screen;
|
||||||
pub mod win32_window;
|
pub mod win32_window;
|
||||||
pub mod windows_media;
|
pub mod windows_media;
|
||||||
pub mod windows_media_engine_notify;
|
pub mod windows_media_engine_notify;
|
||||||
|
|
|
||||||
114
platform/src/os/windows/win32_screen.rs
Normal file
114
platform/src/os/windows/win32_screen.rs
Normal file
|
|
@ -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<ScreenGeom> {
|
||||||
|
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<ScreenGeom>) };
|
||||||
|
let mut info = MonitorInfo {
|
||||||
|
cb_size: size_of::<MonitorInfo>() 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<ScreenGeom> 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,9 @@ use {
|
||||||
droptarget::*,
|
droptarget::*,
|
||||||
win32_app::{encode_wide, with_win32_app, Win32App},
|
win32_app::{encode_wide, with_win32_app, Win32App},
|
||||||
win32_event::*,
|
win32_event::*,
|
||||||
|
win32_screen::{win32_screens, workspace_rect_to_screen},
|
||||||
},
|
},
|
||||||
|
screen::{clamp_point_to_screens, fit_window_rect_to_screens},
|
||||||
window::{WindowBackdrop, WindowId, WindowVisuals},
|
window::{WindowBackdrop, WindowId, WindowVisuals},
|
||||||
windows::{
|
windows::{
|
||||||
core::PCWSTR,
|
core::PCWSTR,
|
||||||
|
|
@ -88,6 +90,7 @@ use {
|
||||||
WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEMOVE,
|
WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEMOVE,
|
||||||
WM_MOUSEWHEEL, WM_NCCALCSIZE, WM_NCHITTEST, WM_RBUTTONDOWN, WM_RBUTTONUP,
|
WM_MOUSEWHEEL, WM_NCCALCSIZE, WM_NCHITTEST, WM_RBUTTONDOWN, WM_RBUTTONUP,
|
||||||
WM_SIZE, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP,
|
WM_SIZE, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP,
|
||||||
|
GetWindowPlacement, WINDOWPLACEMENT,
|
||||||
WS_BORDER, WS_CAPTION, WS_CLIPCHILDREN, WS_CLIPSIBLINGS, WS_EX_ACCEPTFILES,
|
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_APPWINDOW, WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST,
|
||||||
WS_EX_WINDOWEDGE, WS_OVERLAPPEDWINDOW, WS_POPUP, WS_THICKFRAME,
|
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)]
|
#[repr(C)]
|
||||||
struct AccentPolicy {
|
struct AccentPolicy {
|
||||||
accent_state: u32,
|
accent_state: u32,
|
||||||
|
|
@ -203,6 +213,11 @@ pub struct Win32Window {
|
||||||
/// Set by `close_window()`; suppresses the WM_ACTIVATE-derived
|
/// Set by `close_window()`; suppresses the WM_ACTIVATE-derived
|
||||||
/// `PopupDismissed(FocusLost)`, which would duplicate the closer's dismissal.
|
/// `PopupDismissed(FocusLost)`, which would duplicate the closer's dismissal.
|
||||||
pub is_closing: Cell<bool>,
|
pub is_closing: Cell<bool>,
|
||||||
|
/// 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<bool>,
|
||||||
pub ignore_wmsize: usize,
|
pub ignore_wmsize: usize,
|
||||||
pub hwnd: HWND,
|
pub hwnd: HWND,
|
||||||
pub track_mouse_event: bool,
|
pub track_mouse_event: bool,
|
||||||
|
|
@ -518,10 +533,22 @@ impl Win32Window {
|
||||||
|
|
||||||
let style_ex = WS_EX_WINDOWEDGE | WS_EX_APPWINDOW | WS_EX_ACCEPTFILES;
|
let style_ex = WS_EX_WINDOWEDGE | WS_EX_APPWINDOW | WS_EX_ACCEPTFILES;
|
||||||
|
|
||||||
let (x, y) = if let Some(position) = position {
|
let (x, y) = match position {
|
||||||
(position.x as i32, position.y as i32)
|
// A restored position can name a display that is gone, or hold values no display
|
||||||
} else {
|
// ever had. Pinning it now keeps `CreateWindowExW` and the sizing that follows
|
||||||
(CW_USEDEFAULT, CW_USEDEFAULT)
|
// 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 {
|
let hwnd = unsafe {
|
||||||
|
|
@ -567,6 +594,7 @@ impl Win32Window {
|
||||||
nc_dq_gen: Cell::new(0),
|
nc_dq_gen: Cell::new(0),
|
||||||
geom_event_gen: Cell::new(0),
|
geom_event_gen: Cell::new(0),
|
||||||
is_closing: Cell::new(false),
|
is_closing: Cell::new(false),
|
||||||
|
in_size_move: Cell::new(false),
|
||||||
ignore_wmsize: 0,
|
ignore_wmsize: 0,
|
||||||
hwnd,
|
hwnd,
|
||||||
track_mouse_event: false,
|
track_mouse_event: false,
|
||||||
|
|
@ -621,6 +649,7 @@ impl Win32Window {
|
||||||
nc_dq_gen: Cell::new(0),
|
nc_dq_gen: Cell::new(0),
|
||||||
geom_event_gen: Cell::new(0),
|
geom_event_gen: Cell::new(0),
|
||||||
is_closing: Cell::new(false),
|
is_closing: Cell::new(false),
|
||||||
|
in_size_move: Cell::new(false),
|
||||||
ignore_wmsize: 0,
|
ignore_wmsize: 0,
|
||||||
hwnd,
|
hwnd,
|
||||||
track_mouse_event: false,
|
track_mouse_event: false,
|
||||||
|
|
@ -649,6 +678,50 @@ impl Win32Window {
|
||||||
self.set_inner_size(size);
|
self.set_inner_size(size);
|
||||||
if self.is_fullscreen {
|
if self.is_fullscreen {
|
||||||
self.maximize();
|
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 => {
|
WM_ENTERSIZEMOVE => {
|
||||||
|
window.in_size_move.set(true);
|
||||||
with_win32_app(|app| app.start_resize());
|
with_win32_app(|app| app.start_resize());
|
||||||
window.do_callback(Win32Event::WindowResizeLoopStart(window.window_id));
|
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 => {
|
WM_EXITSIZEMOVE => {
|
||||||
|
window.in_size_move.set(false);
|
||||||
with_win32_app(|app| app.stop_resize());
|
with_win32_app(|app| app.stop_resize());
|
||||||
window.do_callback(Win32Event::WindowResizeLoopStop(window.window_id));
|
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
|
// WM_SIZING (0x0214) fires BEFORE the window is resized with
|
||||||
// the proposed new rect. By pre-rendering at this size, the
|
// 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
|
// 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.
|
// DPI so send_change_event() (and subsequent hit-tests) re-read the new value.
|
||||||
window.invalidate_cached_dpi();
|
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();
|
window.send_change_event();
|
||||||
}
|
}
|
||||||
WM_DPICHANGED => {
|
WM_DPICHANGED => {
|
||||||
|
|
@ -1071,6 +1165,13 @@ impl Win32Window {
|
||||||
0x0003 => {
|
0x0003 => {
|
||||||
window.nc_dq_cache.set(None);
|
window.nc_dq_cache.set(None);
|
||||||
window.nc_dq_gen.set(window.nc_dq_gen.get().wrapping_add(1));
|
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 => {
|
WM_CLOSE => {
|
||||||
// close requested
|
// close requested
|
||||||
|
|
@ -1370,31 +1471,51 @@ impl Win32Window {
|
||||||
self.ime_rect = rect;
|
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 {
|
unsafe {
|
||||||
let mut rect = RECT {
|
if self.is_iconic() {
|
||||||
left: 0,
|
let mut placement = WINDOWPLACEMENT {
|
||||||
top: 0,
|
length: mem::size_of::<WINDOWPLACEMENT>() as u32,
|
||||||
bottom: 0,
|
..Default::default()
|
||||||
right: 0,
|
};
|
||||||
};
|
if GetWindowPlacement(self.hwnd, &mut placement).is_ok() {
|
||||||
GetWindowRect(self.hwnd, &mut rect).unwrap();
|
return workspace_rect_to_screen(placement.rcNormalPosition);
|
||||||
Vec2d {
|
}
|
||||||
x: rect.left as f64,
|
|
||||||
y: rect.top as f64,
|
|
||||||
}
|
}
|
||||||
|
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 {
|
pub fn get_inner_size(&self) -> Vec2d {
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut rect = RECT {
|
let mut rect = RECT::default();
|
||||||
left: 0,
|
if self.is_iconic() {
|
||||||
top: 0,
|
// A restored window of this backend is fully client-sized (see the
|
||||||
bottom: 0,
|
// `WM_NCCALCSIZE` handler), so its outer rectangle is also its client size.
|
||||||
right: 0,
|
rect = self.outer_rect();
|
||||||
};
|
} else {
|
||||||
GetClientRect(self.hwnd, &mut rect).unwrap();
|
GetClientRect(self.hwnd, &mut rect).unwrap();
|
||||||
|
}
|
||||||
let dpi = self.get_dpi_factor();
|
let dpi = self.get_dpi_factor();
|
||||||
Vec2d {
|
Vec2d {
|
||||||
x: (rect.right - rect.left) as f64 / dpi,
|
x: (rect.right - rect.left) as f64 / dpi,
|
||||||
|
|
@ -1404,22 +1525,19 @@ impl Win32Window {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_outer_size(&self) -> Vec2d {
|
pub fn get_outer_size(&self) -> Vec2d {
|
||||||
unsafe {
|
let rect = self.outer_rect();
|
||||||
let mut rect = RECT {
|
let dpi = self.get_dpi_factor();
|
||||||
left: 0,
|
Vec2d {
|
||||||
top: 0,
|
x: (rect.right - rect.left) as f64 / dpi,
|
||||||
bottom: 0,
|
y: (rect.bottom - rect.top) as f64 / dpi,
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
pub fn set_position(&mut self, pos: Vec2d) {
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut window_rect = RECT {
|
let mut window_rect = RECT {
|
||||||
|
|
@ -1429,13 +1547,23 @@ impl Win32Window {
|
||||||
right: 0,
|
right: 0,
|
||||||
};
|
};
|
||||||
GetWindowRect(self.hwnd, &mut window_rect).unwrap();
|
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(
|
MoveWindow(
|
||||||
self.hwnd,
|
self.hwnd,
|
||||||
(pos.x * dpi) as i32,
|
fitted.pos.x as i32,
|
||||||
(pos.y * dpi) as i32,
|
fitted.pos.y as i32,
|
||||||
window_rect.right - window_rect.left,
|
fitted.size.x as i32,
|
||||||
window_rect.bottom - window_rect.top,
|
fitted.size.y as i32,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -1595,6 +1723,27 @@ impl Win32Window {
|
||||||
Win32App::do_callback(event);
|
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) {
|
pub fn send_change_event(&mut self) {
|
||||||
// Record that a geometry event is published (see `geom_event_gen`).
|
// Record that a geometry event is published (see `geom_event_gen`).
|
||||||
self.geom_event_gen.set(self.geom_event_gen.get().wrapping_add(1));
|
self.geom_event_gen.set(self.geom_event_gen.get().wrapping_add(1));
|
||||||
|
|
|
||||||
|
|
@ -758,11 +758,12 @@ impl Cx {
|
||||||
match op {
|
match op {
|
||||||
CxOsOp::CreateWindow(window_id) => {
|
CxOsOp::CreateWindow(window_id) => {
|
||||||
let window = &mut self.windows[window_id];
|
let window = &mut self.windows[window_id];
|
||||||
|
let (create_position, create_inner_size) = window.create_geom();
|
||||||
let d3d11_window = D3d11Window::new(
|
let d3d11_window = D3d11Window::new(
|
||||||
window_id,
|
window_id,
|
||||||
&d3d11_cx,
|
&d3d11_cx,
|
||||||
window.create_inner_size.unwrap_or(dvec2(800., 600.)),
|
create_inner_size,
|
||||||
window.create_position,
|
create_position,
|
||||||
&window.create_title,
|
&window.create_title,
|
||||||
window.is_fullscreen,
|
window.is_fullscreen,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
497
platform/src/screen.rs
Normal file
497
platform/src/screen.rs
Normal file
|
|
@ -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<Rect> {
|
||||||
|
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<Rect> = 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<Vec2d>, size: Vec2d) -> (Option<Vec2d>, 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<Rect> = 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -247,6 +247,10 @@ fn load_packaged_resource(cx: &Cx, dep_path: &str) -> Option<Rc<Vec<u8>>> {
|
||||||
|
|
||||||
/// Try to load a resource from the packaged location on desktop.
|
/// Try to load a resource from the packaged location on desktop.
|
||||||
/// Returns None when not in packaged mode (package_root is None).
|
/// 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(
|
#[cfg(all(
|
||||||
not(target_arch = "wasm32"),
|
not(target_arch = "wasm32"),
|
||||||
not(any(target_os = "android", target_os = "ios", target_os = "tvos")),
|
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<Rc<Vec<u8>>> {
|
||||||
fn load_packaged_resource(cx: &Cx, dep_path: &str) -> Option<Rc<Vec<u8>>> {
|
fn load_packaged_resource(cx: &Cx, dep_path: &str) -> Option<Rc<Vec<u8>>> {
|
||||||
let root = cx.package_root.as_deref()?;
|
let root = cx.package_root.as_deref()?;
|
||||||
let full_path = format!("{}/{}", root, dep_path);
|
let full_path = format!("{}/{}", root, dep_path);
|
||||||
let mut file = File::open(&full_path).ok()?;
|
crate::os::cx_native::read_file_cwd_or_exe_relative(&full_path).map(Rc::new)
|
||||||
let mut data = Vec::new();
|
|
||||||
file.read_to_end(&mut data).ok()?;
|
|
||||||
Some(Rc::new(data))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a file directly from the filesystem (desktop/mobile only, not wasm).
|
/// Load a file directly from the filesystem (desktop/mobile only, not wasm).
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use crate::{
|
||||||
makepad_math::*,
|
makepad_math::*,
|
||||||
//makepad_live_id::*,
|
//makepad_live_id::*,
|
||||||
makepad_script::*,
|
makepad_script::*,
|
||||||
|
screen::{sanitize_window_geom, DEFAULT_WINDOW_SIZE},
|
||||||
script::vm::*,
|
script::vm::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -513,6 +514,8 @@ impl WindowHandle {
|
||||||
cx.windows[self.window_id()].get_inner_size()
|
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 {
|
pub fn get_position(&self, cx: &Cx) -> Vec2d {
|
||||||
cx.windows[self.window_id()].get_position()
|
cx.windows[self.window_id()].get_position()
|
||||||
}
|
}
|
||||||
|
|
@ -608,6 +611,12 @@ impl WindowHandle {
|
||||||
cx.push_unique_platform_op(CxOsOp::ResizeWindow(self.window_id(), size));
|
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) {
|
pub fn reposition(&self, cx: &mut Cx, position: Vec2d) {
|
||||||
cx.push_unique_platform_op(CxOsOp::RepositionWindow(self.window_id(), position));
|
cx.push_unique_platform_op(CxOsOp::RepositionWindow(self.window_id(), position));
|
||||||
}
|
}
|
||||||
|
|
@ -694,6 +703,22 @@ impl Default for CxWindow {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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>, 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<f64> {
|
pub(crate) fn valid_dpi_factor(dpi_factor: f64) -> Option<f64> {
|
||||||
if dpi_factor.is_finite() && dpi_factor > 0.0 {
|
if dpi_factor.is_finite() && dpi_factor > 0.0 {
|
||||||
Some(dpi_factor)
|
Some(dpi_factor)
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,52 @@ pub struct RemoteScroll {
|
||||||
pub modifiers: RemoteKeyModifiers,
|
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<RemoteTouchPoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(SerBin, DeBin, SerJson, DeJson, Debug, Clone)]
|
||||||
pub enum AppToStudio {
|
pub enum AppToStudio {
|
||||||
LogItem(StudioLogItem),
|
LogItem(StudioLogItem),
|
||||||
|
|
@ -419,6 +465,10 @@ pub enum StudioToApp {
|
||||||
/// changes. Level state rather than edges, because that is what the OS
|
/// changes. Level state rather than edges, because that is what the OS
|
||||||
/// APIs report and what `Cx::game_input_states` hands back.
|
/// APIs report and what `Cx::game_input_states` hands back.
|
||||||
GameInput(Vec<RemoteGameInput>),
|
GameInput(Vec<RemoteGameInput>),
|
||||||
|
TouchUpdate(RemoteTouchUpdate),
|
||||||
|
LongPress(RemoteLongPress),
|
||||||
|
TextPaste(RemoteTextPaste),
|
||||||
|
IMEComposition(RemoteIMEComposition),
|
||||||
/// Application-defined event. Delivered to the app as `Event::Custom`.
|
/// Application-defined event. Delivered to the app as `Event::Custom`.
|
||||||
Custom(String),
|
Custom(String),
|
||||||
#[default]
|
#[default]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ edition = "2021"
|
||||||
description = "Studio2 hub (protocol + gateway + virtual fs)"
|
description = "Studio2 hub (protocol + gateway + virtual fs)"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "hub-server"
|
||||||
|
path = "src/bin/hub_server.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
makepad-script-std = { path = "../../platform/script/std", version = "1.0.0" }
|
makepad-script-std = { path = "../../platform/script/std", version = "1.0.0" }
|
||||||
makepad-studio-protocol = { path = "../../platform/studio", version = "0.1.0" }
|
makepad-studio-protocol = { path = "../../platform/studio", version = "0.1.0" }
|
||||||
|
|
|
||||||
38
studio/hub/src/bin/hub_server.rs
Normal file
38
studio/hub/src/bin/hub_server.rs
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use crate::dispatch::HubEvent;
|
use crate::dispatch::HubEvent;
|
||||||
use makepad_micro_serde::SerBin;
|
use makepad_micro_serde::SerBin;
|
||||||
use makepad_script_std::makepad_network::{
|
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 makepad_studio_protocol::hub_protocol::{HubToClient, QueryId};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -19,8 +20,21 @@ enum SocketRole {
|
||||||
|
|
||||||
pub struct GatewayHandle {
|
pub struct GatewayHandle {
|
||||||
pub listen_address: SocketAddr,
|
pub listen_address: SocketAddr,
|
||||||
pub request_thread: JoinHandle<()>,
|
pub request_thread: Option<JoinHandle<()>>,
|
||||||
pub http_thread: JoinHandle<()>,
|
pub http_thread: Option<JoinHandle<()>>,
|
||||||
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -39,7 +53,10 @@ pub fn start_http_gateway(
|
||||||
event_tx: Sender<HubEvent>,
|
event_tx: Sender<HubEvent>,
|
||||||
) -> Result<GatewayHandle, String> {
|
) -> Result<GatewayHandle, String> {
|
||||||
let (request_tx, request_rx) = mpsc::channel::<HttpServerRequest>();
|
let (request_tx, request_rx) = mpsc::channel::<HttpServerRequest>();
|
||||||
let http_thread = start_http_server(HttpServer {
|
let HttpServerHandle {
|
||||||
|
thread: http_thread,
|
||||||
|
shutdown: http_shutdown,
|
||||||
|
} = start_http_server(HttpServer {
|
||||||
listen_address,
|
listen_address,
|
||||||
request: request_tx,
|
request: request_tx,
|
||||||
post_max_size,
|
post_max_size,
|
||||||
|
|
@ -218,8 +235,9 @@ pub fn start_http_gateway(
|
||||||
|
|
||||||
Ok(GatewayHandle {
|
Ok(GatewayHandle {
|
||||||
listen_address,
|
listen_address,
|
||||||
request_thread,
|
request_thread: Some(request_thread),
|
||||||
http_thread,
|
http_thread: Some(http_thread),
|
||||||
|
http_shutdown,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,51 @@ fn extract_workspace_patch_sections(workspace_manifest: &str) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extract_workspace_dependencies_section(workspace_manifest: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut current_section: Option<String> = None;
|
||||||
|
let mut current_body = Vec::new();
|
||||||
|
|
||||||
|
let flush_section =
|
||||||
|
|out: &mut String, current_section: &mut Option<String>, current_body: &mut Vec<String>| {
|
||||||
|
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<String> {
|
fn strip_generated_wrapper_args(args: &[String], build_crate: &str) -> Vec<String> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut skip_next = false;
|
let mut skip_next = false;
|
||||||
|
|
@ -556,6 +601,15 @@ fn generate_android_wrapper_manifest(
|
||||||
&workspace_root,
|
&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");
|
let wrapper_manifest_path = wrapper_dir.join("Cargo.toml");
|
||||||
|
|
@ -1011,12 +1065,24 @@ fn prepare_build(opts: &PrepareBuildOpts<'_>) -> Result<BuildPaths, String> {
|
||||||
debuggable: opts.debuggable,
|
debuggable: opts.debuggable,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom manifest override: if `<crate>/resources/android/AndroidManifest.xml.template`
|
// Custom manifest override: check two paths in priority order:
|
||||||
// exists, use it after substituting `{key}` placeholders. Useful for declaring a
|
// 1. `<crate>/resources/android/AndroidManifest.xml` — used verbatim (no
|
||||||
// permissions/features set tailored to the app (Play Store rejects most of the
|
// placeholder substitution). Drop a finished manifest and cargo-makepad
|
||||||
// default kitchen-sink permission list without justification).
|
// uses it as-is.
|
||||||
|
// 2. `<crate>/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 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)
|
let template = fs::read_to_string(&custom_template)
|
||||||
.map_err(|e| format!("Cant read custom manifest {:?}: {e}", custom_template))?;
|
.map_err(|e| format!("Cant read custom manifest {:?}: {e}", custom_template))?;
|
||||||
println!(
|
println!(
|
||||||
|
|
@ -1092,8 +1158,34 @@ fn build_r_class(
|
||||||
Ok(())
|
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<Vec<PathBuf>, 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(
|
fn compile_java(
|
||||||
sdk_dir: &Path,
|
sdk_dir: &Path,
|
||||||
|
build_crate: &str,
|
||||||
build_paths: &BuildPaths,
|
build_paths: &BuildPaths,
|
||||||
urls: &AndroidSDKUrls,
|
urls: &AndroidSDKUrls,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
|
@ -1110,7 +1202,7 @@ fn compile_java(
|
||||||
let makepad_java_classes_dir = &cargo_manifest_dir
|
let makepad_java_classes_dir = &cargo_manifest_dir
|
||||||
.join("src/android/java/")
|
.join("src/android/java/")
|
||||||
.join(makepad_package_path);
|
.join(makepad_package_path);
|
||||||
let java_sources = vec![
|
let mut java_sources = vec![
|
||||||
r_class_path.clone(),
|
r_class_path.clone(),
|
||||||
makepad_java_classes_dir.join("MakepadNative.java"),
|
makepad_java_classes_dir.join("MakepadNative.java"),
|
||||||
makepad_java_classes_dir.join("MakepadActivity.java"),
|
makepad_java_classes_dir.join("MakepadActivity.java"),
|
||||||
|
|
@ -1128,6 +1220,20 @@ fn compile_java(
|
||||||
build_paths.xr_file.clone(),
|
build_paths.xr_file.clone(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// App-supplied Java hook: compile every `<crate>/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 `<service>` 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();
|
let mut hasher = DefaultHasher::new();
|
||||||
for source in &java_sources {
|
for source in &java_sources {
|
||||||
source.to_string_lossy().hash(&mut hasher);
|
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`
|
// Reuse the existing R-class / javac / d8 pipeline; outputs `classes.dex`
|
||||||
// into `build_paths.out_dir`.
|
// into `build_paths.out_dir`.
|
||||||
build_r_class(sdk_dir, &build_paths, urls)?;
|
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_dex(sdk_dir, &build_paths, urls)?;
|
||||||
let classes_dex = build_paths.out_dir.join("classes.dex");
|
let classes_dex = build_paths.out_dir.join("classes.dex");
|
||||||
if !classes_dex.is_file() {
|
if !classes_dex.is_file() {
|
||||||
|
|
@ -2783,7 +2889,7 @@ pub fn build(
|
||||||
debuggable
|
debuggable
|
||||||
);
|
);
|
||||||
build_r_class(sdk_dir, &build_paths, urls)?;
|
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_dex(sdk_dir, &build_paths, urls)?;
|
||||||
build_unaligned_apk(sdk_dir, &build_paths, urls)?;
|
build_unaligned_apk(sdk_dir, &build_paths, urls)?;
|
||||||
let build_dir = add_rust_library(
|
let build_dir = add_rust_library(
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ fn main() {
|
||||||
}
|
}
|
||||||
let root_path = args[1].clone();
|
let root_path = args[1].clone();
|
||||||
|
|
||||||
net.start_http_server(HttpServer{
|
let _http_server = net.start_http_server(HttpServer{
|
||||||
listen_address:addr,
|
listen_address:addr,
|
||||||
post_max_size: 1024*1024,
|
post_max_size: 1024*1024,
|
||||||
request: tx_request
|
request: tx_request
|
||||||
|
|
|
||||||
|
|
@ -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-fast-inflate = { path = "../libs/fast_inflate", optional = true }
|
||||||
makepad-voice = { path = "../libs/voice", version = "0.1.0", optional = true }
|
makepad-voice = { path = "../libs/voice", version = "0.1.0", optional = true }
|
||||||
makepad-cef = { path = "../libs/cef", 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" }
|
makepad-html = { path = "../libs/html", version = "1.0.0" }
|
||||||
unicode-segmentation = { version = "1.12.0", path = "../libs/unicode/unicode-segmentation" }
|
unicode-segmentation = { version = "1.12.0", path = "../libs/unicode/unicode-segmentation" }
|
||||||
|
|
@ -33,6 +38,9 @@ default = []
|
||||||
|
|
||||||
voice = ["dep:makepad-voice"]
|
voice = ["dep:makepad-voice"]
|
||||||
maps = ["dep:makepad-mbtile-reader", "dep:makepad-fast-inflate", "dep:i_overlay"]
|
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"]
|
pdf = ["dep:makepad-pdf-parse"]
|
||||||
cef = ["dep:makepad-cef"]
|
cef = ["dep:makepad-cef"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,20 @@ pub use makepad_pdf_parse;
|
||||||
pub use makepad_draw::makepad_zune_jpeg;
|
pub use makepad_draw::makepad_zune_jpeg;
|
||||||
pub use makepad_draw::makepad_zune_png;
|
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)
|
// Core modules (used internally first)
|
||||||
pub mod animator;
|
pub mod animator;
|
||||||
pub mod theme_desktop_dark;
|
pub mod theme_desktop_dark;
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ script_mod! {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Script, ScriptHook, Clone)]
|
#[derive(Script, ScriptHook, Clone, Debug)]
|
||||||
pub struct XrCamera {
|
pub struct XrCamera {
|
||||||
#[live(28.0)]
|
#[live(28.0)]
|
||||||
pub fov_y: f32,
|
pub fov_y: f32,
|
||||||
|
|
@ -82,6 +82,14 @@ pub struct XrCamera {
|
||||||
pub orbit_last_abs: Option<DVec2>,
|
pub orbit_last_abs: Option<DVec2>,
|
||||||
#[rust]
|
#[rust]
|
||||||
pub viewport_rect: Option<Rect>,
|
pub viewport_rect: Option<Rect>,
|
||||||
|
#[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)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
|
|
@ -107,6 +115,10 @@ impl Default for XrCamera {
|
||||||
orbit_pitch: 0.0,
|
orbit_pitch: 0.0,
|
||||||
orbit_last_abs: None,
|
orbit_last_abs: None,
|
||||||
viewport_rect: None,
|
viewport_rect: None,
|
||||||
|
ortho: false,
|
||||||
|
ortho_height: 2.0,
|
||||||
|
ortho_height_min: 0.02,
|
||||||
|
ortho_height_max: 80_000.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue