feat(ui): add runtime inspector and fix HTML layout regressions

Add an opt-in TCP/NDJSON inspector for running eepp applications. It
provides authenticated, UI-thread-safe access to windows, scenes, and
widgets, including nested WebView documents. The v1 protocol supports
queries, trees, properties, focus, input actions, watches, screenshots,
and composed batches with result references and deferred execution.

Add a standard-library Python CLI, protocol documentation, build
entries, and integration tests. The listener remains disabled unless
explicitly enabled.

Fix HTML compatibility issues found while debugging NewsBlur:
- Let explicit flex minimums, including min-width: 0, replace the
  automatic content minimum.
- Measure flex lines against the container content box when padding
  and explicit dimensions are present.
- Keep promoted positioned descendants of hidden ancestors out of
  the paint list.
This commit is contained in:
Martín Lucas Golini
2026-09-28 01:15:39 -03:00
parent cfe7761880
commit 4ffba20d07
20 changed files with 3329 additions and 15 deletions
+2
View File
@@ -253,6 +253,8 @@ the most basic widgets in a vertical linear layout display.
**UI introduction can be found [here](https://cdn.ensoft.dev/eepp-docs/page_uiintroduction.html)**.
**The UI data-binding guide can be found [here](docs/articles/uidatabinding.md).**
**The runtime UI inspector guide can be found [here](docs/articles/ui_inspector.md).**
## UI Widgets with C++ example
+278
View File
@@ -0,0 +1,278 @@
# Runtime UI inspector
The eepp inspector is a small TCP interface for inspecting and interacting with a running eepp UI. It is useful for debugging layout, CSS, focus, input delivery, multiple windows, and HTML inside `UIWebView`. Its compact JSON replies and server side batches are designed to reduce tool round trips for automated agents.
## Security and activation
The capability can be compiled into every eepp application. **It is disabled by default:** a normal run has no inspector listener, token, network thread, or reachable parser. Enabling it starts an explicit developer debugging interface. In control mode, authenticated clients can click and type into the application.
The listener binds to `127.0.0.1` by default. Every connection must present a bearer token. This is a safeguard against accidental or unintended attachment, including attachment to the wrong process; it is **not a sandbox boundary against malicious software running as the same OS user**. The interface resembles accessibility or OS automation in its ability to inspect and act, but additionally exposes eepp widget classes, CSS values, geometry, nested scenes, and watches. Availability of accessibility support does not enable the inspector.
Raw TCP is plaintext, including the token, UI text, and actions. Binding to a non-loopback address prints a warning and should be used only on a trusted network. For remote use, leave the inspector on loopback and forward it with `ssh -L 9876:127.0.0.1:<remote-port> host`. Password input text is returned as `[redacted]` in summaries, inspection, trees, and watches. Screenshots capture visible pixels, which can include sensitive information even when semantic text is redacted. Screenshot files remain in the OS temporary directory until the client or OS removes them. A generated token is printed once at startup; anyone who can read that process output can authenticate. A caller supplied token is not printed.
Set the following environment variables before launching any normal eepp UI application:
| Variable | Default | Meaning |
| --- | --- | --- |
| `EEPP_INSPECTOR` | unset | Set to `1` to start after the first top-level UI scene is registered. Unset, empty, or `0` disables it. |
| `EEPP_INSPECTOR_ADDRESS` | `127.0.0.1` | TCP bind address. |
| `EEPP_INSPECTOR_PORT` | `0` | TCP port; `0` asks the OS for an available port. |
| `EEPP_INSPECTOR_READ_ONLY` | `0` | Set to `1` to reject every `input.*` command. |
| `EEPP_INSPECTOR_TOKEN` | generated | Optional caller supplied bearer token. |
```sh
EEPP_INSPECTOR=1 ./bin/ecode
EEPP_INSPECTOR=1 EEPP_INSPECTOR_PORT=9876 EEPP_INSPECTOR_READ_ONLY=1 ./bin/ecode
EEPP_INSPECTOR=1 EEPP_INSPECTOR_TOKEN="$secret" ./bin/ecode
EEPP_INSPECTOR=1 EEPP_INSPECTOR_ADDRESS=0.0.0.0 ./bin/ecode # trusted network only
```
Successful startup writes one compact line to stderr:
```text
EEPP_INSPECTOR={"protocolVersion":1,"host":"127.0.0.1","port":43187,"token":"<generated-token>","pid":19382}
```
With `EEPP_INSPECTOR_TOKEN` supplied, the `token` member is omitted. The prefix `EEPP_INSPECTOR=` is stable. The server accepts up to 16 simultaneous TCP clients, and a request line is limited to 1 MiB. Each client's pending output is limited to 4 MiB. A single query returns at most 1000 nodes; a tree returns at most 1000 nodes; a batch accepts at most 100 commands.
## Python client
`projects/scripts/eepp-inspect.py` requires Python 3 and only the standard library. It reads `EEPP_INSPECTOR_HOST`, `EEPP_INSPECTOR_PORT`, and `EEPP_INSPECTOR_TOKEN`, or the corresponding `--host`, `--port`, and `--token` options. Use `--pretty` for indented JSON. Default stdout is compact JSON only; diagnostics go to stderr. Ordinary commands connect, authenticate, print one response, and disconnect.
```sh
export EEPP_INSPECTOR_PORT=43187 EEPP_INSPECTOR_TOKEN='<token>'
python3 projects/scripts/eepp-inspect.py contexts
python3 projects/scripts/eepp-inspect.py query '#preview'
python3 projects/scripts/eepp-inspect.py query --scene scene:2 'table > tr > td.problem' --properties geometry.size css.font-size
python3 projects/scripts/eepp-inspect.py tree --scene scene:2 --depth 4
python3 projects/scripts/eepp-inspect.py inspect w:42 geometry.size geometry.position css.font-size
python3 projects/scripts/eepp-inspect.py focus --scene scene:2
python3 projects/scripts/eepp-inspect.py screenshot --scene scene:2 --format png
python3 projects/scripts/eepp-inspect.py screenshot --window win:2 --rect 10 20 400 300 --format webp
python3 projects/scripts/eepp-inspect.py click --scene scene:2 '#submit'
python3 projects/scripts/eepp-inspect.py key --scene scene:2 Enter
python3 projects/scripts/eepp-inspect.py type --scene scene:2 'hello world'
python3 projects/scripts/eepp-inspect.py type --target '#search' 'hello world'
python3 projects/scripts/eepp-inspect.py watch --scene scene:2 '#problem' geometry.size css.font-size --count 3 --timeout 10
python3 projects/scripts/eepp-inspect.py batch workflow.json
python3 projects/scripts/eepp-inspect.py batch - < workflow.json
python3 projects/scripts/eepp-inspect.py raw '{"method":"ui.query","params":{"selector":"#foo"}}'
python3 projects/scripts/eepp-inspect.py session
```
`type --target` sends `input.click` followed by `input.text`, using the clicked widget's scene. `watch` streams one event per line; `--count` counts `watch.changed` events and `--timeout` bounds elapsed seconds. `batch` reads a JSON object containing `commands` from a file or stdin. `session` authenticates once, then sends one JSON request per stdin line and writes every response or event to stdout. It keeps the connection open while stdin is open. `raw` supplies a normal request object, assigning an ID if absent.
## Transport and envelopes
Protocol version 1 uses TCP, UTF-8, and newline delimited JSON: one object followed by `\n`. `\r\n` is accepted. TCP read boundaries have no meaning. Messages are compact, and a client may keep a connection open, pipeline requests, and receive unsolicited events between responses. Correlate responses by their non-negative integer `id`; response order is not guaranteed. A connection's first request must be `session.connect`.
```json
{"id":1,"method":"session.connect","params":{"protocolVersion":1,"token":"<token>","client":{"name":"my-tool","version":"1"}}}
```
Success includes `protocolVersion`, `application`, `pid`, `readOnly`, `defaultWindow`, `defaultScene`, and `capabilities`. An invalid token returns `authentication-failed` and closes the connection; a non-v1 version returns `unsupported-protocol` and closes it. Requests use `{"id":17,"method":"ui.query","params":{...}}`; `params` defaults to `{}`. Success is `{"id":17,"result":{...}}`. Failure is `{"id":17,"error":{"code":"...","message":"...","data":{...}}}`; `data` is optional. Events have no ID: `{"method":"watch.changed","params":{...}}`.
Every command may have a post-command `delay` such as `"100ms"` or `"1s"`. The command runs first; its response or the next batch command waits asynchronously. The normal UI update loop continues. A single delay is limited to 60 seconds. The accepted syntax is a non-negative decimal number followed by `ms` or `s`.
## Windows, scenes, and handles
Handles are opaque: `win:*` identifies a native window, `scene:*` a `UISceneNode`, `w:*` a widget, and `sub:*` a watch subscription. Window, scene, and widget IDs are monotonic for the server's lifetime and valid across client reconnections while the objects live. Destroyed objects invalidate their handles. Subscription IDs belong to one connection and are released on disconnect. Handles never contain pointers and do not survive server restarts.
Each selector executes in **exactly one scene**. An omitted scene means the stable `defaultScene` from the handshake, never the currently active scene. If that scene is destroyed, omission returns `default-scene-unavailable`. A scene determines its native window; a widget handle determines its scene and window. `ui.contexts` reports all discovered top-level and nested scenes. A `UIWebView` document is a separate scene and is classified `webview`, with its owner widget and parent scene. Other embedded scenes are classified `nested`.
For example, discover the WebView in its parent scene, then query its document scene:
```json
{"id":10,"method":"ui.query","params":{"selector":"#preview"}}
```
The returned WebView summary includes `"documentScene":"scene:2"`. Then:
```json
{"id":11,"method":"ui.query","params":{"scene":"scene:2","selector":"table > tr > td.problem"}}
{"id":12,"method":"ui.inspect","params":{"handle":"w:95","properties":["geometry.position","geometry.size","css.display","css.font-size"]}}
```
`#preview p` in the parent scene never enters the HTML document. `ui.contexts` also reveals secondary native windows and their scenes; pass the secondary `scene:*` to query or act there.
## Method reference
All examples below omit `id` where the surrounding explanation is enough; real requests always require it.
| Method | Parameters and defaults | Result | Notable errors |
| --- | --- | --- | --- |
| `session.connect` | `protocolVersion:1`, `token` required; optional `client` | Protocol version, application, PID, mode, default handles, capabilities | `authentication-failed`, `unsupported-protocol` |
| `session.batch` | `commands` array, at most 100 | `results` object keyed by command `name` or zero-based index string | `batch-command-failed`, `invalid-params` |
| `session.nextFrame` | `count` default 1, maximum 10 | `{ "frames": count }`; mainly used inside a batch | `invalid-params` |
| `ui.contexts` | none | `revision`, default handles, `windows`, `scenes` | `internal-error` |
| `ui.query` | `selector` required; `scene` default; `offset:0`, `limit:50` (maximum 1000); optional `properties`, `maxStringLength:4096` (maximum 65536) | Scene, selector, total, pagination, truncation, compact `nodes` | `invalid-scene`, `selector-invalid` |
| `ui.tree` | `scene` default or `root` handle; `depth:3` (maximum 32), `maxNodes:200` (maximum 1000), `includeText:false` | Flat nodes with `parent`, `children`, and `truncated` | `invalid-widget`, `invalid-scene` |
| `ui.inspect` | Exactly one of `handle` or `selector` plus optional `scene`; optional `properties`, `maxStringLength:4096` (maximum 65536) | Handle, scene, typed `properties`, `unavailable` | `invalid-widget`, `target-not-found`, `target-ambiguous` |
| `ui.focus` | `scene` default | Scene and focused widget summary or `null` | `invalid-scene` |
| `ui.screenshot` | Optional `scene` or `window` (mutually exclusive); optional `rect:[x,y,width,height]`; `format:"png"` | Temporary file path, format, window/scene handles, captured rectangle and size | `invalid-window`, `invalid-scene`, `invalid-params`, `screenshot-failed` |
| `input.click` | Exactly one target; optional `button:"left"`, `count:1` (maximum 2) | Target handle, scene, click point | `target-not-interactable`, `permission-denied` |
| `input.key` | `scene` default, `key` required, `action:"press"`, `modifiers:[]` | Key and action | `invalid-params`, `permission-denied` |
| `input.text` | `scene` default, `text` required (maximum 64 KiB UTF-8) | Character count | `invalid-params`, `permission-denied` |
| `watch.subscribe` | One handle or selector; explicit `properties` array; `initial:true` | Subscription and bound target values | `target-not-found`, `property-unknown` |
| `watch.unsubscribe` | `subscription` required | `{ "unsubscribed": true }` | `invalid-params` |
`ui.contexts` window entries include handle, native ID, title, pixel size and position, and focus. Scene entries include handle, window, kind, parent scene, owner widget, and root widget; WebView scenes include URI. The `revision` increases on observed topology changes. Create/destroy events are checked periodically while clients are connected; `ui.contexts` is the authoritative current snapshot.
These compact request/response pairs show each method's envelope. Handle numbers and geometry are illustrative; methods also accept the optional fields in the table above. Errors use the shared error envelope.
```json
{"id":1,"method":"session.connect","params":{"protocolVersion":1,"token":"secret"}}
{"id":1,"result":{"protocolVersion":1,"application":"ecode","pid":19382,"readOnly":false,"defaultWindow":"win:1","defaultScene":"scene:1","capabilities":["ui.contexts","ui.query","ui.tree","ui.inspect","ui.focus","ui.screenshot","session.batch","session.nextFrame","watch.properties","events.context-lifecycle","input.click","input.key","input.text"]}}
{"id":2,"method":"ui.contexts"}
{"id":2,"result":{"revision":0,"defaultWindow":"win:1","defaultScene":"scene:1","windows":[{"handle":"win:1","id":1,"title":"ecode","sizePx":[800,600],"positionPx":[0,0],"focused":true}],"scenes":[{"handle":"scene:1","window":"win:1","kind":"top-level","parentScene":null,"owner":null,"root":"w:1"}]}}
{"id":3,"method":"ui.query","params":{"selector":"#panel","properties":["geometry.size"],"limit":1}}
{"id":3,"result":{"scene":"scene:1","selector":"#panel","total":1,"offset":0,"returned":1,"truncated":false,"nodes":[{"handle":"w:2","scene":"scene:1","tag":"widget","id":"panel","classes":[],"pseudoClasses":[],"boundsPx":[10,10,100,30],"visible":true,"enabled":true,"focused":false,"properties":{"geometry.size":[100,30]}}]}}
{"id":4,"method":"ui.tree","params":{"depth":1,"maxNodes":50}}
{"id":4,"result":{"scene":"scene:1","root":"w:1","truncated":false,"nodes":[{"handle":"w:1","scene":"scene:1","tag":"root","id":"","classes":[],"pseudoClasses":[],"parent":null,"children":["w:2"]},{"handle":"w:2","scene":"scene:1","tag":"widget","id":"panel","classes":[],"pseudoClasses":[],"parent":"w:1","children":[]}]}}
{"id":5,"method":"ui.inspect","params":{"handle":"w:2","properties":["geometry.size","css.font-size"]}}
{"id":5,"result":{"handle":"w:2","scene":"scene:1","properties":{"geometry.size":[100,30],"css.font-size":"14dp"},"unavailable":[]}}
{"id":6,"method":"ui.focus"}
{"id":6,"result":{"scene":"scene:1","widget":null}}
{"id":14,"method":"ui.screenshot","params":{"scene":"scene:2","format":"png"}}
{"id":14,"result":{"path":"/tmp/eepp-inspector-19382-<random>.png","format":"png","window":"win:1","scene":"scene:2","rectPx":[100,80,480,320],"sizePx":[480,320]}}
{"id":7,"method":"input.click","params":{"handle":"w:2"}}
{"id":7,"result":{"target":"w:2","scene":"scene:1","pointPx":[60,25]}}
{"id":8,"method":"input.key","params":{"key":"Enter","action":"press","modifiers":[]}}
{"id":8,"result":{"key":"Enter","action":"press"}}
{"id":9,"method":"input.text","params":{"text":"hello"}}
{"id":9,"result":{"characters":5}}
{"id":10,"method":"watch.subscribe","params":{"handle":"w:2","properties":["geometry.size"],"initial":true}}
{"id":10,"result":{"subscription":"sub:1","targets":[{"handle":"w:2","values":{"geometry.size":[100,30]}}]}}
{"id":11,"method":"watch.unsubscribe","params":{"subscription":"sub:1"}}
{"id":11,"result":{"unsubscribed":true}}
{"id":12,"method":"session.nextFrame","params":{"count":1}}
{"id":12,"result":{"frames":1}}
{"id":13,"method":"session.batch","params":{"commands":[{"name":"panel","method":"ui.query","params":{"selector":"#panel","limit":1},"return":false},{"name":"size","method":"ui.inspect","params":{"handle":{"$ref":"panel#/nodes/0/handle"},"properties":["geometry.size"]}}]}}
{"id":13,"result":{"results":{"size":{"handle":"w:2","scene":"scene:1","properties":{"geometry.size":[100,30]},"unavailable":[]}}}}
```
`ui.query` uses eepp's CSS selector matching and returns an empty `nodes` array for no matches. Summary fields include handle, scene, tag, ID, classes, active pseudo-classes, pixel bounds, visible, enabled, focused, and optional text. Text is exposed for text controls, `UITextNode`, `UITextSpan`, and `UIRichText`; rich-text containers concatenate descendant source text without forcing layout. Summary text is limited to 256 Unicode characters. When `properties` is provided, each returned node also has the requested inspection values, avoiding one `ui.inspect` call per match. `ui.tree` does not cross a nested scene boundary; a WebView entry instead includes `documentScene`.
`ui.screenshot` captures the native window containing the selected scene. With no target it captures the default scene's full native window. An explicit `window` captures that whole window; an explicit `scene` crops to its visible world bounds, including a WebView document viewport. `rect` overrides that crop and uses top-left native-window pixel coordinates. It must fit entirely inside the window and have positive width and height. Supported formats are `png`, `jpg`, `bmp`, `tga`, `qoi`, and `webp`; eepp's case-insensitive image extension parser also accepts `jpeg` and `jfif` as aliases for `jpg`. The response uses the canonical extension. The server renders the selected window after the current UI update, saves the image in the OS temporary directory, and returns its absolute `path`; it does not send image bytes over NDJSON. The caller is responsible for deleting the file when finished. The path is useful to a local agent; remote clients must separately retrieve the file, for example over SSH. Screenshot observation is available in read-only mode and can be used inside `session.batch`.
Single-widget methods never pick the first of multiple selector matches: zero matches return `target-not-found`, and multiple matches return `target-ambiguous`. `input.click` uses the window input route and scene hit testing; clipped, covered, invisible, disabled, or off-window targets can return `target-not-interactable`. It does not scroll a target into view. `input.key` accepts names understood by eepp's `Input::getKeyFromName()` plus `Enter` as an alias for `Return`, and modifiers `Ctrl`, `Shift`, `Alt`, `Meta`; action may be `press`, `down`, or `up`. `input.text` sends Unicode text input to the current focus path and does not focus a widget first. Read-only mode omits input capabilities and returns `permission-denied` for these methods.
## Properties
`ui.inspect` defaults to `identity.*`, `geometry.*`, and `state.*`. Explicit names and the wildcards `identity.*`, `geometry.*`, `state.*`, `content.*`, `css.*`, and `*` are accepted. `css.*` expands to CSS properties implemented by that widget. Unknown or unavailable properties appear in `unavailable` rather than failing the whole inspection. Long text properties are bounded to 4096 Unicode characters by default. `maxStringLength` can raise that limit to 65536; `truncatedProperties` lists fields cut short. Watches use the 4096 character default.
| Group | Names and values |
| --- | --- |
| `identity.*` | `tag`, `id`, `classes`, `pseudoClasses` |
| `geometry.*` | `position` and `size` as local pixel `[x,y]`; `worldPosition` as window/world pixel `[x,y]`; `bounds` as `[x,y,width,height]` in the window/world hit-test space |
| `state.*` | `visible`, `enabled`, `focused` booleans |
| `content.*` | `text` from text controls and HTML text widgets (`UITextNode`, `UITextSpan`, `UIRichText`); rich-text source traversal is capped at 2048 nodes and does not enter nested scenes or text controls. Subject to truncation and secret redaction. |
| `css.*` | `css.<name>` as eepp's effective `getPropertyString()` serialization, for example `css.font-size` |
## Batching and chaining
`session.batch` executes commands sequentially on the UI thread, without rollback. Each command has `method`, optional `params`, optional unique `name`, optional `return` (default `true`), and optional post-command `delay`. `session.connect` and nested batches are forbidden. A failed step stops later steps and returns `batch-command-failed` with its index, name, and underlying `commandError`; earlier actions remain in effect.
A later command may recursively replace any parameter value with `{"$ref":"name#/json/pointer"}`. The name must identify an earlier command. The pointer is an RFC 6901 JSON Pointer into that command's **result**. A missing name, forward reference, or bad pointer produces `batch-reference-error` inside the batch failure. A result with `"return":false` is retained for references but omitted from the final wire reply. This makes dependent workflows a single agent/tool interaction.
The WebView discovery, document query, and inspection flow can be one request:
```json
{"commands":[
{"name":"webview","method":"ui.query","return":false,"params":{"selector":"#preview","limit":1}},
{"name":"problem","method":"ui.query","return":false,"params":{"scene":{"$ref":"webview#/nodes/0/documentScene"},"selector":"td.problem","limit":1}},
{"name":"inspection","method":"ui.inspect","params":{"handle":{"$ref":"problem#/nodes/0/handle"},"properties":["geometry.size","css.font-size"]}}
]}
```
Only `results.inspection` is returned. To request a larger single response instead of many inspect calls, use `ui.query` with `"properties":["geometry.size","css.font-size"]` and an appropriate limit.
For action and observation in one call:
```json
{"commands":[
{"method":"input.click","delay":"100ms","return":false,"params":{"selector":"#toggle"}},
{"name":"state","method":"ui.inspect","params":{"selector":"#panel","properties":["geometry.size","state.visible","css.display"]}}
]}
```
The click happens first; eepp's normal timeout machinery resumes the batch at least 100 ms later while frames keep running. For a frame barrier, insert `{"method":"session.nextFrame","return":false,"params":{"count":1}}` between the click and inspection. This resumes after the next completed normal scene update. Delayed batches do not block other clients or requests. Closing a client cancels its pending batches. Batching reduces agent/tool round trips; inline properties let the client deliberately choose one larger response when that costs less than several small calls.
## Watches and events
`watch.subscribe` resolves a selector once and binds to its current matches; it is not a live selector. Watches require explicit property names, without wildcards. The initial values are in the subscribe response by default. The inspector compares only watched targets and properties once per UI frame, coalescing changes to one `watch.changed` event per target per frame. `sequence` increases per subscription. A target's destruction emits `watch.targetRemoved` with reason `target-destroyed`; once none remain, `watch.ended` has reason `no-targets`. Explicit unsubscribe returns directly without an ended event. Disconnect releases the connection's subscriptions.
```json
{"method":"watch.changed","params":{"subscription":"sub:1","sequence":7,"target":"w:42","changes":{"geometry.size":{"old":[800,600],"new":[760,600]}}}}
```
Other events are `ui.windowCreated` and `ui.windowDestroyed` with `revision` and a window descriptor or handle; `ui.sceneCreated` and `ui.sceneDestroyed` with `revision` and a scene descriptor or handle. Events may appear between request responses. Keep a watch client connected while another client sends actions.
| Event | Example `params` |
| --- | --- |
| `ui.windowCreated` | `{"revision":1,"window":{"handle":"win:2","id":2,"title":"Preview","sizePx":[800,600]}}` |
| `ui.windowDestroyed` | `{"revision":2,"window":"win:2"}` |
| `ui.sceneCreated` | `{"revision":3,"scene":{"handle":"scene:2","window":"win:1","kind":"webview","parentScene":"scene:1","owner":"w:4","root":"w:5"}}` |
| `ui.sceneDestroyed` | `{"revision":4,"scene":"scene:2"}` |
| `watch.changed` | `{"subscription":"sub:1","sequence":1,"target":"w:2","changes":{"geometry.size":{"old":[100,30],"new":[120,30]}}}` |
| `watch.targetRemoved` | `{"subscription":"sub:1","target":"w:2","reason":"target-destroyed"}` |
| `watch.ended` | `{"subscription":"sub:1","reason":"no-targets"}` |
## Errors
Stable v1 error codes are:
| Code | Meaning |
| --- | --- |
| `parse-error` | Malformed JSON. |
| `invalid-request` | Missing/invalid request envelope, ID, or params. |
| `unauthenticated` | Inspector method before connection authentication. |
| `authentication-failed` | Token rejected. |
| `unsupported-protocol` | Client requested a version other than 1. |
| `method-not-found` | Unknown method. |
| `invalid-params` | A method argument is missing, invalid, or beyond a limit. |
| `permission-denied` | An action was requested in read-only mode. |
| `invalid-window`, `invalid-scene`, `invalid-widget` | A handle does not identify a live object of that kind. |
| `default-scene-unavailable` | The original default scene was destroyed. |
| `selector-invalid` | Selector is empty or rejected by selector validation. |
| `target-not-found`, `target-ambiguous` | Single-target selector matched zero or multiple widgets. |
| `target-not-interactable` | Click point cannot reach the target. |
| `property-unknown` | Watch property cannot be read. |
| `input-unavailable` | The scene/window has no usable input context. |
| `screenshot-failed` | No drawable window/scene area, or image capture/save failed. |
| `batch-reference-error` | Invalid command reference or JSON Pointer. |
| `batch-command-failed` | A batch step failed; `data.commandError` contains its cause. |
| `request-too-large` | A request line exceeded 1 MiB; the server closes the connection. |
| `internal-error` | An unexpected command error was caught. |
## C++ API
Applications normally need no code. Tests and embedding code may use:
```cpp
#include <eepp/ui/tools/uiinspectorserver.hpp>
UIInspectorServerSettings settings;
settings.address = "127.0.0.1";
settings.port = 0;
settings.readOnly = false;
settings.token = "caller-chosen-secret"; // leave empty to generate one
UIInspectorServer::start( defaultScene, settings );
auto* server = UIInspectorServer::instance();
auto port = server->getPort();
UIInspectorServer::stop();
```
`instance()` is null when disabled. `isRunning()`, `getAddress()`, `getPort()`, and `getToken()` are available while running. Call the API on the UI thread. The framework automatically pumps it during `SceneManager::update()` and stops it during scene-manager teardown.
The headless `UIInspector` owns the handle registry and scene/widget reads. Protocol dispatch and TCP session code currently share one implementation file; the network thread only frames messages and manages connections, while the scene update pump runs every UI operation. This file layout does not affect the wire protocol.
## Troubleshooting
- **Connection refused:** confirm `EEPP_INSPECTOR=1`, read the emitted port, and use the correct host or SSH forward.
- **Authentication failed:** use the token from the same application run; a supplied token is intentionally absent from the descriptor.
- **Query returns nothing:** inspect `ui.contexts`; HTML inside a WebView is in its `documentScene`.
- **Ambiguous selector:** use a more specific selector or a widget handle.
- **Click rejected:** inspect pixel bounds and visibility; the center may be clipped, covered, or outside the window. Scrolling is manual in v1.
- **Key/text has no effect:** call `ui.focus`, then click the intended input before typing.
- **Remote debugging:** keep the listener on loopback and use SSH forwarding; direct raw TCP has no encryption.
+3
View File
@@ -48,6 +48,9 @@ class EE_API SceneManager {
/** Returns the UI scene associated with @p window, or nullptr if none is registered. */
UISceneNode* getUISceneNode( EE::Window::Window* window );
/** Enumerates registered top-level scenes on the UI thread. */
void forEachSceneNode( const std::function<void( SceneNode* )>& callback ) const;
void setCurrentUISceneNode( UISceneNode* uiSceneNode );
/** Removes and destroys all registered scene nodes associated with @p window. */
+71
View File
@@ -0,0 +1,71 @@
#ifndef EE_UI_TOOLS_UIINSPECTOR_HPP
#define EE_UI_TOOLS_UIINSPECTOR_HPP
#include <eepp/core/containers.hpp>
#include <eepp/core/small_vector.hpp>
#include <eepp/scene/eventconnection.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uiwidget.hpp>
#include <string>
namespace EE { namespace UI {
/** UI-thread-only inspection domain. Handles are monotonic and invalidated on Node::OnClose. */
class EE_API UIInspector {
public:
UIInspector();
~UIInspector();
void setDefaultScene( UISceneNode* scene );
UISceneNode* defaultScene() const;
std::string windowHandle( EE::Window::Window* window );
std::string sceneHandle( UISceneNode* scene );
std::string widgetHandle( UIWidget* widget );
EE::Window::Window* window( const std::string& handle ) const;
void invalidateWindow( EE::Window::Window* window );
UISceneNode* scene( const std::string& handle ) const;
UIWidget* widget( const std::string& handle ) const;
SmallVector<UISceneNode*, 4> topLevelScenes() const;
WidgetQueryResult query( UISceneNode* scene, const std::string& selector ) const;
std::string property( UIWidget* widget, const std::string& name ) const;
bool isSecret( UIWidget* widget ) const;
private:
struct NodeEntry {
Scene::Node* node{ nullptr };
Scene::EventConnection close;
};
using NodeHandles = UnorderedMap<std::string, NodeEntry>;
using ReverseNodeHandles = UnorderedMap<Scene::Node*, std::string>;
std::string registerNode( Scene::Node* node, const char* prefix, NodeHandles& handles,
ReverseNodeHandles& reverse );
Uint64 mNextWindow{ 1 };
Uint64 mNextScene{ 1 };
Uint64 mNextWidget{ 1 };
UISceneNode* mDefaultScene{ nullptr };
UnorderedMap<EE::Window::Window*, std::string> mWindowReverse;
UnorderedMap<std::string, EE::Window::Window*> mWindows;
ReverseNodeHandles mSceneReverse;
NodeHandles mScenes;
ReverseNodeHandles mWidgetReverse;
NodeHandles mWidgets;
};
}} // namespace EE::UI
#endif
@@ -0,0 +1,56 @@
#ifndef EE_UI_TOOLS_UIINSPECTORSERVER_HPP
#define EE_UI_TOOLS_UIINSPECTORSERVER_HPP
#include <eepp/config.hpp>
#include <memory>
#include <string>
namespace EE { namespace Window {
class Window;
}} // namespace EE::Window
namespace EE { namespace UI {
class UISceneNode;
struct UIInspectorServerSettings {
std::string address{ "127.0.0.1" };
Uint16 port{ 0 };
bool readOnly{ false };
std::string token;
};
/** Explicitly enabled TCP inspector. Public methods must be called from the UI thread. */
class EE_API UIInspectorServer {
public:
static UIInspectorServer* instance();
static bool start( UISceneNode* defaultScene, UIInspectorServerSettings settings = {} );
static void startFromEnvironment( UISceneNode* defaultScene );
static void stop();
static void pump();
static void notifyWindowDestroyed( EE::Window::Window* window );
bool isRunning() const;
const std::string& getAddress() const;
Uint16 getPort() const;
const std::string& getToken() const;
~UIInspectorServer();
private:
UIInspectorServer();
struct Impl;
std::unique_ptr<Impl> mImpl;
static UIInspectorServer* sInstance;
};
}} // namespace EE::UI
#endif
+4
View File
@@ -376,6 +376,8 @@
../../include/eepp/ui/tools/uicodeeditorsplitter.hpp
../../include/eepp/ui/tools/uicolorpicker.hpp
../../include/eepp/ui/tools/uidocfindreplace.hpp
../../include/eepp/ui/tools/uiinspector.hpp
../../include/eepp/ui/tools/uiinspectorserver.hpp
../../include/eepp/ui/tools/uiwidgetinspector.hpp
../../include/eepp/ui/uiabstractview.hpp
../../include/eepp/ui/uiapplication.hpp
@@ -1174,6 +1176,8 @@
../../src/eepp/ui/tools/uidocfindreplace.cpp
../../src/eepp/ui/tools/uitextureviewer.cpp
../../src/eepp/ui/tools/uitextureviewer.hpp
../../src/eepp/ui/tools/uiinspector.cpp
../../src/eepp/ui/tools/uiinspectorserver.cpp
../../src/eepp/ui/tools/uiwidgetinspector.cpp
../../src/eepp/ui/uiabstractview.cpp
../../src/eepp/ui/uiapplication.cpp
+4
View File
@@ -373,6 +373,8 @@
../../include/eepp/ui/tools/uicodeeditorsplitter.hpp
../../include/eepp/ui/tools/uicolorpicker.hpp
../../include/eepp/ui/tools/uidocfindreplace.hpp
../../include/eepp/ui/tools/uiinspector.hpp
../../include/eepp/ui/tools/uiinspectorserver.hpp
../../include/eepp/ui/tools/uiwidgetinspector.hpp
../../include/eepp/ui/uiabstractview.hpp
../../include/eepp/ui/uibackgrounddrawable.hpp
@@ -1155,6 +1157,8 @@
../../src/eepp/ui/tools/uidocfindreplace.cpp
../../src/eepp/ui/tools/uitextureviewer.cpp
../../src/eepp/ui/tools/uitextureviewer.hpp
../../src/eepp/ui/tools/uiinspector.cpp
../../src/eepp/ui/tools/uiinspectorserver.cpp
../../src/eepp/ui/tools/uiwidgetinspector.cpp
../../src/eepp/ui/uiabstractview.cpp
../../src/eepp/ui/uiapplication.cpp
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Small standard-library client for the eepp inspector NDJSON protocol."""
import argparse
import json
import os
import queue
import select
import socket
import sys
import threading
import time
def emit(value, pretty=False):
print(json.dumps(value, ensure_ascii=False, indent=2 if pretty else None,
separators=None if pretty else (",", ":")), flush=True)
class Client:
def __init__(self, host, port, token):
self.sock = socket.create_connection((host, port), timeout=10)
self.sock.settimeout(None)
self.input = b""
self.next_id = 2
reply = self.request({"id": 1, "method": "session.connect", "params": {
"protocolVersion": 1, "token": token,
"client": {"name": "eepp-inspect.py", "version": "1"}}})
if "error" in reply:
raise RuntimeError(reply["error"].get("message", str(reply["error"])))
def close(self):
self.sock.close()
def send(self, request):
request = dict(request)
if "id" not in request:
request["id"] = self.next_id
self.next_id += 1
self.sock.sendall((json.dumps(request, ensure_ascii=False,
separators=(",", ":")) + "\n").encode("utf-8"))
return request["id"]
def receive(self):
while b"\n" not in self.input:
chunk = self.sock.recv(8192)
if not chunk:
raise EOFError("inspector disconnected")
self.input += chunk
if len(self.input) > 4 * 1024 * 1024:
raise ValueError("response exceeds 4 MiB")
line, self.input = self.input.split(b"\n", 1)
return json.loads(line.decode("utf-8").rstrip("\r"))
def readable(self, timeout=None):
return b"\n" in self.input or bool(select.select([self.sock], [], [], timeout)[0])
def request(self, request, on_event=None):
request_id = self.send(request)
while True:
message = self.receive()
if message.get("id") == request_id:
return message
if on_event is not None:
on_event(message)
def target(value, scene=None):
return {"handle": value} if value.startswith("w:") else {
**({"scene": scene} if scene else {}), "selector": value}
def scene_param(args):
return {"scene": args.scene} if getattr(args, "scene", None) else {}
def command(args):
name = args.command
params = scene_param(args)
if name == "contexts":
return "ui.contexts", {}
if name == "query":
params.update(selector=args.selector, offset=args.offset, limit=args.limit)
if args.properties:
params["properties"] = args.properties
if args.max_string_length is not None:
params["maxStringLength"] = args.max_string_length
return "ui.query", params
if name == "tree":
params.update(depth=args.depth, maxNodes=args.max_nodes,
includeText=args.include_text)
if args.root:
params = {key: value for key, value in params.items() if key != "scene"}
params["root"] = args.root
return "ui.tree", params
if name == "inspect":
params.update(target(args.target, args.scene))
if args.properties:
params["properties"] = args.properties
if args.max_string_length is not None:
params["maxStringLength"] = args.max_string_length
return "ui.inspect", params
if name == "focus":
return "ui.focus", params
if name == "screenshot":
if args.window:
params["window"] = args.window
if args.rect:
params["rect"] = args.rect
params["format"] = args.format
return "ui.screenshot", params
if name == "click":
return "input.click", target(args.target, args.scene)
if name == "key":
params.update(key=args.key, action=args.action, modifiers=args.modifiers)
return "input.key", params
if name == "type":
params["text"] = args.text
return "input.text", params
if name == "watch":
params.update(target(args.target, args.scene))
params.update(properties=args.properties, initial=not args.no_initial)
return "watch.subscribe", params
if name == "batch":
with (sys.stdin if args.file == "-" else open(args.file, encoding="utf-8")) as stream:
return "session.batch", json.load(stream)
if name == "raw":
return None, json.loads(args.request)
raise ValueError(name)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default=os.getenv("EEPP_INSPECTOR_HOST", "127.0.0.1"))
parser.add_argument("--port", type=int, default=int(os.getenv("EEPP_INSPECTOR_PORT", "0")))
parser.add_argument("--token", default=os.getenv("EEPP_INSPECTOR_TOKEN"))
parser.add_argument("--pretty", action="store_true")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("contexts")
query = sub.add_parser("query")
query.add_argument("selector")
query.add_argument("--scene")
query.add_argument("--offset", type=int, default=0)
query.add_argument("--limit", type=int, default=50)
query.add_argument("--properties", nargs="+")
query.add_argument("--max-string-length", type=int)
tree = sub.add_parser("tree")
tree.add_argument("--scene")
tree.add_argument("--root")
tree.add_argument("--depth", type=int, default=3)
tree.add_argument("--max-nodes", type=int, default=200)
tree.add_argument("--include-text", action="store_true")
inspect = sub.add_parser("inspect")
inspect.add_argument("target", help="widget handle or selector")
inspect.add_argument("properties", nargs="*")
inspect.add_argument("--scene")
inspect.add_argument("--max-string-length", type=int)
focus = sub.add_parser("focus")
focus.add_argument("--scene")
screenshot = sub.add_parser("screenshot", help="save a window or scene image to a temporary path")
screenshot_context = screenshot.add_mutually_exclusive_group()
screenshot_context.add_argument("--scene", help="capture the scene's visible rectangle")
screenshot_context.add_argument("--window", help="capture this native window")
screenshot.add_argument("--rect", nargs=4, type=int, metavar=("X", "Y", "WIDTH", "HEIGHT"),
help="capture a rectangle in native-window pixel coordinates")
screenshot.add_argument("--format", choices=("png", "jpg", "bmp", "tga", "qoi", "webp"),
default="png")
click = sub.add_parser("click")
click.add_argument("target")
click.add_argument("--scene")
key = sub.add_parser("key")
key.add_argument("key")
key.add_argument("--scene")
key.add_argument("--action", choices=("press", "down", "up"), default="press")
key.add_argument("--modifiers", nargs="*", default=[])
typ = sub.add_parser("type", help="inject text; optional --target clicks first")
typ.add_argument("text")
typ.add_argument("--scene")
typ.add_argument("--target")
watch = sub.add_parser("watch")
watch.add_argument("target")
watch.add_argument("properties", nargs="+")
watch.add_argument("--scene")
watch.add_argument("--no-initial", action="store_true")
watch.add_argument("--timeout", type=float)
watch.add_argument("--count", type=int)
batch = sub.add_parser("batch")
batch.add_argument("file", help="JSON params file, or - for stdin")
sub.add_parser("session", help="authenticated persistent NDJSON stdin/stdout")
raw = sub.add_parser("raw")
raw.add_argument("request", help="JSON request object; id is assigned if omitted")
args = parser.parse_args()
if not args.port or not args.token:
parser.error("--port and --token (or their environment variables) are required")
client = Client(args.host, args.port, args.token)
try:
if args.command == "session":
requests = queue.Queue()
def read_stdin():
for line in sys.stdin:
requests.put(line)
requests.put(None)
threading.Thread(target=read_stdin, daemon=True).start()
stdin_open = True
outstanding = set()
while stdin_open or outstanding:
try:
line = requests.get_nowait()
if line is None:
stdin_open = False
elif line.strip():
outstanding.add(client.send(json.loads(line)))
except queue.Empty:
pass
if client.readable(0.05):
message = client.receive()
emit(message)
outstanding.discard(message.get("id"))
return
if args.command == "type" and args.target:
reply = client.request({"method": "input.click",
"params": target(args.target, args.scene)})
if "error" in reply:
emit(reply, args.pretty)
return 1
if not args.scene:
args.scene = reply["result"].get("scene")
method, params = command(args)
request = params if method is None else {"method": method, "params": params}
reply = client.request(request)
emit(reply, args.pretty)
if "error" in reply:
return 1
if args.command == "watch":
deadline = time.monotonic() + args.timeout if args.timeout is not None else None
seen = 0
while args.count is None or seen < args.count:
remaining = None if deadline is None else max(0, deadline - time.monotonic())
if remaining == 0 or not client.readable(remaining):
break
event = client.receive()
emit(event, args.pretty)
if event.get("method") == "watch.changed":
seen += 1
finally:
client.close()
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except (OSError, ValueError, EOFError, RuntimeError, json.JSONDecodeError) as exc:
print(f"eepp-inspect: {exc}", file=sys.stderr)
sys.exit(2)
+121
View File
@@ -0,0 +1,121 @@
"""Transport and CLI smoke tests using a small fake NDJSON inspector."""
import json
import pathlib
import socket
import subprocess
import sys
import threading
import unittest
CLIENT = pathlib.Path(__file__).resolve().parents[1] / "eepp-inspect.py"
class FakeServer:
def __init__(self, handler):
self.listener = socket.socket()
self.listener.bind(("127.0.0.1", 0))
self.listener.listen(1)
self.port = self.listener.getsockname()[1]
self.handler = handler
self.error = None
self.thread = threading.Thread(target=self.run, daemon=True)
self.thread.start()
def run(self):
try:
with self.listener:
conn, _ = self.listener.accept()
with conn, conn.makefile("r", encoding="utf-8") as lines:
connect = json.loads(lines.readline())
assert connect["method"] == "session.connect"
assert connect["params"]["token"] == "test-token"
conn.sendall(b'{"id":1,"result":{"protocolVersion":1}}\n')
self.handler(conn, lines)
except Exception as exc:
self.error = exc
def join(self):
self.thread.join(timeout=5)
if self.error:
raise self.error
assert not self.thread.is_alive(), "fake server did not finish"
def invoke(server, *args, input_text=None):
return subprocess.run(
[sys.executable, str(CLIENT), "--port", str(server.port), "--token", "test-token", *args],
input=input_text, text=True, capture_output=True, timeout=5,
)
class InspectorCliTests(unittest.TestCase):
def test_contexts_emits_compact_json(self):
def handler(conn, lines):
request = json.loads(lines.readline())
self.assertEqual(request["method"], "ui.contexts")
conn.sendall((json.dumps({"id": request["id"], "result": {"scenes": []}}) + "\n").encode())
server = FakeServer(handler)
result = invoke(server, "contexts")
server.join()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)["result"]["scenes"], [])
self.assertEqual(result.stdout.count("\n"), 1)
def test_batch_reads_stdin_and_preserves_commands(self):
def handler(conn, lines):
request = json.loads(lines.readline())
self.assertEqual(request["method"], "session.batch")
self.assertFalse(request["params"]["commands"][0]["return"])
conn.sendall((json.dumps({"id": request["id"], "result": {"results": {}}}) + "\n").encode())
server = FakeServer(handler)
definition = '{"commands":[{"method":"ui.query","return":false,"params":{"selector":"*"}}]}'
result = invoke(server, "batch", "-", input_text=definition)
server.join()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)["result"]["results"], {})
def test_screenshot_sends_window_rect_and_format(self):
def handler(conn, lines):
request = json.loads(lines.readline())
self.assertEqual(request["method"], "ui.screenshot")
self.assertEqual(request["params"], {
"window": "win:2", "rect": [5, 6, 30, 20], "format": "qoi"})
conn.sendall((json.dumps({"id": request["id"], "result": {
"path": "/tmp/example.qoi", "format": "qoi"}}) + "\n").encode())
server = FakeServer(handler)
result = invoke(server, "screenshot", "--window", "win:2", "--rect",
"5", "6", "30", "20", "--format", "qoi")
server.join()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)["result"]["path"], "/tmp/example.qoi")
def test_session_streams_events_between_pipelined_responses(self):
def handler(conn, lines):
first = json.loads(lines.readline())
second = json.loads(lines.readline())
self.assertEqual(first["method"], "ui.contexts")
self.assertEqual(second["method"], "ui.focus")
messages = [
{"method": "watch.changed", "params": {"sequence": 1}},
{"id": second["id"], "result": {"widget": None}},
{"id": first["id"], "result": {"scenes": []}},
]
conn.sendall("".join(json.dumps(message) + "\n" for message in messages).encode())
server = FakeServer(handler)
requests = '{"method":"ui.contexts"}\n{"method":"ui.focus"}\n'
result = invoke(server, "session", input_text=requests)
server.join()
self.assertEqual(result.returncode, 0, result.stderr)
messages = [json.loads(line) for line in result.stdout.splitlines()]
self.assertEqual(messages[0]["method"], "watch.changed")
self.assertEqual({message.get("id") for message in messages[1:]}, {2, 3})
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -366,6 +366,8 @@
../../include/eepp/ui/tools/uicodeeditorsplitter.hpp
../../include/eepp/ui/tools/uicolorpicker.hpp
../../include/eepp/ui/tools/uidocfindreplace.hpp
../../include/eepp/ui/tools/uiinspector.hpp
../../include/eepp/ui/tools/uiinspectorserver.hpp
../../include/eepp/ui/tools/uiwidgetinspector.hpp
../../include/eepp/ui/uiabstractview.hpp
../../include/eepp/ui/uibackgrounddrawable.hpp
@@ -853,6 +855,8 @@
../../src/eepp/ui/tools/uicodeeditorsplitter.cpp
../../src/eepp/ui/tools/uicolorpicker.cpp
../../src/eepp/ui/tools/uidocfindreplace.cpp
../../src/eepp/ui/tools/uiinspector.cpp
../../src/eepp/ui/tools/uiinspectorserver.cpp
../../src/eepp/ui/tools/uiwidgetinspector.cpp
../../src/eepp/ui/uiabstractview.cpp
../../src/eepp/ui/uibackgrounddrawable.cpp
+10
View File
@@ -1,6 +1,7 @@
#include <algorithm>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/scene/scenenode.hpp>
#include <eepp/ui/tools/uiinspectorserver.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/window/engine.hpp>
@@ -16,6 +17,7 @@ bool SceneManager::isActive() {
SceneManager::SceneManager() : mUISceneNode( NULL ), mScopedUISceneNode( NULL ) {}
SceneManager::~SceneManager() {
UIInspectorServer::stop();
for ( auto& it : mSceneNodes ) {
SceneNode* node = it;
eeSAFE_DELETE( node );
@@ -26,6 +28,8 @@ SceneManager::~SceneManager() {
SceneNode* SceneManager::add( SceneNode* sceneNode ) {
mSceneNodes.push_back( sceneNode );
if ( sceneNode->isUISceneNode() )
UIInspectorServer::startFromEnvironment( sceneNode->asType<UISceneNode>() );
return sceneNode;
}
@@ -82,6 +86,7 @@ void SceneManager::update( const Time& elapsed ) {
sceneNode->update( elapsed );
}
}
UIInspectorServer::pump();
}
void SceneManager::update() {
@@ -119,6 +124,11 @@ UISceneNode* SceneManager::getUISceneNode( EE::Window::Window* window ) {
return nullptr;
}
void SceneManager::forEachSceneNode( const std::function<void( SceneNode* )>& callback ) const {
for ( auto* sceneNode : mSceneNodes )
callback( sceneNode );
}
void SceneManager::setCurrentUISceneNode( UISceneNode* uiSceneNode ) {
mUISceneNode = uiSceneNode;
}
+10 -13
View File
@@ -473,8 +473,10 @@ void FlexLayouter::measureFlexItems( const Axis& mainAxis, const Axis& crossAxis
const auto* minW = item.widget->getUIStyle()->getProperty( PropertyId::MinWidth );
if ( minW ) {
Float explicitMin = item.widget->cssWidthPropertyToBorderBoxWidth( *minW );
if ( explicitMin > item.minMainSize )
item.minMainSize = explicitMin;
// An explicit minimum replaces the automatic content minimum. In
// particular, min-width: 0 must permit a flex item to shrink below
// the min-content width of an image or other descendant.
item.minMainSize = explicitMin;
}
const auto* maxW = item.widget->getUIStyle()->getProperty( PropertyId::MaxWidth );
if ( maxW )
@@ -514,8 +516,7 @@ void FlexLayouter::measureFlexItems( const Axis& mainAxis, const Axis& crossAxis
const auto* minH = item.widget->getUIStyle()->getProperty( PropertyId::MinHeight );
if ( minH ) {
Float explicitMin = item.widget->cssHeightPropertyToBorderBoxHeight( *minH );
if ( explicitMin > item.minMainSize )
item.minMainSize = explicitMin;
item.minMainSize = explicitMin;
}
const auto* maxH = item.widget->getUIStyle()->getProperty( PropertyId::MaxHeight );
if ( maxH )
@@ -1285,15 +1286,11 @@ void FlexLayouter::updateLayout() {
Float containerMainSize = mainAxis.horizontal ? containerWidth : containerHeight;
Float containerCrossSize = crossAxis.horizontal ? containerWidth : containerHeight;
if ( widthPolicy != SizePolicy::Fixed && mainAxis.horizontal )
containerMainSize -= totalPaddingMain;
if ( heightPolicy != SizePolicy::Fixed && !mainAxis.horizontal )
containerMainSize -= totalPaddingMain;
if ( widthPolicy != SizePolicy::Fixed && crossAxis.horizontal )
containerCrossSize -= totalPaddingCross;
if ( heightPolicy != SizePolicy::Fixed && !crossAxis.horizontal )
containerCrossSize -= totalPaddingCross;
// Flex lines use the content box on both axes, including when the
// container has an explicit width or height. The outer dimensions above
// already include padding after CSS box-sizing resolution.
containerMainSize -= totalPaddingMain;
containerCrossSize -= totalPaddingCross;
if ( containerMainSize < 0.f )
containerMainSize = 0.f;
+137
View File
@@ -0,0 +1,137 @@
#include <algorithm>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/ui/css/stylesheetspecification.hpp>
#include <eepp/ui/tools/uiinspector.hpp>
#include <eepp/ui/uihtmlinput.hpp>
#include <eepp/ui/uitextinput.hpp>
#include <eepp/ui/uiwebview.hpp>
#include <eepp/window/engine.hpp>
namespace EE { namespace UI {
UIInspector::UIInspector() = default;
UIInspector::~UIInspector() = default;
void UIInspector::setDefaultScene( UISceneNode* scene ) {
mDefaultScene = scene;
if ( scene )
sceneHandle( scene );
}
UISceneNode* UIInspector::defaultScene() const {
return mDefaultScene;
}
std::string UIInspector::registerNode( Scene::Node* node, const char* prefix, NodeHandles& handles,
ReverseNodeHandles& reverse ) {
if ( !node )
return {};
auto found = reverse.find( node );
if ( found != reverse.end() )
return found->second;
Uint64& next = prefix[0] == 'w' ? mNextWidget : mNextScene;
std::string handle = std::string( prefix ) + std::to_string( next++ );
NodeEntry entry;
entry.node = node;
entry.close = node->connect( Scene::Event::OnClose,
[this, node, handle, &handles, &reverse]( const Scene::Event* ) {
auto it = handles.find( handle );
if ( it != handles.end() )
it->second.node = nullptr;
reverse.erase( node );
if ( mDefaultScene == node )
mDefaultScene = nullptr;
} );
handles.emplace( handle, std::move( entry ) );
reverse.emplace( node, handle );
return handle;
}
std::string UIInspector::windowHandle( EE::Window::Window* window ) {
if ( !window )
return {};
auto found = mWindowReverse.find( window );
if ( found != mWindowReverse.end() )
return found->second;
std::string handle = "win:" + std::to_string( mNextWindow++ );
mWindowReverse.emplace( window, handle );
mWindows.emplace( handle, window );
return handle;
}
std::string UIInspector::sceneHandle( UISceneNode* scene ) {
return registerNode( scene, "scene:", mScenes, mSceneReverse );
}
std::string UIInspector::widgetHandle( UIWidget* widget ) {
return registerNode( widget, "w:", mWidgets, mWidgetReverse );
}
EE::Window::Window* UIInspector::window( const std::string& handle ) const {
auto it = mWindows.find( handle );
if ( it == mWindows.end() )
return nullptr;
bool live = false;
EE::Window::Engine::instance()->forEachWindow(
[&]( EE::Window::Window* candidate ) { live |= candidate == it->second; } );
return live ? it->second : nullptr;
}
void UIInspector::invalidateWindow( EE::Window::Window* window ) {
auto it = mWindowReverse.find( window );
if ( it != mWindowReverse.end() ) {
mWindows.erase( it->second );
mWindowReverse.erase( it );
}
}
UISceneNode* UIInspector::scene( const std::string& handle ) const {
auto it = mScenes.find( handle );
return it == mScenes.end() ? nullptr : static_cast<UISceneNode*>( it->second.node );
}
UIWidget* UIInspector::widget( const std::string& handle ) const {
auto it = mWidgets.find( handle );
return it == mWidgets.end() ? nullptr : static_cast<UIWidget*>( it->second.node );
}
SmallVector<UISceneNode*, 4> UIInspector::topLevelScenes() const {
SmallVector<UISceneNode*, 4> scenes;
Scene::SceneManager::instance()->forEachSceneNode( [&]( Scene::SceneNode* node ) {
if ( node->isUISceneNode() )
scenes.push_back( node->asType<UISceneNode>() );
} );
return scenes;
}
WidgetQueryResult UIInspector::query( UISceneNode* scene, const std::string& selector ) const {
if ( !scene || !scene->getRoot() )
return {};
WidgetQueryResult widgets = scene->getRoot()->querySelectorAll( selector );
widgets.erase(
std::remove_if( widgets.begin(), widgets.end(),
[scene]( UIWidget* widget ) { return widget->getUISceneNode() != scene; } ),
widgets.end() );
return widgets;
}
std::string UIInspector::property( UIWidget* widget, const std::string& name ) const {
if ( !widget || !CSS::StyleSheetSpecification::instance()->getProperty( name ) )
return {};
return widget->getPropertyString( name );
}
bool UIInspector::isSecret( UIWidget* widget ) const {
if ( !widget )
return false;
if ( widget->isType( UI_TYPE_TEXTINPUT ) &&
widget->asType<UITextInput>()->getMode() == UITextInput::TextInputMode::Password )
return true;
if ( widget->isType( UI_TYPE_HTML_INPUT ) &&
widget->asType<UIHTMLInput>()->getInputType() == "password" )
return true;
return false;
}
}} // namespace EE::UI
File diff suppressed because it is too large Load Diff
+5
View File
@@ -731,6 +731,11 @@ void UIHTMLWidget::collectStackingScopeItems( UIHTMLWidget* container, SmallVect
void UIHTMLWidget::collectStackingScopeChild( Node* child, SmallVector<Node*, 16>& out,
bool directChildren ) const {
// A promoted positioned descendant must not escape a hidden ancestor's
// subtree. The normal node traversal stops at that ancestor, while this
// flattened paint list would otherwise visit and paint its descendants.
if ( !child->isVisible() )
return;
if ( directChildren )
out.push_back( child );
if ( !child->isType( UI_TYPE_HTML_WIDGET ) )
+3 -2
View File
@@ -206,6 +206,7 @@ UISceneNode::UISceneNode( EE::Window::Window* window, bool importDefaultResource
}
UISceneNode::~UISceneNode() {
onClose();
if ( mAsyncResourceLoadState ) {
mAsyncResourceLoadState->owner.store( nullptr, std::memory_order_release );
mAsyncResourceLoadState->alive.store( false, std::memory_order_release );
@@ -422,8 +423,8 @@ UISceneNode* UISceneNode::setSmoothScrollEnabled( bool enabled, bool applyNow )
rootScene->mSmoothScrollEnabled = enabled;
if ( applyNow ) {
const auto applyToScene = [enabled]( auto&& self, UISceneNode* scene ) -> void {
for ( auto* widget : scene->findAllByType<UITouchDraggableWidget>(
UI_TYPE_TOUCH_DRAGGABLE_WIDGET ) )
for ( auto* widget :
scene->findAllByType<UITouchDraggableWidget>( UI_TYPE_TOUCH_DRAGGABLE_WIDGET ) )
widget->setSmoothScrollEnabled( enabled );
for ( auto* childScene : scene->mChildUISceneNodes )
self( self, childScene );
+2
View File
@@ -20,6 +20,7 @@
#include <eepp/system/virtualfilesystem.hpp>
#include <eepp/ui/css/stylesheetspecification.hpp>
#include <eepp/ui/doc/syntaxdefinitionmanager.hpp>
#include <eepp/ui/tools/uiinspectorserver.hpp>
#include <eepp/ui/uiscenenode.hpp>
#include <eepp/ui/uithememanager.hpp>
#include <eepp/window/backend.hpp>
@@ -348,6 +349,7 @@ EE::Window::Window* Engine::createWindow( WindowSettings Settings, ContextSettin
}
void Engine::destroyWindow( EE::Window::Window* window ) {
UIInspectorServer::notifyWindowDestroyed( window );
mWindows.erase( window->getWindowID() );
if ( window == mWindow ) {
+30
View File
@@ -1533,6 +1533,36 @@ UTEST( FlexContainer, iterativeFlexResolutionWithMinWidths ) {
Engine::destroySingleton();
}
UTEST( FlexContainer, explicitZeroMinWidthOverridesIntrinsicMinimum ) {
Engine::instance()->createWindow( WindowSettings( 1024, 650, "Flex explicit minimum test",
WindowStyle::Default, WindowBackend::Default,
32, {}, 1, false, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
init_flex_test();
auto* scene = SceneManager::instance()->getUISceneNode();
scene->loadLayoutFromString( Tools::HTMLFormatter::HTMLtoXML( R"html(
<html><head><style>
.row { display: flex; width: 1000px; gap: 64px; padding: 0 48px;
box-sizing: border-box; }
.text { flex: 0 0 320px; }
.screenshot { flex: 1.2; min-width: 0; }
.wide-content { width: 904px; height: 10px; }
</style></head><body>
<div class="row"><div class="text">Text</div><div class="screenshot">
<div class="wide-content"></div>
</div></div>
</body></html>
)html" ) );
scene->updateDirtyLayouts();
auto* row = scene->getRoot()->findByClass( "row" )->asType<UIWidget>();
auto* screenshot = scene->getRoot()->findByClass( "screenshot" )->asType<UIWidget>();
ASSERT_TRUE( row != nullptr && screenshot != nullptr );
EXPECT_LE( screenshot->getPixelsPosition().x + screenshot->getPixelsSize().getWidth(),
row->getPixelsSize().getWidth() - 48.f + 2.f );
EXPECT_NEAR( screenshot->getPixelsSize().getWidth(), 520.f, 5.f );
Engine::destroySingleton();
}
UTEST( FlexContainer, crossAxisAutoMargins ) {
// G4: margin: auto on the cross axis should center/position the item
// within the line before align-self applies (§8.1).
@@ -246,6 +246,31 @@ UTEST( UIHTMLFloat, positionedDescendantParticipatesInRootStackingScope ) {
Engine::destroySingleton();
}
UTEST( UIHTMLFloat, hiddenAncestorExcludesPromotedDescendant ) {
init_float_test();
auto* scene = SceneManager::instance()->getUISceneNode();
auto* root = UIHTMLWidget::New();
root->setParent( scene->getRoot() );
root->setPixelsSize( 300, 200 );
auto* hidden = UIHTMLWidget::New();
hidden->setParent( root );
hidden->setPixelsSize( 300, 200 );
auto* promoted = UIHTMLWidget::New();
promoted->setParent( hidden );
promoted->setPixelsSize( 200, 100 );
promoted->setCSSPosition( CSSPosition::Absolute );
promoted->setZIndex( 10 );
ASSERT_EQ( root->debugGetHTMLPaintOrder().size(), 2u );
hidden->setDisplay( CSSDisplay::None );
const auto hiddenOrder = root->debugGetHTMLPaintOrder();
EXPECT_TRUE( hiddenOrder.empty() );
EXPECT_FALSE( promoted->isHTMLPaintPromoted() );
EXPECT_NE( root->overFind( { 10, 10 } ), promoted );
hidden->setDisplay( CSSDisplay::Block );
EXPECT_EQ( root->debugGetHTMLPaintOrder().size(), 2u );
Engine::destroySingleton();
}
UTEST( UIHTMLFloat, directStackingGroupIsAtomic ) {
init_float_test();
UISceneNode* sceneNode = SceneManager::instance()->getUISceneNode();
+782
View File
@@ -0,0 +1,782 @@
#include "utest.hpp"
#include <array>
#include <chrono>
#include <eepp/network/tcpsocket.hpp>
#include <eepp/scene/keyevent.hpp>
#include <eepp/scene/scenemanager.hpp>
#include <eepp/system/filesystem.hpp>
#include <eepp/system/sys.hpp>
#include <eepp/ui/tools/uiinspector.hpp>
#include <eepp/ui/tools/uiinspectorserver.hpp>
#include <eepp/ui/uiapplication.hpp>
#include <eepp/ui/uirichtext.hpp>
#include <eepp/ui/uitextinput.hpp>
#include <eepp/ui/uitextnode.hpp>
#include <eepp/ui/uitextspan.hpp>
#include <eepp/ui/uitextview.hpp>
#include <eepp/ui/uiwebview.hpp>
#include <eepp/ui/uiwidget.hpp>
#include <eepp/window/engine.hpp>
#include <nlohmann/json.hpp>
#include <thread>
using namespace EE;
using namespace EE::Network;
using namespace EE::Scene;
using namespace EE::UI;
using namespace EE::Window;
using json = nlohmann::json;
namespace {
class InspectorClient {
public:
InspectorClient( Uint16 port ) {
connected = socket.connect( IpAddress( "127.0.0.1" ), port, Seconds( 2 ) ) == Socket::Done;
if ( connected )
socket.setBlocking( false );
}
void send( const json& request ) { sendBytes( request.dump() + "\n" ); }
void sendBytes( const std::string& bytes ) {
socket.setBlocking( true );
socket.send( bytes.data(), bytes.size() );
socket.setBlocking( false );
}
json receive() {
using namespace std::chrono;
auto deadline = steady_clock::now() + seconds( 3 );
while ( steady_clock::now() < deadline ) {
auto end = input.find( '\n' );
if ( end != std::string::npos ) {
std::string line = input.substr( 0, end );
input.erase( 0, end + 1 );
return json::parse( line );
}
Engine::instance()->updateInput();
SceneManager::instance()->update();
char bytes[4096];
size_t length = 0;
if ( socket.receive( bytes, sizeof( bytes ), length ) == Socket::Done )
input.append( bytes, length );
std::this_thread::sleep_for( milliseconds( 1 ) );
}
return nullptr;
}
bool connected{ false };
TcpSocket socket;
std::string input;
};
bool connectInspector( InspectorClient& client, const char* token ) {
client.send( { { "id", 1 },
{ "method", "session.connect" },
{ "params", { { "protocolVersion", 1 }, { "token", token } } } } );
json reply = client.receive();
return reply.is_object() && reply.contains( "result" );
}
} // namespace
UTEST( UIInspector, HandlesInvalidateAndScenesStaySeparate ) {
UIApplication app( WindowSettings( 320, 240, "Inspector Domain", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
UIApplication::Settings(
Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1.f, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
auto* scene = app.getUI();
EXPECT_EQ( UIInspectorServer::instance(), nullptr );
auto* webview = UIWebView::New();
webview->setId( "preview" );
webview->setParent( scene->getRoot() );
auto* document = webview->getDocumentSceneNode();
auto* inside = UIWidget::New();
inside->setId( "inside" );
inside->setParent( document->getRoot() );
UIInspector inspector;
inspector.setDefaultScene( scene );
std::string handle = inspector.widgetHandle( inside );
std::string handleAgain = inspector.widgetHandle( inside );
EXPECT_STREQ( handle.c_str(), handleAgain.c_str() );
EXPECT_EQ( inspector.query( scene, "#inside" ).size(), 0u );
EXPECT_EQ( inspector.query( document, "#inside" ).size(), 1u );
SmallVector<UIWidget*, 64> registered;
SmallVector<std::string, 64> registeredHandles;
for ( size_t i = 0; i < 64; ++i ) {
auto* widget = UIWidget::New();
widget->setParent( scene->getRoot() );
registered.push_back( widget );
registeredHandles.push_back( inspector.widgetHandle( widget ) );
}
EXPECT_EQ( inspector.widget( registeredHandles.back() ), registered.back() );
registered.front()->close();
SceneManager::instance()->update();
EXPECT_EQ( inspector.widget( registeredHandles.front() ), nullptr );
EXPECT_EQ( inspector.widget( registeredHandles.back() ), registered.back() );
inside->close();
SceneManager::instance()->update();
EXPECT_EQ( inspector.widget( handle ), nullptr );
ASSERT_TRUE( UIInspectorServer::start( scene ) );
EXPECT_TRUE( UIInspectorServer::instance()->getToken().size() >= 32 );
UIInspectorServer::stop();
Engine::destroySingleton();
}
UTEST( UIInspector, TcpBatchWatchAndMultipleClients ) {
UIApplication app( WindowSettings( 320, 240, "Inspector TCP", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
UIApplication::Settings(
Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1.f, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
auto* scene = app.getUI();
auto* widget = UIWidget::New();
widget->setId( "observed" );
widget->setPixelsSize( 100, 40 );
widget->setParent( scene->getRoot() );
auto* password = UITextInput::NewPassword();
password->setId( "password" );
password->setPixelsPosition( 0, 60 );
password->setText( String::fromUtf8( std::string_view( "secret-value" ) ) );
password->setParent( scene->getRoot() );
auto* longText = UITextView::New();
longText->setId( "long-text" );
longText->setPixelsPosition( 0, 110 );
longText->setText( String::fromUtf8( std::string( 5000, 'x' ) ) );
longText->setParent( scene->getRoot() );
ASSERT_TRUE( UIInspectorServer::start( scene, { "127.0.0.1", 0, false, "test-token" } ) );
auto* server = UIInspectorServer::instance();
ASSERT_TRUE( server->getPort() != 0 );
InspectorClient first( server->getPort() );
InspectorClient second( server->getPort() );
ASSERT_TRUE( first.connected );
ASSERT_TRUE( second.connected );
ASSERT_TRUE( connectInspector( first, "test-token" ) );
ASSERT_TRUE( connectInspector( second, "test-token" ) );
first.send(
{ { "id", 2 },
{ "method", "ui.query" },
{ "params", { { "selector", "#observed" }, { "properties", { "geometry.size" } } } } } );
json query = first.receive();
ASSERT_TRUE( query.contains( "result" ) );
ASSERT_EQ( query["result"]["total"].get<int>(), 1 );
std::string handle = query["result"]["nodes"][0]["handle"];
second.send(
{ { "id", 6 }, { "method", "ui.query" }, { "params", { { "selector", "#password" } } } } );
json secretQuery = second.receive();
ASSERT_TRUE( secretQuery.contains( "result" ) );
EXPECT_TRUE( secretQuery["result"]["nodes"][0]["text"] == "[redacted]" );
second.send( { { "id", 7 },
{ "method", "ui.inspect" },
{ "params", { { "selector", "#password" }, { "properties", { "*" } } } } } );
json secretInspect = second.receive();
ASSERT_TRUE( secretInspect.contains( "result" ) );
EXPECT_TRUE( secretInspect["result"]["properties"]["content.text"] == "[redacted]" );
second.send( { { "id", 8 },
{ "method", "ui.inspect" },
{ "params",
{ { "selector", "#long-text" },
{ "properties", { "content.text" } },
{ "maxStringLength", 10 } } } } );
json truncated = second.receive();
ASSERT_TRUE( truncated.contains( "result" ) );
EXPECT_EQ( truncated["result"]["properties"]["content.text"].get<std::string>().size(), 10u );
EXPECT_TRUE( truncated["result"].contains( "truncatedProperties" ) );
first.send( { { "id", 3 },
{ "method", "watch.subscribe" },
{ "params", { { "handle", handle }, { "properties", { "geometry.size" } } } } } );
json watch = first.receive();
ASSERT_TRUE( watch.contains( "result" ) );
widget->setPixelsSize( 120, 40 );
json changed = first.receive();
std::string method = changed["method"].get<std::string>();
ASSERT_STREQ( method.c_str(), "watch.changed" );
ASSERT_EQ( changed["params"]["changes"]["geometry.size"]["new"][0].get<int>(), 120 );
int clicks = 0;
widget->onClick( [&]( const MouseEvent* ) {
++clicks;
widget->setPixelsSize( 140, 40 );
} );
second.send( { { "id", 5 },
{ "method", "input.click" },
{ "params", { { "selector", "#observed" } } } } );
json click = second.receive();
ASSERT_TRUE( click.contains( "result" ) );
json clickChange = first.receive();
std::string clickMethod = clickChange["method"].get<std::string>();
ASSERT_STREQ( clickMethod.c_str(), "watch.changed" );
EXPECT_EQ( clickChange["params"]["changes"]["geometry.size"]["new"][0].get<int>(), 140 );
EXPECT_EQ( clicks, 1 );
second.send(
{ { "id", 4 },
{ "method", "session.batch" },
{ "params",
{ { "commands",
json::array( { { { "name", "found" },
{ "method", "ui.query" },
{ "return", false },
{ "params", { { "selector", "#observed" } } } },
{ { "name", "state" },
{ "method", "ui.inspect" },
{ "params",
{ { "handle", { { "$ref", "found#/nodes/0/handle" } } },
{ "properties", { "geometry.size" } } } } } } ) } } } } );
json batch = second.receive();
ASSERT_TRUE( batch.contains( "result" ) );
EXPECT_TRUE( batch["result"]["results"].contains( "state" ) );
EXPECT_FALSE( batch["result"]["results"].contains( "found" ) );
second.send(
{ { "id", 10 },
{ "method", "session.batch" },
{ "params",
{ { "commands",
json::array(
{ { { "method", "ui.inspect" },
{ "params",
{ { "handle", { { "$ref", "unknown#/handle" } } } } } } } ) } } } } );
json badReference = second.receive();
EXPECT_TRUE( badReference.contains( "error" ) &&
badReference["error"]["code"] == "batch-command-failed" );
EXPECT_TRUE( badReference["error"]["data"]["commandError"]["code"] == "batch-reference-error" );
second.send(
{ { "id", 11 },
{ "method", "session.batch" },
{ "params",
{ { "commands",
json::array( { { { "method", "input.click" },
{ "delay", "bad" },
{ "params", { { "selector", "#observed" } } } } } ) } } } } );
json badDelay = second.receive();
EXPECT_TRUE( badDelay.contains( "error" ) );
EXPECT_EQ( clicks, 1 );
widget->close();
Engine::instance()->updateInput();
SceneManager::instance()->update();
json removed = first.receive();
json ended = first.receive();
EXPECT_TRUE( removed["method"] == "watch.targetRemoved" );
EXPECT_TRUE( ended["method"] == "watch.ended" );
second.send(
{ { "id", 9 }, { "method", "ui.inspect" }, { "params", { { "handle", handle } } } } );
json invalid = second.receive();
EXPECT_TRUE( invalid.contains( "error" ) && invalid["error"]["code"] == "invalid-widget" );
UIInspectorServer::stop();
Engine::destroySingleton();
}
UTEST( UIInspector, InputActionErrorsAndKeyEvents ) {
UIApplication app( WindowSettings( 320, 240, "Inspector Input", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
UIApplication::Settings(
Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1.f, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
auto* scene = app.getUI();
auto* first = UIWidget::New();
first->setId( "first" );
first->setClass( "ambiguous" );
first->setPixelsPosition( 10, 10 );
first->setPixelsSize( 30, 30 );
first->setParent( scene->getRoot() );
auto* second = UIWidget::New();
second->setClass( "ambiguous" );
second->setPixelsPosition( 50, 10 );
second->setPixelsSize( 30, 30 );
second->setParent( scene->getRoot() );
auto* hidden = UIWidget::New();
hidden->setId( "hidden" );
hidden->setPixelsPosition( 10, 50 );
hidden->setPixelsSize( 30, 30 );
hidden->setParent( scene->getRoot() );
hidden->setVisible( false );
auto* outside = UIWidget::New();
outside->setId( "outside" );
outside->setPixelsPosition( 400, 400 );
outside->setPixelsSize( 30, 30 );
outside->setParent( scene->getRoot() );
auto* covered = UIWidget::New();
covered->setId( "covered" );
covered->setPixelsPosition( 100, 100 );
covered->setPixelsSize( 40, 40 );
covered->setParent( scene->getRoot() );
auto* blocker = UIWidget::New();
blocker->setPixelsPosition( 100, 100 );
blocker->setPixelsSize( 40, 40 );
blocker->setParent( scene->getRoot() );
int clicks = 0;
first->onClick( [&]( const MouseEvent* ) { ++clicks; } );
covered->onClick( [&]( const MouseEvent* ) { ++clicks; } );
scene->flushDirtyStyleAndLayout();
ASSERT_TRUE( UIInspectorServer::start( scene, { "127.0.0.1", 0, false, "input-token" } ) );
InspectorClient client( UIInspectorServer::instance()->getPort() );
ASSERT_TRUE( client.connected );
ASSERT_TRUE( connectInspector( client, "input-token" ) );
const std::array<std::pair<const char*, const char*>, 4> rejectedTargets{
{ { ".ambiguous", "target-ambiguous" },
{ "#hidden", "target-not-interactable" },
{ "#outside", "target-not-interactable" },
{ "#covered", "target-not-interactable" } } };
int id = 2;
for ( const auto& [selector, expectedCode] : rejectedTargets ) {
client.send( { { "id", id++ },
{ "method", "input.click" },
{ "params", { { "selector", selector } } } } );
json response = client.receive();
ASSERT_TRUE( response.contains( "error" ) );
EXPECT_TRUE( response["error"]["code"] == expectedCode );
}
EXPECT_EQ( clicks, 0 );
client.send( { { "id", id++ },
{ "method", "input.click" },
{ "params", { { "selector", "#first" } } } } );
ASSERT_TRUE( client.receive().contains( "result" ) );
EXPECT_EQ( clicks, 1 );
scene->getEventDispatcher()->setFocusNode( first );
int keyDown = 0;
int keyUp = 0;
Uint32 lastModifiers = 0;
first->addEventListener( Event::KeyDown, [&]( const Event* event ) {
++keyDown;
lastModifiers = event->asKeyEvent()->getMod();
} );
first->addEventListener( Event::KeyUp, [&]( const Event* event ) {
++keyUp;
lastModifiers = event->asKeyEvent()->getMod();
} );
client.send(
{ { "id", id++ },
{ "method", "input.key" },
{ "params",
{ { "key", "F" }, { "action", "down" }, { "modifiers", { "Ctrl", "Shift" } } } } } );
ASSERT_TRUE( client.receive().contains( "result" ) );
EXPECT_EQ( keyDown, 1 );
EXPECT_EQ( keyUp, 0 );
EXPECT_EQ( lastModifiers & ( KEYMOD_LCTRL | KEYMOD_LSHIFT ),
static_cast<Uint32>( KEYMOD_LCTRL | KEYMOD_LSHIFT ) );
client.send( { { "id", id++ },
{ "method", "input.key" },
{ "params", { { "key", "F" }, { "action", "up" } } } } );
ASSERT_TRUE( client.receive().contains( "result" ) );
EXPECT_EQ( keyDown, 1 );
EXPECT_EQ( keyUp, 1 );
client.send( { { "id", id++ },
{ "method", "input.key" },
{ "params", { { "key", "F" }, { "action", "press" } } } } );
ASSERT_TRUE( client.receive().contains( "result" ) );
EXPECT_EQ( keyDown, 2 );
EXPECT_EQ( keyUp, 2 );
client.send(
{ { "id", id++ }, { "method", "input.key" }, { "params", { { "key", "not-a-key" } } } } );
json invalidKey = client.receive();
ASSERT_TRUE( invalidKey.contains( "error" ) );
EXPECT_TRUE( invalidKey["error"]["code"] == "invalid-params" );
EXPECT_EQ( keyDown, 2 );
EXPECT_EQ( keyUp, 2 );
UIInspectorServer::stop();
Engine::destroySingleton();
}
UTEST( UIInspector, HTMLTextSummariesAndProperties ) {
UIApplication app( WindowSettings( 320, 240, "Inspector HTML Text", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
UIApplication::Settings(
Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1.f, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
auto* scene = app.getUI();
auto* webview = UIWebView::New();
webview->setId( "preview" );
webview->setPixelsSize( 220, 160 );
webview->setParent( scene->getRoot() );
auto* document = webview->getDocumentSceneNode();
auto* paragraph = UIRichText::NewParagraph();
paragraph->setId( "article" );
paragraph->setParent( document->getRoot() );
auto* source = UITextNode::New();
source->setId( "source" );
source->setText( "Hello " );
source->setParent( paragraph );
auto* span = UITextSpan::NewStrong();
span->setId( "emphasis" );
span->setText( "world" );
span->setParent( paragraph );
auto* punctuation = UITextNode::New();
punctuation->setText( "!" );
punctuation->setParent( span );
paragraph->setPixelsSize( 200, 30 );
ASSERT_TRUE( UIInspectorServer::start( scene, { "127.0.0.1", 0, false, "html-token" } ) );
InspectorClient client( UIInspectorServer::instance()->getPort() );
ASSERT_TRUE( client.connected );
ASSERT_TRUE( connectInspector( client, "html-token" ) );
client.send(
{ { "id", 2 }, { "method", "ui.query" }, { "params", { { "selector", "#preview" } } } } );
json preview = client.receive();
ASSERT_TRUE( preview.contains( "result" ) );
std::string documentScene = preview["result"]["nodes"][0]["documentScene"];
client.send( { { "id", 3 },
{ "method", "ui.query" },
{ "params", { { "scene", documentScene }, { "selector", "#article" } } } } );
json article = client.receive();
ASSERT_TRUE( article.contains( "result" ) );
ASSERT_EQ( article["result"]["nodes"].size(), 1u );
EXPECT_TRUE( article["result"]["nodes"][0]["text"] == "Hello world!" );
std::string articleHandle = article["result"]["nodes"][0]["handle"];
client.send( { { "id", 4 },
{ "method", "ui.query" },
{ "params", { { "scene", documentScene }, { "selector", "#emphasis" } } } } );
json emphasis = client.receive();
ASSERT_TRUE( emphasis.contains( "result" ) );
EXPECT_TRUE( emphasis["result"]["nodes"][0]["text"] == "world!" );
client.send( { { "id", 5 },
{ "method", "ui.query" },
{ "params", { { "scene", documentScene }, { "selector", "#source" } } } } );
json sourceResult = client.receive();
ASSERT_TRUE( sourceResult.contains( "result" ) );
EXPECT_TRUE( sourceResult["result"]["nodes"][0]["text"] == "Hello " );
client.send(
{ { "id", 6 },
{ "method", "ui.inspect" },
{ "params", { { "handle", articleHandle }, { "properties", { "content.text" } } } } } );
json inspection = client.receive();
ASSERT_TRUE( inspection.contains( "result" ) );
EXPECT_TRUE( inspection["result"]["properties"]["content.text"] == "Hello world!" );
client.send( { { "id", 11 },
{ "method", "ui.query" },
{ "params",
{ { "scene", documentScene },
{ "selector", "#article" },
{ "properties", { "content.text" } } } } } );
json inlineProperties = client.receive();
ASSERT_TRUE( inlineProperties.contains( "result" ) );
EXPECT_TRUE( inlineProperties["result"]["nodes"][0]["properties"]["content.text"] ==
"Hello world!" );
client.send(
{ { "id", 12 },
{ "method", "ui.tree" },
{ "params", { { "root", articleHandle }, { "depth", 0 }, { "includeText", true } } } } );
json tree = client.receive();
ASSERT_TRUE( tree.contains( "result" ) );
EXPECT_TRUE( tree["result"]["nodes"][0]["text"] == "Hello world!" );
client.send(
{ { "id", 7 },
{ "method", "watch.subscribe" },
{ "params", { { "handle", articleHandle }, { "properties", { "content.text" } } } } } );
json watch = client.receive();
ASSERT_TRUE( watch.contains( "result" ) );
EXPECT_TRUE( watch["result"]["targets"][0]["values"]["content.text"] == "Hello world!" );
span->setText( "eepp" );
json changed = client.receive();
ASSERT_TRUE( changed.contains( "method" ) );
EXPECT_TRUE( changed["method"] == "watch.changed" );
EXPECT_TRUE( changed["params"]["changes"]["content.text"]["old"] == "Hello world!" );
EXPECT_TRUE( changed["params"]["changes"]["content.text"]["new"] == "Hello eepp!" );
client.send( { { "id", 8 },
{ "method", "watch.unsubscribe" },
{ "params", { { "subscription", watch["result"]["subscription"] } } } } );
ASSERT_TRUE( client.receive().contains( "result" ) );
auto* password = UITextInput::NewPassword();
password->setText( String::fromUtf8( std::string_view( "do-not-expose" ) ) );
password->setParent( paragraph );
client.send(
{ { "id", 13 },
{ "method", "ui.inspect" },
{ "params", { { "handle", articleHandle }, { "properties", { "content.text" } } } } } );
json parentText = client.receive();
ASSERT_TRUE( parentText.contains( "result" ) );
EXPECT_TRUE( parentText["result"]["properties"]["content.text"] == "Hello eepp!" );
auto* longSource = UITextNode::New();
longSource->setId( "long-source" );
longSource->setText( String::fromUtf8( std::string( 300, 'x' ) ) );
longSource->setParent( paragraph );
client.send( { { "id", 9 },
{ "method", "ui.query" },
{ "params", { { "scene", documentScene }, { "selector", "#long-source" } } } } );
json longSummary = client.receive();
ASSERT_TRUE( longSummary.contains( "result" ) );
EXPECT_EQ( longSummary["result"]["nodes"][0]["text"].get<std::string>().size(), 256u );
EXPECT_TRUE( longSummary["result"]["nodes"][0]["textTruncated"] == true );
client.send( { { "id", 10 },
{ "method", "ui.inspect" },
{ "params",
{ { "scene", documentScene },
{ "selector", "#article" },
{ "properties", { "content.text" } },
{ "maxStringLength", 10 } } } } );
json shortInspection = client.receive();
ASSERT_TRUE( shortInspection.contains( "result" ) );
EXPECT_TRUE( shortInspection["result"]["properties"]["content.text"] == "Hello eepp" );
EXPECT_TRUE( shortInspection["result"]["truncatedProperties"][0] == "content.text" );
UIInspectorServer::stop();
Engine::destroySingleton();
}
UTEST( UIInspector, WebViewSecondaryWindowAndDeferredBatch ) {
UIApplication app( WindowSettings( 320, 240, "Inspector Primary", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
UIApplication::Settings(
Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1.f, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
auto* mainScene = app.getUI();
auto* webview = UIWebView::New();
webview->setId( "preview" );
webview->setPixelsSize( 200, 100 );
webview->setParent( mainScene->getRoot() );
auto* document = webview->getDocumentSceneNode();
auto* htmlWidget = UIWidget::New();
htmlWidget->setId( "inside" );
htmlWidget->setPixelsSize( 60, 30 );
htmlWidget->setParent( document->getRoot() );
int innerClicks = 0;
htmlWidget->onClick( [&]( const MouseEvent* ) { ++innerClicks; } );
auto* textInput = UITextInput::New();
textInput->setId( "edit" );
textInput->setPixelsPosition( 0, 40 );
textInput->setPixelsSize( 100, 30 );
textInput->setParent( document->getRoot() );
auto* secondary =
app.createWindow( WindowSettings( 280, 180, "Inspector Secondary", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
ASSERT_TRUE( secondary != nullptr );
auto* button = UIWidget::New();
button->setId( "secondary-button" );
button->setPixelsPosition( 30, 30 );
button->setPixelsSize( 100, 50 );
button->setParent( secondary->getRoot() );
int clicks = 0;
button->onClick( [&]( const MouseEvent* ) { ++clicks; } );
mainScene->flushDirtyStyleAndLayout();
secondary->flushDirtyStyleAndLayout();
ASSERT_TRUE(
UIInspectorServer::start( mainScene, { "127.0.0.1", 0, false, "contexts-token" } ) );
InspectorClient client( UIInspectorServer::instance()->getPort() );
ASSERT_TRUE( client.connected );
ASSERT_TRUE( connectInspector( client, "contexts-token" ) );
client.send( { { "id", 2 }, { "method", "ui.contexts" } } );
json contexts = client.receive();
ASSERT_TRUE( contexts.contains( "result" ) );
EXPECT_EQ( contexts["result"]["windows"].size(), 2u );
std::string secondaryScene;
std::string secondaryWindow;
for ( const auto& item : contexts["result"]["scenes"] ) {
if ( item["kind"] == "top-level" &&
item["window"] != contexts["result"]["defaultWindow"] ) {
secondaryScene = item["handle"].get<std::string>();
secondaryWindow = item["window"].get<std::string>();
}
}
ASSERT_TRUE( !secondaryScene.empty() );
client.send(
{ { "id", 3 }, { "method", "ui.query" }, { "params", { { "selector", "#preview" } } } } );
json preview = client.receive();
ASSERT_TRUE( preview.contains( "result" ) );
std::string documentScene = preview["result"]["nodes"][0]["documentScene"];
std::string previewHandle = preview["result"]["nodes"][0]["handle"];
bool foundDocumentScene = false;
for ( const auto& item : contexts["result"]["scenes"] ) {
if ( item["handle"] == documentScene ) {
foundDocumentScene = true;
EXPECT_TRUE( item["kind"] == "webview" );
EXPECT_TRUE( item["parentScene"] == contexts["result"]["defaultScene"] );
EXPECT_TRUE( item["owner"] == previewHandle );
EXPECT_TRUE( item["window"] == contexts["result"]["defaultWindow"] );
}
}
EXPECT_TRUE( foundDocumentScene );
client.send( { { "id", 24 },
{ "method", "ui.screenshot" },
{ "params", { { "scene", documentScene } } } } );
json documentImage = client.receive();
ASSERT_TRUE( documentImage.contains( "result" ) );
EXPECT_TRUE( documentImage["result"]["scene"] == documentScene );
EXPECT_TRUE( documentImage["result"]["sizePx"] == json::array( { 200, 100 } ) );
FileSystem::fileRemove( documentImage["result"]["path"].get<std::string>() );
client.send( { { "id", 25 },
{ "method", "ui.screenshot" },
{ "params", { { "window", secondaryWindow } } } } );
json secondaryImage = client.receive();
ASSERT_TRUE( secondaryImage.contains( "result" ) );
EXPECT_TRUE( secondaryImage["result"]["sizePx"] == json::array( { 280, 180 } ) );
FileSystem::fileRemove( secondaryImage["result"]["path"].get<std::string>() );
int secondaryKeyDown = 0;
button->addEventListener( Event::KeyDown, [&]( const Event* event ) {
if ( event->asKeyEvent()->getKeyCode() == KEY_F )
++secondaryKeyDown;
} );
secondary->getEventDispatcher()->setFocusNode( button );
client.send( { { "id", 11 },
{ "method", "input.key" },
{ "params", { { "scene", secondaryScene }, { "key", "F" } } } } );
ASSERT_TRUE( client.receive().contains( "result" ) );
EXPECT_EQ( secondaryKeyDown, 1 );
client.send(
{ { "id", 4 }, { "method", "ui.query" }, { "params", { { "selector", "#inside" } } } } );
json outer = client.receive();
ASSERT_TRUE( outer.contains( "result" ) );
EXPECT_EQ( outer["result"]["total"].get<int>(), 0 );
client.send( { { "id", 5 },
{ "method", "ui.query" },
{ "params", { { "scene", documentScene }, { "selector", "#inside" } } } } );
json inner = client.receive();
ASSERT_TRUE( inner.contains( "result" ) );
EXPECT_EQ( inner["result"]["total"].get<int>(), 1 );
client.send( { { "id", 7 },
{ "method", "input.click" },
{ "params", { { "scene", documentScene }, { "selector", "#inside" } } } } );
json innerClick = client.receive();
ASSERT_TRUE( innerClick.contains( "result" ) );
EXPECT_EQ( innerClicks, 1 );
client.send( { { "id", 8 },
{ "method", "input.click" },
{ "params", { { "scene", documentScene }, { "selector", "#edit" } } } } );
json editClick = client.receive();
ASSERT_TRUE( editClick.contains( "result" ) );
client.send( { { "id", 9 },
{ "method", "input.text" },
{ "params", { { "scene", documentScene }, { "text", "h\u00e9llo" } } } } );
json typed = client.receive();
ASSERT_TRUE( typed.contains( "result" ) );
std::string entered = textInput->getText().toUtf8();
EXPECT_STREQ( entered.c_str(), "h\u00e9llo" );
client.send(
{ { "id", 10 },
{ "method", "input.key" },
{ "params",
{ { "scene", documentScene }, { "key", "Backspace" }, { "action", "press" } } } } );
json pressed = client.receive();
ASSERT_TRUE( pressed.contains( "result" ) );
std::string afterBackspace = textInput->getText().toUtf8();
EXPECT_STREQ( afterBackspace.c_str(), "h\u00e9ll" );
client.send(
{ { "id", 6 },
{ "method", "session.batch" },
{ "params",
{ { "commands",
json::array(
{ { { "method", "input.click" },
{ "return", false },
{ "delay", "20ms" },
{ "params",
{ { "scene", secondaryScene }, { "selector", "#secondary-button" } } } },
{ { "method", "session.nextFrame" }, { "return", false } },
{ { "name", "state" },
{ "method", "ui.inspect" },
{ "params",
{ { "scene", secondaryScene },
{ "selector", "#secondary-button" },
{ "properties", { "state.visible" } } } } } } ) } } } } );
json batch = client.receive();
ASSERT_TRUE( batch.contains( "result" ) );
EXPECT_EQ( clicks, 1 );
EXPECT_TRUE( batch["result"]["results"].contains( "state" ) );
UIInspectorServer::stop();
Engine::destroySingleton();
}
UTEST( UIInspector, AuthenticationFramingAndReadOnly ) {
UIApplication app( WindowSettings( 320, 240, "Inspector Security", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
UIApplication::Settings(
Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1.f, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
ASSERT_TRUE( UIInspectorServer::start( app.getUI(), { "127.0.0.1", 0, true, "read-token" } ) );
Uint16 port = UIInspectorServer::instance()->getPort();
InspectorClient wrong( port );
ASSERT_TRUE( wrong.connected );
wrong.send( { { "id", 1 },
{ "method", "session.connect" },
{ "params", { { "protocolVersion", 1 }, { "token", "wrong" } } } } );
json rejected = wrong.receive();
ASSERT_TRUE( rejected.contains( "error" ) );
std::string code = rejected["error"]["code"];
EXPECT_STREQ( code.c_str(), "authentication-failed" );
InspectorClient client( port );
ASSERT_TRUE( client.connected );
ASSERT_TRUE( connectInspector( client, "read-token" ) );
client.sendBytes( "{\"id\":2,\"method\":\"ui.con" );
client.sendBytes( "texts\"}\r\n{\"id\":3,\"method\":\"ui.focus\"}\n" );
json contexts = client.receive();
json focus = client.receive();
ASSERT_TRUE( contexts.contains( "result" ) );
ASSERT_TRUE( focus.contains( "result" ) );
EXPECT_EQ( contexts["id"].get<int>(), 2 );
EXPECT_EQ( focus["id"].get<int>(), 3 );
const std::array<json, 3> actions{
{ { { "method", "input.click" }, { "params", { { "selector", "*" } } } },
{ { "method", "input.key" }, { "params", { { "key", "Enter" } } } },
{ { "method", "input.text" }, { "params", { { "text", "blocked" } } } } } };
int actionId = 4;
for ( auto action : actions ) {
action["id"] = actionId++;
client.send( action );
json denied = client.receive();
ASSERT_TRUE( denied.contains( "error" ) );
EXPECT_TRUE( denied["error"]["code"] == "permission-denied" );
}
client.sendBytes( "not-json\n" );
json parse = client.receive();
ASSERT_TRUE( parse.contains( "error" ) );
std::string parseCode = parse["error"]["code"];
EXPECT_STREQ( parseCode.c_str(), "parse-error" );
UIInspectorServer::stop();
Engine::destroySingleton();
}
UTEST( UIInspector, ScreenshotCapturesRenderedPixelsAndCrop ) {
UIApplication app( WindowSettings( 320, 240, "Inspector Screenshot", WindowStyle::Default,
WindowBackend::Default, 32, {}, 1, false, true ),
UIApplication::Settings(
Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1.f, true ),
ContextSettings( false, 0, 0, GLv_default, true, false ) );
auto* scene = app.getUI();
auto* panel = UIWidget::New();
panel->setPixelsSize( 320, 240 );
panel->setBackgroundColor( Color::Red );
panel->setParent( scene->getRoot() );
ASSERT_TRUE( UIInspectorServer::start( scene, { "127.0.0.1", 0, true, "image-token" } ) );
InspectorClient client( UIInspectorServer::instance()->getPort() );
ASSERT_TRUE( client.connected );
ASSERT_TRUE( connectInspector( client, "image-token" ) );
client.send( { { "id", 2 }, { "method", "ui.screenshot" } } );
json full = client.receive();
ASSERT_TRUE( full.contains( "result" ) );
std::string fullPath = full["result"]["path"];
EXPECT_TRUE( FileSystem::fileExists( fullPath ) );
EXPECT_TRUE( full["result"]["sizePx"] == json::array( { 320, 240 } ) );
Graphics::Image fullImage( fullPath );
EXPECT_EQ( fullImage.getWidth(), 320u );
EXPECT_EQ( fullImage.getHeight(), 240u );
EXPECT_GT( fullImage.getPixel( 10, 10 ).r, 200 );
EXPECT_LT( fullImage.getPixel( 10, 10 ).b, 50 );
FileSystem::fileRemove( fullPath );
panel->setBackgroundColor( Color::Blue );
client.send( { { "id", 3 },
{ "method", "ui.screenshot" },
{ "params", { { "rect", { 8, 12, 40, 30 } }, { "format", "bmp" } } } } );
json cropped = client.receive();
ASSERT_TRUE( cropped.contains( "result" ) );
std::string croppedPath = cropped["result"]["path"];
Graphics::Image croppedImage( croppedPath );
EXPECT_EQ( croppedImage.getWidth(), 40u );
EXPECT_EQ( croppedImage.getHeight(), 30u );
EXPECT_GT( croppedImage.getPixel( 2, 2 ).b, 200 );
EXPECT_LT( croppedImage.getPixel( 2, 2 ).r, 50 );
FileSystem::fileRemove( croppedPath );
client.send( { { "id", 4 },
{ "method", "ui.screenshot" },
{ "params", { { "rect", { 300, 0, 40, 30 } } } } } );
json invalidRect = client.receive();
ASSERT_TRUE( invalidRect.contains( "error" ) );
EXPECT_TRUE( invalidRect["error"]["code"] == "invalid-params" );
client.send(
{ { "id", 5 }, { "method", "ui.screenshot" }, { "params", { { "format", "gif" } } } } );
json invalidFormat = client.receive();
ASSERT_TRUE( invalidFormat.contains( "error" ) );
EXPECT_TRUE( invalidFormat["error"]["code"] == "invalid-params" );
UIInspectorServer::stop();
Engine::destroySingleton();
}