diff --git a/.github/main-ruleset.json b/.github/main-ruleset.json index 0b09ef85..79d5ddb0 100644 --- a/.github/main-ruleset.json +++ b/.github/main-ruleset.json @@ -51,6 +51,14 @@ "context": "Rust quality", "integration_id": 15368 }, + { + "context": "Public API", + "integration_id": 15368 + }, + { + "context": "Rust coverage", + "integration_id": 15368 + }, { "context": "Desktop compile (Windows)", "integration_id": 15368 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38293675..17a793e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,52 @@ jobs: name: Rust quality needs: security runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Install Linux build dependencies + run: ./scripts/install-linux-ci.sh + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: '1.98.0' + components: rustfmt, clippy + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 + with: + key: quality-static + cache-on-failure: true + - name: Validate registry manifests + run: python3 scripts/release.py check + - name: Run source, formatting, Clippy, Wasm, and public API checks + run: ./scripts/check-quality-static.sh + + semver: + name: Public API + needs: security + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + with: + toolchain: '1.98.0' + - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 + with: + tool: cargo-semver-checks@0.49.0 + - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 + with: + key: semver + - name: Compare publishable APIs with crates.io + run: cargo semver-checks check-release --workspace --release-type minor + + coverage: + name: Rust coverage + needs: security + runs-on: ubuntu-24.04 timeout-minutes: 90 env: ARGUI_NATIVE_TESTS: '1' @@ -80,8 +126,8 @@ jobs: cache-on-failure: true - name: Validate registry manifests run: python3 scripts/release.py check - - name: Run the complete 85% quality gate on a private display - run: ./scripts/linux-hidden-display.sh ./scripts/quality.sh + - name: Run the 85% coverage gate on a private display + run: ./scripts/linux-hidden-display.sh ./scripts/check-coverage.sh - name: Save coverage results if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -332,7 +378,7 @@ jobs: release: name: Publish crates and GitHub release - needs: [security, quality, desktop, mobile, android-package, ios-package, packages, website] + needs: [security, quality, semver, coverage, desktop, mobile, android-package, ios-package, packages, website] if: >- github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'ExtraBinoss/argui' && github.actor == 'ExtraBinoss' && diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d83cee..70fb8e64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ that do not use them. ### Added +- Added incremental `TextEdit` delivery for `Input` and `TextArea`, including + `on_edit`, context callback helpers, UTF-8 validation, fragment-based history, + and lazy compatibility delivery for the existing `on_input(String)` API. +- Added opaque `EventHandler`/typed `ValueHandler` bindings and local direct + handlers across interactive widgets, including domain payloads for forms, + selection, ranges, overlays, menus, navigation, data controls, and composites. +- Added the publishable `argui-testing` crate with real headless layout and hit + testing, accessible queries, editing, focus, scrolling, gestures, lifecycle, + multiple windows, diagnostic settle bounds, and deterministic task time. +- Added the small `argui::prelude`, `basic`, `desktop`, and `web` convenience + feature profiles, while preserving every granular feature. +- Added staged archive verification and a public API/SemVer CI job for the 0.3 + release line. +- Added installable Android Widget Gallery packaging for ARM64 devices and + x86-64 emulators, including launcher and notification icons, command-line SDK + scripts, USB deployment, and native soft-keyboard integration. +- Added shared mobile background-activity state with an Android foreground + service and ongoing progress notification, plus an iOS ActivityKit bridge and + SwiftUI Lock Screen/Dynamic Island extension kept under `argui-ios`. +- Added direct-touch momentum, configurable natural scrolling, drag-safe click + activation, and draggable text-selection handles shared by Android and iOS. - Added retained GPU canvases for editor, visualization, game/map and scientific viewports. `Element::gpu_canvas`, `GpuCanvasSpec` and the renderer-neutral `CustomPaintContext::gpu_canvas` helper preserve normal Argui layout, @@ -44,10 +65,58 @@ that do not use them. palette while keeping `shadcn` as a compatible alias. - Added an interactive documentation site whose lessons display and run their exact Rust source as dedicated WebAssembly examples, including a complete - light/dark, accent and token override configurator. + light/dark, accent and token override configurator, plus side-by-side solid + and backdrop-blurred popover surfaces in the overlays lesson. +- Added dedicated Technicalities and Platforms documentation categories that + explain retained versus immediate UI, Argui's scope, the audited target + support matrix, and a clearly labelled Android/iOS capability roadmap. +- Added an interaction API decision guide with a callback selection table and + an exact-source live example that distinguishes `on_change`, `on_commit`, and + `on_click`. +- Added a Widget Gallery drag-and-drop board with live data reordering, retained + image cards, pointer capture, keyboard/accessibility alternatives, and + velocity-driven squash, stretch, shadow, and spring settling. +- Added a working Widget Gallery split-pane showcase with horizontal, vertical, + trailing, and nested IDE layouts, all resizable by pointer or keyboard. +- Added a Technicalities performance guide explaining `Immediate` versus + `FrameCoalesced`, its requestAnimationFrame-shaped scheduling contract, and + appropriate high-frequency workloads. ### Fixed +- Replaced unsupported checklist glyphs in the clean-code documentation example + with explicit status labels, and made each completion step visible in the + button label before its final disabled state. +- Rebuilt the custom-element lesson as a recognizable video-editor timeline + with labeled tracks, fitted clip text, draggable clips, time ruler, timecode, + and a forgiving draggable, keyboard-accessible playhead. +- Made mouse text selection start from the nearest selectable text when a drag + begins in non-interactive whitespace, matching browser-style document + selection without stealing button, editor, or custom gesture input. +- Embedded Arabic, Hebrew, and emoji fallback fonts in every shipped WebAssembly + application so localized text no longer renders as missing-glyph squares. +- Applied coalesced split-pane gesture deltas to the latest controlled value so + pane sizes no longer drift when the view rerenders during a drag. +- Coalesced drag-and-drop updates to one delivery per rendered frame, skipped + redundant list mutations, and replaced the expensive offscreen blur with + transform-locked shadow plates so fast drags remain responsive and artifact-free. +- Fixed frame-coalesced pan delivery to accumulate every raw delta between + display frames, keeping split-pane handles exactly under the pointer during + fast input on Firefox and other high-rate hosts. +- Smoothed the Widget Gallery drag-and-drop velocity deformation with + frame-rate-independent interpolation while keeping its position and live + velocity label on the same frame-coalesced callback. +- Clamped, wrapped, and clipped split-pane panel text and raised compact vertical + pane minima so content cannot paint outside its panel. +- Made Android and iOS render edge to edge while preserving native safe areas, + painting the active theme behind transparent system regions and matching + Android system-icon contrast to light and dark themes. +- Prevented a closed or animated DevTools dock from reserving the bottom system + inset, which removed the moving band of repeated framebuffer pixels on + Android without hiding content behind the status or navigation bars. +- Made mobile search reliably focus and open the software keyboard, preserved + adjustable selection handles, and stopped a touch scroll from activating the + item released beneath the finger. - Cleared workspace crate artifacts and the temporary crates.io registry before archive checks, preventing same-version caches from masking coordinated workspace changes and breaking dependent archive verification. @@ -74,6 +143,12 @@ that do not use them. ### Changed +- Moved the workspace to 0.3.0. `Context::callback` now provides the short + invalidating path; `event_handler`, `listener`, `Element::on`, and typed + behavior/action APIs remain the explicit advanced layer. +- Migrated every naturally local documentation example, its exact generated + website snippet, the Widget Gallery, and the fake AI harness to direct + callbacks while retaining the Events delegation example and complex reducers. - Made vertical canvas dragging follow the content by default, added an in-app natural/inverted direction toggle, aligned keyboard panning with the selected direction and smoothed wheel/pinch zoom interaction. @@ -85,6 +160,10 @@ that do not use them. ### Known limitations +- Native file picking is not wired to Android's document provider or the iOS + document picker yet. The cross-platform file-picker widget still compiles on + mobile and reports the mode as unsupported; adding the two native adapters + does not require a change to its public model. - GPU canvases use Argui-owned WGPU instances, devices, queues and surfaces; the high-level runtime still does not accept externally owned GPU objects. - Canvas pixels have no automatic semantic meaning. Applications must provide @@ -145,4 +224,4 @@ that do not use them. [0.2.1]: https://github.com/ExtraBinoss/argui/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/ExtraBinoss/argui/releases/tag/v0.2.0 -[Unreleased]: https://github.com/ExtraBinoss/argui/compare/v0.2.1...HEAD +[Unreleased]: https://github.com/ExtraBinoss/argui/compare/v0.3.0...HEAD diff --git a/Cargo.lock b/Cargo.lock index 3645e817..bd961daa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,7 +224,7 @@ dependencies = [ [[package]] name = "argui" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-accessibility", "argui-animation", @@ -248,7 +248,7 @@ dependencies = [ [[package]] name = "argui-accessibility" -version = "0.2.1" +version = "0.3.0" dependencies = [ "accesskit", "accesskit_consumer", @@ -259,7 +259,7 @@ dependencies = [ [[package]] name = "argui-android" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-platform", "argui-render", @@ -270,18 +270,18 @@ dependencies = [ [[package]] name = "argui-animation" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", ] [[package]] name = "argui-core" -version = "0.2.1" +version = "0.3.0" [[package]] name = "argui-devtools" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-animation", "argui-core", @@ -307,7 +307,7 @@ dependencies = [ [[package]] name = "argui-effects" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-paint", "argui-render", @@ -318,7 +318,7 @@ dependencies = [ [[package]] name = "argui-i18n" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-ui", "fluent-bundle", @@ -329,7 +329,7 @@ dependencies = [ [[package]] name = "argui-image" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-paint", "image", @@ -338,7 +338,7 @@ dependencies = [ [[package]] name = "argui-inspect" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "serde", @@ -347,14 +347,14 @@ dependencies = [ [[package]] name = "argui-ios" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-runtime", ] [[package]] name = "argui-layout" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-animation", "argui-core", @@ -368,14 +368,14 @@ dependencies = [ [[package]] name = "argui-paint" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", ] [[package]] name = "argui-perf-showcase" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui", "wasm-bindgen", @@ -383,23 +383,28 @@ dependencies = [ [[package]] name = "argui-platform" -version = "0.2.1" +version = "0.3.0" dependencies = [ + "android-activity", "arboard", "argui-core", "argui-paint", "ashpd", "base64", + "block2 0.6.2", + "dispatch2", "futures-channel", "futures-util", "gtk", "image", + "jni 0.22.4", "ksni", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-graphics", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", + "objc2-ui-kit 0.3.2", "pollster 1.0.1", "raw-window-handle", "rfd", @@ -419,7 +424,7 @@ dependencies = [ [[package]] name = "argui-render" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "argui-paint", @@ -435,7 +440,7 @@ dependencies = [ [[package]] name = "argui-runtime" -version = "0.2.1" +version = "0.3.0" dependencies = [ "accesskit", "accesskit_winit", @@ -466,7 +471,7 @@ dependencies = [ [[package]] name = "argui-showcase" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-animation", "argui-core", @@ -483,7 +488,7 @@ dependencies = [ [[package]] name = "argui-state-app" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui", "argui-devtools", @@ -492,9 +497,26 @@ dependencies = [ "winresource", ] +[[package]] +name = "argui-testing" +version = "0.3.0" +dependencies = [ + "argui-accessibility", + "argui-animation", + "argui-core", + "argui-layout", + "argui-platform", + "argui-runtime", + "argui-text", + "argui-ui", + "argui-widgets", + "epaint_default_fonts", + "thiserror 2.0.20", +] + [[package]] name = "argui-text" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "cosmic-text", @@ -503,14 +525,14 @@ dependencies = [ [[package]] name = "argui-theme" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", ] [[package]] name = "argui-ui" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-accessibility", "argui-animation", @@ -525,7 +547,7 @@ dependencies = [ [[package]] name = "argui-updater" -version = "0.2.1" +version = "0.3.0" dependencies = [ "flate2", "minisign-verify", @@ -541,7 +563,7 @@ dependencies = [ [[package]] name = "argui-vector" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "argui-paint", @@ -551,7 +573,7 @@ dependencies = [ [[package]] name = "argui-web-demo" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui", "argui-devtools", @@ -561,7 +583,7 @@ dependencies = [ [[package]] name = "argui-webview" -version = "0.2.1" +version = "0.3.0" dependencies = [ "ammonia", "argui-core", @@ -579,7 +601,7 @@ dependencies = [ [[package]] name = "argui-widget-gallery" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui", "argui-android", @@ -594,7 +616,7 @@ dependencies = [ [[package]] name = "argui-widgets" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-animation", "argui-core", @@ -1684,6 +1706,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "epaint_default_fonts" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" + [[package]] name = "equivalent" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 26e1a2d4..6b292893 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "3" [workspace.package] -version = "0.2.1" +version = "0.3.0" edition = "2024" rust-version = "1.89" license = "MIT OR Apache-2.0" @@ -12,30 +12,31 @@ homepage = "https://extrabinoss.github.io/argui/" readme = "README.md" [workspace.dependencies] -argui = { path = "crates/argui", version = "0.2.1" } -argui-android = { path = "crates/argui-android", version = "0.2.1" } -argui-accessibility = { path = "crates/argui-accessibility", version = "0.2.1" } -argui-animation = { path = "crates/argui-animation", version = "0.2.1" } -argui-core = { path = "crates/argui-core", version = "0.2.1" } -argui-devtools = { path = "crates/argui-devtools", version = "0.2.1" } -argui-effects = { path = "crates/argui-effects", version = "0.2.1" } -argui-layout = { path = "crates/argui-layout", version = "0.2.1" } -argui-image = { path = "crates/argui-image", version = "0.2.1" } -argui-i18n = { path = "crates/argui-i18n", version = "0.2.1" } -argui-ios = { path = "crates/argui-ios", version = "0.2.1" } -argui-inspect = { path = "crates/argui-inspect", version = "0.2.1" } -argui-paint = { path = "crates/argui-paint", version = "0.2.1" } -argui-platform = { path = "crates/argui-platform", version = "0.2.1" } -argui-render = { path = "crates/argui-render", version = "0.2.1" } +argui = { path = "crates/argui", version = "0.3.0" } +argui-android = { path = "crates/argui-android", version = "0.3.0" } +argui-accessibility = { path = "crates/argui-accessibility", version = "0.3.0" } +argui-animation = { path = "crates/argui-animation", version = "0.3.0" } +argui-core = { path = "crates/argui-core", version = "0.3.0" } +argui-devtools = { path = "crates/argui-devtools", version = "0.3.0" } +argui-effects = { path = "crates/argui-effects", version = "0.3.0" } +argui-layout = { path = "crates/argui-layout", version = "0.3.0" } +argui-image = { path = "crates/argui-image", version = "0.3.0" } +argui-i18n = { path = "crates/argui-i18n", version = "0.3.0" } +argui-ios = { path = "crates/argui-ios", version = "0.3.0" } +argui-inspect = { path = "crates/argui-inspect", version = "0.3.0" } +argui-paint = { path = "crates/argui-paint", version = "0.3.0" } +argui-platform = { path = "crates/argui-platform", version = "0.3.0" } +argui-render = { path = "crates/argui-render", version = "0.3.0" } argui-showcase = { path = "crates/argui-showcase" } -argui-runtime = { path = "crates/argui-runtime", version = "0.2.1" } -argui-text = { path = "crates/argui-text", version = "0.2.1" } -argui-theme = { path = "crates/argui-theme", version = "0.2.1" } -argui-ui = { path = "crates/argui-ui", version = "0.2.1" } -argui-updater = { path = "crates/argui-updater", version = "0.2.1" } -argui-vector = { path = "crates/argui-vector", version = "0.2.1" } -argui-widgets = { path = "crates/argui-widgets", version = "0.2.1" } -argui-webview = { path = "crates/argui-webview", version = "0.2.1" } +argui-runtime = { path = "crates/argui-runtime", version = "0.3.0" } +argui-text = { path = "crates/argui-text", version = "0.3.0" } +argui-testing = { path = "crates/argui-testing", version = "0.3.0" } +argui-theme = { path = "crates/argui-theme", version = "0.3.0" } +argui-ui = { path = "crates/argui-ui", version = "0.3.0" } +argui-updater = { path = "crates/argui-updater", version = "0.3.0" } +argui-vector = { path = "crates/argui-vector", version = "0.3.0" } +argui-widgets = { path = "crates/argui-widgets", version = "0.3.0" } +argui-webview = { path = "crates/argui-webview", version = "0.3.0" } bytemuck = { version = "=1.25.2", features = ["derive"] } arboard = { version = "=3.6.1", default-features = false, features = ["wayland-data-control"] } @@ -76,6 +77,7 @@ usvg = { version = "=0.48.1", default-features = false } resvg = { version = "=0.48.1", default-features = false } icondata_core = "=0.1.0" icondata_tb = "=0.1.0" +epaint_default_fonts = "=0.31.1" accesskit = "=0.24.1" accesskit_consumer = "=0.38.0" accesskit_winit = "=0.33.2" diff --git a/README.md b/README.md index 14102d99..156cf80b 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ for workloads, memory figures and reproduction commands. - [x] State-preserving Subsecond patches through the optional `hot-reload` feature - [x] Optional DevTools, file picker, updater, WebView, tray, native popovers and desktop backdrop - [x] Android/iOS bootstrap crates, safe areas and packaged Widget Gallery CI artifacts +- [x] Local typed widget callbacks and renderer-independent application tests The public integration flags are `i18n`, `hot-reload`, `tasks`, `devtools`, `devtools-all-smi`, `file-picker`, `updater`, `widget-updater`, @@ -105,6 +106,9 @@ feature bundle. | Async model tasks | `tasks` | All targets | | Every widget | `widgets-all` | All targets | | A small widget set | the matching `widget-*` flags | All targets | +| Common forms and overlays | `basic` | All targets | +| Common UI plus native integrations | `desktop` | Desktop | +| Common UI plus browser/WebView support | `web` | Desktop and browser | | Inspector and profiler | `devtools` | Desktop and WebAssembly | | NVIDIA/AMD/Intel sensor collection | `devtools-all-smi` | Supported desktop hosts | | State-preserving Rust patches | `hot-reload` | Native desktop debug builds | @@ -120,7 +124,7 @@ not need an OS feature. A focused application can enable capabilities directly: ```toml [dependencies] -argui = { version = "0.2.1", default-features = false, features = [ +argui = { version = "0.3.0", default-features = false, features = [ "i18n", "tasks", "widget-button", "widget-input", ] } ``` @@ -129,12 +133,12 @@ For mobile, add the platform entry crate as a separate dependency: ```toml # Android -argui = { version = "0.2.1", features = ["i18n", "widgets-all"] } -argui-android = "0.2.1" +argui = { version = "0.3.0", features = ["i18n", "widgets-all"] } +argui-android = "0.3.0" # iOS -argui = { version = "0.2.1", features = ["i18n", "widgets-all"] } -argui-ios = "0.2.1" +argui = { version = "0.3.0", features = ["i18n", "widgets-all"] } +argui-ios = "0.3.0" ``` Available widget flags are `widget-accordion`, `widget-alert`, @@ -210,25 +214,27 @@ Add only the widgets your application uses: ```toml [dependencies] -argui = { version = "0.2.1", features = ["widget-button"] } +argui = { version = "0.3.0", features = ["widget-button"] } ``` ```rust -use argui::{ - ui::Element, - widgets::{Button, WidgetTheme}, -}; - -fn view(theme: &WidgetTheme) -> Element { - Element::row([ - Button::new("save", "Save changes", theme.button()).build(), - Button::new("cancel", "Cancel", theme.ghost_button()).build(), - ]) - .gap(10.0) +use argui::{runtime::{Context, Render}, ui::Element, widgets::{Button, WidgetTheme}}; + +struct Editor { + saved: bool, +} + +impl Editor { + fn view(&mut self, theme: &WidgetTheme, cx: &mut Context) -> Element { + Button::new("save", "Save changes", theme.button()) + .on_click(cx.callback(|editor| editor.saved = true)) + .build() + } } ``` Continue with the product-shaped [example applications](app_examples/), +[simplified 0.3 API](docs/simplified-api.md), [models and state](docs/runtime/models.md), [localization](docs/i18n.md), [hot reload](docs/hot-reload.md), the [widget catalogue](docs/widgets/shadcn.md) or the [complete examples](crates/argui/examples/). diff --git a/app_examples/Cargo.lock b/app_examples/Cargo.lock index ee55fb00..b93ce9ce 100644 --- a/app_examples/Cargo.lock +++ b/app_examples/Cargo.lock @@ -62,7 +62,7 @@ dependencies = [ "hashbrown 0.16.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-ui-kit", + "objc2-ui-kit 0.2.2", ] [[package]] @@ -145,6 +145,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -205,7 +214,7 @@ dependencies = [ [[package]] name = "argui" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-accessibility", "argui-animation", @@ -225,7 +234,7 @@ dependencies = [ [[package]] name = "argui-accessibility" -version = "0.2.1" +version = "0.3.0" dependencies = [ "accesskit", "argui-core", @@ -235,14 +244,23 @@ dependencies = [ [[package]] name = "argui-animation" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", ] [[package]] name = "argui-core" -version = "0.2.1" +version = "0.3.0" + +[[package]] +name = "argui-effects" +version = "0.3.0" +dependencies = [ + "argui-paint", + "argui-render", + "argui-ui", +] [[package]] name = "argui-example-ai-harness" @@ -253,11 +271,26 @@ dependencies = [ "web-time", ] +[[package]] +name = "argui-example-astra-editor" +version = "0.1.0" +dependencies = [ + "argui", + "argui-effects", + "argui-testing", + "tempfile", + "tree-sitter-highlight", + "tree-sitter-rust", + "wasm-bindgen", + "web-time", +] + [[package]] name = "argui-example-docs" version = "0.1.0" dependencies = [ "argui", + "argui-testing", "wasm-bindgen", ] @@ -271,7 +304,7 @@ dependencies = [ [[package]] name = "argui-i18n" -version = "0.2.1" +version = "0.3.0" dependencies = [ "fluent-bundle", "fluent-langneg", @@ -281,7 +314,7 @@ dependencies = [ [[package]] name = "argui-inspect" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "serde", @@ -290,7 +323,7 @@ dependencies = [ [[package]] name = "argui-layout" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "argui-paint", @@ -301,23 +334,31 @@ dependencies = [ [[package]] name = "argui-paint" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", ] [[package]] name = "argui-platform" -version = "0.2.1" +version = "0.3.0" dependencies = [ + "android-activity", "arboard", "argui-core", "ashpd", "base64", + "block2 0.6.2", + "dispatch2", + "futures-channel", "futures-util", "image", + "jni", + "objc2 0.6.4", "objc2-app-kit 0.3.2", - "pollster", + "objc2-ui-kit 0.3.2", + "pollster 1.0.1", + "rfd", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -327,7 +368,7 @@ dependencies = [ [[package]] name = "argui-render" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "argui-paint", @@ -341,7 +382,7 @@ dependencies = [ [[package]] name = "argui-runtime" -version = "0.2.1" +version = "0.3.0" dependencies = [ "accesskit", "accesskit_winit", @@ -359,16 +400,32 @@ dependencies = [ "futures-channel", "futures-util", "gloo-timers", - "pollster", + "pollster 1.0.1", "tokio", "wasm-bindgen-futures", "web-time", "winit", ] +[[package]] +name = "argui-testing" +version = "0.3.0" +dependencies = [ + "argui-accessibility", + "argui-animation", + "argui-core", + "argui-layout", + "argui-platform", + "argui-runtime", + "argui-text", + "argui-ui", + "epaint_default_fonts", + "thiserror 2.0.20", +] + [[package]] name = "argui-text" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "cosmic-text", @@ -377,14 +434,14 @@ dependencies = [ [[package]] name = "argui-theme" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", ] [[package]] name = "argui-ui" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-accessibility", "argui-animation", @@ -398,7 +455,7 @@ dependencies = [ [[package]] name = "argui-vector" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "argui-paint", @@ -408,7 +465,7 @@ dependencies = [ [[package]] name = "argui-widgets" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-animation", "argui-core", @@ -954,6 +1011,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1025,6 +1084,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "epaint_default_fonts" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f" + [[package]] name = "equivalent" version = "1.0.2" @@ -1940,6 +2005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -2164,6 +2230,18 @@ dependencies = [ "objc2-user-notifications", ] +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-uniform-type-identifiers" version = "0.2.2" @@ -2413,6 +2491,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "pollster" version = "1.0.1" @@ -2594,6 +2678,35 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -2615,6 +2728,33 @@ dependencies = [ "usvg", ] +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster 0.4.0", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rgb" version = "0.8.53" @@ -2774,6 +2914,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2951,6 +3092,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -3231,6 +3378,48 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree-sitter" +version = "0.26.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ebdd3a5a7e28a1890b876fdbd0c3c0fe0a6336cffaa104f11b9f720c9daa29" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-highlight" +version = "0.26.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae179971f151b7c5aec4c77c1c9a9190d45912e50b58de2114091ee3998afba2" +dependencies = [ + "regex", + "streaming-iterator", + "thiserror 2.0.20", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree_magic_mini" version = "3.2.2" @@ -4078,7 +4267,7 @@ dependencies = [ "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", - "objc2-ui-kit", + "objc2-ui-kit 0.2.2", "orbclient", "percent-encoding", "pin-project", diff --git a/app_examples/Cargo.toml b/app_examples/Cargo.toml index ab5278a6..ff4bf31c 100644 --- a/app_examples/Cargo.toml +++ b/app_examples/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["docs-examples", "fake-ai-harness", "gpu-canvas"] +members = ["astra-editor", "docs-examples", "fake-ai-harness", "gpu-canvas"] resolver = "3" [workspace.package] @@ -10,9 +10,13 @@ license = "MIT OR Apache-2.0" repository = "https://github.com/ExtraBinoss/argui" [workspace.dependencies] -argui = { path = "../crates/argui", version = "0.2.1" } +argui = { path = "../crates/argui", version = "0.3.0" } +argui-testing = { path = "../crates/argui-testing", version = "0.3.0" } +argui-effects = { path = "../crates/argui-effects", version = "0.3.0", features = ["scroll"] } web-time = "=1.1.0" wasm-bindgen = "=0.2.127" +tree-sitter-highlight = "=0.26.7" +tree-sitter-rust = "=0.24.2" [workspace.lints.rust] unsafe_code = "deny" diff --git a/app_examples/README.md b/app_examples/README.md index 5ac04ce1..5aa77d7d 100644 --- a/app_examples/README.md +++ b/app_examples/README.md @@ -6,6 +6,7 @@ packages and dependency graphs do not include them. | Application | What it demonstrates | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| [Astra Editor](astra-editor/) | A responsive, native/Web Rust editor with project navigation, multi-file tabs, virtualized search, resizable panels, and polished motion. | | [Fake AI Harness](fake-ai-harness/) | A bundled adaptation of Wikipedia's Large language model article streams 6,000 simulated tokens at 1,000 tokens/s through a responsive VList. | | [Documentation examples](docs-examples/) | Seventeen exact-source applications compiled into the interactive learning site. Each guide displays the Rust module it actually runs. | | [GPU Canvas Lab](gpu-canvas/) | A retained WGPU compute/render viewport with Argui controls, pan/zoom, pause, overlays, effects, diagnostics, native and WebAssembly builds. | @@ -18,3 +19,9 @@ cargo run --manifest-path app_examples/Cargo.toml -p argui-example-ai-harness GPU Canvas Lab has its own native and browser commands in its [README](gpu-canvas/README.md). + +Run the editor from the repository root: + +```sh +cargo run --manifest-path app_examples/Cargo.toml -p argui-example-astra-editor +``` diff --git a/app_examples/astra-editor/Cargo.toml b/app_examples/astra-editor/Cargo.toml new file mode 100644 index 00000000..874f2b36 --- /dev/null +++ b/app_examples/astra-editor/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "argui-example-astra-editor" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false +description = "A fast, responsive Rust editor demonstrating application-scale Argui patterns" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +argui = { workspace = true, features = [ + "file-picker", + "tasks", + "widget-button", + "widget-icons", + "widget-input", + "widget-kbd", + "widget-split-pane", + "widget-text-selection", + "widget-textarea", + "widget-tooltip", + "widget-tree-view", + "widget-vlist", +] } +argui-effects.workspace = true +web-time.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tree-sitter-highlight.workspace = true +tree-sitter-rust.workspace = true + +[dev-dependencies] +argui-testing.workspace = true +tempfile = "=3.27.0" + +[lints] +workspace = true diff --git a/app_examples/astra-editor/README.md b/app_examples/astra-editor/README.md new file mode 100644 index 00000000..9f6d7902 --- /dev/null +++ b/app_examples/astra-editor/README.md @@ -0,0 +1,95 @@ +# Astra Editor + +Astra Editor is an application-scale Argui example: a responsive Rust-oriented +text editor that starts on an embedded workspace and can open a real folder on +desktop. It demonstrates controlled text editing, multiple files, dirty state, +native saves, Rust syntax highlighting, virtualized project navigation and +search results, resizable panels, display-linked transitions, light and dark +themes, keyboard shortcuts, safe-area layout, and an adaptive phone-sized +experience. Its compact activity rail keeps project actions discoverable +without consuming editor height. + +Run the native app from the repository root: + +```sh +cargo run --manifest-path app_examples/Cargo.toml -p argui-example-astra-editor +``` + +Build the WebAssembly version: + +```sh +wasm-pack build app_examples/astra-editor --target web --dev \ + --out-dir ../../web/examples/astra-editor/pkg +``` + +To verify the built editor in a real WebGPU browser, serve `web/` and run the +private-display scenario in another terminal: + +```sh +python3 scripts/dev_server.py 8795 --directory web \ + --entry /examples/astra-editor/ + +CHROME_PATH=/path/to/chrome ./scripts/linux-hidden-display.sh \ + python3 app_examples/astra-editor/tests/browser.py +``` + +The browser scenario checks the desktop and compact layouts, `Ctrl+B`, the +global-search dialog, canvas sizing, non-blank output, and console errors. + +Desktop builds open and index a selected directory away from the UI thread. +The browser build imports selected UTF-8 files into its in-memory workspace; +browser saves update that session's checkpoint because browsers do not expose a +portable folder-write API. + +The editor keeps the keystroke path deliberately small: the engine delivers one +UTF-8 range replacement instead of cloning the complete document, retained input +state is painted without rebuilding the whole application, and syntax/search +derivation is coalesced before a source snapshot is taken. Non-wrapping code +shapes only the visible lines plus overscan while retaining the full document +scroll extent. Folder scans also defer syntax work until a document is opened. + +## Reproducible performance checks + +The edit-delivery profile isolates the cost removed by `TextArea::on_edit`. It +applies the same fixed-width edits to an engine buffer and a 1 MiB controlled +document, then compares incremental `TextEdit` delivery with cloning the complete +value for every callback: + +```sh +nice -n 15 ionice -c 3 taskset -c 0 \ + cargo run --manifest-path app_examples/Cargo.toml \ + -p argui-example-astra-editor --release \ + --example edit_delivery_profile -- 1048576 2000 +``` + +The native profile measures the complete application instead: first present, +idle CPU and memory, directory animation frames, `Ctrl+B`, a visible character, +a queued typing burst, and scrolling after growing the document. Build once, +then run the saved binary on the repository's private display. Pass `--check` to +enforce the conservative interaction budgets recorded in `profile.json`. + +```sh +cargo build --manifest-path app_examples/Cargo.toml \ + -p argui-example-astra-editor --release + +ARGUI_TEST_BACKEND=x11 ./scripts/linux-hidden-display.sh \ + nice -n 10 ionice -c 3 \ + python3 scripts/profile-astra-editor.py \ + --binary /path/to/cargo-target/release/argui-example-astra-editor \ + --output target/astra-editor-profile \ + --sampler-cpu 0 --app-cpu 1 --check +``` + +Keep the viewport, backend, CPU affinity, event counts, and build profile +identical when comparing revisions. The script saves every measurement as JSON +and validates captures instead of treating a mapped or blank window as success. +Omit the two CPU options on a single-core test environment. + +Useful shortcuts: + +- `Ctrl/Cmd+Shift+F` searches every indexed line. +- `Ctrl/Cmd+P` opens a file by path. +- `Ctrl/Cmd+S` saves the active document. +- `Ctrl/Cmd+B` toggles the explorer. +- `Ctrl/Cmd+J` opens the system terminal in the project directory on desktop. +- `Escape` dismisses search. diff --git a/app_examples/astra-editor/examples/edit_delivery_profile.rs b/app_examples/astra-editor/examples/edit_delivery_profile.rs new file mode 100644 index 00000000..55f4a4d4 --- /dev/null +++ b/app_examples/astra-editor/examples/edit_delivery_profile.rs @@ -0,0 +1,110 @@ +//! Compares incremental `TextEdit` delivery with complete-value delivery. + +use std::{hint::black_box, time::Instant}; + +use argui::ui::TextEdit; + +const DEFAULT_DOCUMENT_BYTES: usize = 1_048_576; +const DEFAULT_EDITS: usize = 2_000; +const RUNS: usize = 5; + +/// Reads one positive integer argument or returns `default` when it is absent. +fn positive_argument(index: usize, default: usize, name: &str) -> usize { + let Some(value) = std::env::args().nth(index) else { + return default; + }; + let value = value + .parse::() + .unwrap_or_else(|error| panic!("invalid {name} '{value}': {error}")); + assert!(value > 0, "{name} must be positive"); + value +} + +/// Returns one deterministic ASCII replacement at `iteration`. +fn replacement(iteration: usize) -> &'static str { + if iteration.is_multiple_of(2) { + "b" + } else { + "a" + } +} + +/// Profiles the delta path used by `on_edit` and returns elapsed nanoseconds. +fn incremental_sample(base: &str, edits: usize) -> u128 { + let mut engine_value = base.to_owned(); + let mut controlled_value = base.to_owned(); + let started = Instant::now(); + for iteration in 0..edits { + let offset = iteration.wrapping_mul(7_919) % base.len(); + let edit = TextEdit::new(offset..offset + 1, replacement(iteration)); + edit.apply_to(&mut engine_value) + .expect("the benchmark range is an ASCII boundary"); + edit.apply_to(&mut controlled_value) + .expect("the controlled value follows the engine revision"); + black_box(&controlled_value); + } + let elapsed = started.elapsed().as_nanos(); + assert_eq!(engine_value, controlled_value); + elapsed +} + +/// Profiles complete-value callback delivery and returns elapsed nanoseconds. +fn complete_value_sample(base: &str, edits: usize) -> u128 { + let mut engine_value = base.to_owned(); + let mut controlled_value = base.to_owned(); + black_box(&controlled_value); + let started = Instant::now(); + for iteration in 0..edits { + let offset = iteration.wrapping_mul(7_919) % base.len(); + engine_value.replace_range(offset..offset + 1, replacement(iteration)); + controlled_value = engine_value.clone(); + black_box(&controlled_value); + } + let elapsed = started.elapsed().as_nanos(); + assert_eq!(engine_value, controlled_value); + elapsed +} + +/// Returns the median value from the non-empty sample set `values`. +fn median(values: &mut [u128]) -> u128 { + assert!(!values.is_empty(), "a profile needs at least one sample"); + values.sort_unstable(); + values[values.len() / 2] +} + +/// Runs both delivery strategies in alternating order and prints one JSON record. +fn main() { + let document_bytes = positive_argument(1, DEFAULT_DOCUMENT_BYTES, "document bytes"); + let edits = positive_argument(2, DEFAULT_EDITS, "edit count"); + let base = "a".repeat(document_bytes); + let mut incremental = Vec::with_capacity(RUNS); + let mut complete = Vec::with_capacity(RUNS); + + for run in 0..RUNS { + if run.is_multiple_of(2) { + incremental.push(incremental_sample(&base, edits)); + complete.push(complete_value_sample(&base, edits)); + } else { + complete.push(complete_value_sample(&base, edits)); + incremental.push(incremental_sample(&base, edits)); + } + } + + let incremental_ns = median(&mut incremental); + let complete_ns = median(&mut complete); + let speedup = complete_ns as f64 / incremental_ns.max(1) as f64; + println!( + concat!( + "{{\"document_bytes\":{},\"edits\":{},\"runs\":{},", + "\"incremental_median_ms\":{:.3},\"complete_value_median_ms\":{:.3},", + "\"delivery_speedup\":{:.2},\"complete_value_payload_mib\":{:.2}}}" + ), + document_bytes, + edits, + RUNS, + incremental_ns as f64 / 1_000_000.0, + complete_ns as f64 / 1_000_000.0, + speedup, + document_bytes.saturating_mul(edits) as f64 / 1_048_576.0, + ); +} diff --git a/app_examples/astra-editor/src/app.rs b/app_examples/astra-editor/src/app.rs new file mode 100644 index 00000000..ffa79638 --- /dev/null +++ b/app_examples/astra-editor/src/app.rs @@ -0,0 +1,599 @@ +use std::{cell::RefCell, collections::BTreeSet, time::Duration}; + +use argui::{ + animation::Frame, + core::{Color, ColorScheme, Key, KeyState, Size}, + paint::VectorAsset, + runtime::{Context, LayoutSnapshot, Render, ThemeRequest, tasks::TaskSlot}, + theme::ThemeMode, + ui::{Element, EventType, TextEdit, UiEvent, UiEventKind}, + widgets::{TablerIcon, TreeNode, TreeViewCache, WidgetAssets, shadcn}, +}; +use web_time::Instant; + +use crate::{ + syntax, + workspace::{EntryKind, Project, TextMatch}, +}; + +mod editor; +mod io; +mod search; +mod sidebar; +mod view; + +const TREE_ANIMATION_SECONDS: f32 = 0.12; +const HIGHLIGHT_DEBOUNCE: Duration = Duration::from_millis(48); +const SEARCH_LIMIT: usize = 300; + +/// Kind of instant workspace navigation currently shown by the search overlay. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SearchMode { + /// Search every indexed source line. + Text, + /// Search project paths for quick opening. + Files, +} + +impl SearchMode { + /// Returns the accessible title for this search mode. + const fn title(self) -> &'static str { + match self { + Self::Text => "Search across files", + Self::Files => "Open a file", + } + } + + /// Returns the mode-specific input placeholder. + const fn placeholder(self) -> &'static str { + match self { + Self::Text => "Search every file in the workspace…", + Self::Files => "Type a file name or path…", + } + } +} + +/// Retained global-search model and its virtual viewport state. +struct SearchState { + mode: SearchMode, + open: bool, + query: String, + text_matches: Vec, + file_matches: Vec, + offset: f32, + elapsed_micros: u128, + progress: f32, +} + +impl Default for SearchState { + /// Creates a closed, empty global-search overlay. + fn default() -> Self { + Self { + mode: SearchMode::Text, + open: false, + query: String::new(), + text_matches: Vec::new(), + file_matches: Vec::new(), + offset: 0.0, + elapsed_micros: 0, + progress: 0.0, + } + } +} + +/// Product-shaped Argui text editor example. +pub struct AstraEditor { + project: Project, + open_documents: Vec, + active_document: usize, + selected_tree_key: String, + collapsed: BTreeSet, + tree_nodes: Vec, + tree_cache: RefCell, + explorer_offset: f32, + explorer_width: f32, + explorer_open: bool, + compact: bool, + explorer_revealed: bool, + viewport: Size, + theme_mode: ThemeMode, + assets: WidgetAssets, + search: SearchState, + tree_reveal_root: Option, + tree_reveal_started_at: Instant, + tree_reveal_complete: bool, + reduced_motion: bool, + focus_initialized: bool, + loading_project: bool, + picker_task: TaskSlot, + highlight_task: TaskSlot, + #[cfg(not(target_arch = "wasm32"))] + scan_task: Option, + #[cfg(not(target_arch = "wasm32"))] + save_task: Option, + notice: Option, +} + +impl Default for AstraEditor { + /// Creates the editor around an embedded project without touching disk. + fn default() -> Self { + let mut editor = Self::with_project(Project::demo()); + editor.open_documents = vec![0, 3]; + editor.notice = Some("Bundled workspace · open a folder to edit your project".into()); + editor + } +} + +impl AstraEditor { + /// Creates an editor around a preloaded nonempty project. + /// + /// `project` supplies the documents, navigator entries, and optional native root. + /// The first document is active and at most two initial tabs are opened. + /// + /// # Panics + /// + /// Panics when `project` contains no editable documents. + #[must_use] + pub fn with_project(project: Project) -> Self { + assert!( + !project.documents.is_empty(), + "Astra Editor requires at least one document" + ); + let active_document = 0; + let selected_tree_key = project.documents[active_document].path.clone(); + let assets = WidgetAssets::tabler_subset( + Color::WHITE, + [ + TablerIcon::Search, + TablerIcon::Sun, + TablerIcon::Moon, + TablerIcon::Sidebar, + TablerIcon::Close, + TablerIcon::ChevronRight, + TablerIcon::Folder, + TablerIcon::File, + TablerIcon::Rust, + TablerIcon::Terminal, + TablerIcon::ArrowDown, + TablerIcon::Check, + TablerIcon::Loader, + ], + ); + let tree_nodes = project_tree_nodes(&project, &assets); + let open_documents = (0..project.documents.len().min(2)).collect(); + Self { + project, + open_documents, + active_document, + selected_tree_key, + collapsed: BTreeSet::new(), + tree_nodes, + tree_cache: RefCell::new(TreeViewCache::default()), + explorer_offset: 0.0, + explorer_width: 248.0, + explorer_open: true, + compact: false, + explorer_revealed: false, + viewport: Size::new(1280.0, 800.0), + theme_mode: ThemeMode::Light, + assets, + search: SearchState::default(), + tree_reveal_root: None, + tree_reveal_started_at: Instant::now(), + tree_reveal_complete: true, + reduced_motion: false, + focus_initialized: false, + loading_project: false, + picker_task: TaskSlot::default(), + highlight_task: TaskSlot::default(), + #[cfg(not(target_arch = "wasm32"))] + scan_task: None, + #[cfg(not(target_arch = "wasm32"))] + save_task: None, + notice: Some("Preloaded workspace".into()), + } + } +} + +impl AstraEditor { + /// Returns the active immutable document. + fn active_document(&self) -> &crate::workspace::Document { + &self.project.documents[self.active_document] + } + + /// Opens `document`, reusing an existing tab when possible. + fn open_document(&mut self, document: usize, cx: &mut Context) { + if document >= self.project.documents.len() { + return; + } + if !self.open_documents.contains(&document) { + self.open_documents.push(document); + } + self.active_document = document; + self.selected_tree_key = self.project.documents[document].path.clone(); + if self.compact { + self.explorer_revealed = false; + } + self.schedule_derived_refresh(document, Duration::ZERO, cx); + cx.request_focus("code-editor"); + cx.notify(); + } + + /// Closes one tab while retaining unsaved in-memory document contents. + fn close_document(&mut self, document: usize, cx: &mut Context) { + let Some(position) = self + .open_documents + .iter() + .position(|candidate| *candidate == document) + else { + return; + }; + if self.open_documents.len() == 1 { + return; + } + self.open_documents.remove(position); + if self.active_document == document { + let next = position.min(self.open_documents.len() - 1); + self.active_document = self.open_documents[next]; + self.selected_tree_key = self.project.documents[self.active_document].path.clone(); + self.schedule_derived_refresh(self.active_document, Duration::ZERO, cx); + } + cx.notify(); + } + + /// Applies a text edit immediately and defers derived syntax/search work. + fn edit_active(&mut self, edit: TextEdit, cx: &mut Context) { + let was_dirty = self.project.documents[self.active_document].is_dirty(); + if self.project.documents[self.active_document] + .apply_edit(&edit) + .is_err() + { + self.notice = Some("Ignored a stale text edit; the editor was resynchronized".into()); + cx.notify(); + return; + } + self.schedule_derived_refresh(self.active_document, HIGHLIGHT_DEBOUNCE, cx); + if was_dirty != self.project.documents[self.active_document].is_dirty() { + cx.notify(); + } + } + + /// Coalesces derived document work so it never runs in the synchronous typing path. + fn schedule_derived_refresh( + &mut self, + document: usize, + delay: Duration, + cx: &mut Context, + ) { + if !self.project.documents[document].needs_derived_refresh() { + return; + } + if !delay.is_zero() { + let started = cx.spawn_latest( + &mut self.highlight_task, + async move { + argui::runtime::tasks::sleep(delay).await; + document + }, + |editor, result, cx| { + let Ok(document) = result else { + return; + }; + editor.schedule_derived_refresh(document, Duration::ZERO, cx); + }, + ); + if started.is_err() { + cx.notify(); + } + return; + } + let (revision, path, source) = self.project.documents[document].derived_snapshot(); + let started = cx.spawn_latest( + &mut self.highlight_task, + async move { + let search_content = source.to_lowercase(); + let highlighted = syntax::highlight(&path, &source); + (document, revision, search_content, highlighted) + }, + |editor, result, cx| { + let Ok((document, revision, search_content, highlighted)) = result else { + return; + }; + let Some(document) = editor.project.documents.get_mut(document) else { + return; + }; + if document.apply_derived(revision, search_content, highlighted) { + if editor.search.open { + editor.refresh_search(); + } + cx.notify(); + } + }, + ); + if started.is_err() { + cx.notify(); + } + } + + /// Rebuilds the active in-memory search result set and records its elapsed time. + fn refresh_search(&mut self) { + let started = Instant::now(); + match self.search.mode { + SearchMode::Text => { + self.search.text_matches = self.project.search(&self.search.query, SEARCH_LIMIT); + self.search.file_matches.clear(); + } + SearchMode::Files => { + self.search.file_matches = self + .project + .matching_files(&self.search.query, SEARCH_LIMIT); + self.search.text_matches.clear(); + } + } + self.search.elapsed_micros = started.elapsed().as_micros(); + self.search.offset = 0.0; + } + + /// Opens the requested search surface and focuses its query field. + fn show_search(&mut self, mode: SearchMode, cx: &mut Context) { + self.search.mode = mode; + self.search.open = true; + self.search.query.clear(); + if self.reduced_motion { + self.search.progress = 1.0; + } + self.refresh_search(); + cx.request_focus("workspace-search"); + cx.notify(); + } + + /// Closes the search surface and restores focus to the editor. + fn hide_search(&mut self, cx: &mut Context) { + self.search.open = false; + if self.reduced_motion { + self.search.progress = 0.0; + } + cx.request_focus("code-editor"); + cx.notify(); + } + + /// Replaces the active project and resets project-scoped presentation state. + fn install_project(&mut self, project: Project, cx: &mut Context) { + self.highlight_task.cancel(); + #[cfg(not(target_arch = "wasm32"))] + if let Some(task) = self.save_task.take() { + task.cancel(); + } + self.project = project; + self.tree_nodes = project_tree_nodes(&self.project, &self.assets); + self.tree_cache = RefCell::new(TreeViewCache::default()); + self.open_documents = (0..self.project.documents.len().min(2)).collect(); + self.active_document = 0; + self.selected_tree_key = self.project.documents[0].path.clone(); + self.collapsed.clear(); + self.explorer_offset = 0.0; + self.loading_project = false; + self.explorer_revealed = false; + self.tree_reveal_root = None; + self.tree_reveal_started_at = Instant::now(); + self.tree_reveal_complete = true; + self.notice = Some(format!( + "Indexed {} files · ready for instant search", + self.project.documents.len() + )); + self.schedule_derived_refresh(self.active_document, Duration::ZERO, cx); + cx.request_focus("code-editor"); + cx.notify(); + } + + /// Toggles between the deliberately supported light and dark palettes. + fn toggle_theme(&mut self, cx: &mut Context) { + self.theme_mode = match self.theme_mode { + ThemeMode::Light => ThemeMode::Dark, + ThemeMode::Dark | ThemeMode::System => ThemeMode::Light, + }; + cx.set_theme(self.theme_request()); + cx.notify(); + } + + /// Toggles the explorer in the active responsive presentation. + fn toggle_explorer(&mut self, cx: &mut Context) { + if self.compact { + self.explorer_revealed = !self.explorer_revealed; + } else { + self.explorer_open = !self.explorer_open; + cx.request_focus("code-editor"); + } + cx.notify(); + } + + /// Returns the runtime theme request for the selected editor palette. + fn theme_request(&self) -> ThemeRequest { + ThemeRequest { + color_scheme: Some(match self.theme_mode { + ThemeMode::Dark => ColorScheme::Dark, + ThemeMode::Light | ThemeMode::System => ColorScheme::Light, + }), + primary: Some(Color::from_srgb8(43, 110, 242)), + } + } + + /// Interprets application-wide keyboard shortcuts before focused controls. + fn handle_shortcut(&mut self, event: &UiEvent, cx: &mut Context) -> bool { + let UiEventKind::KeyInput(input) = &event.kind else { + return false; + }; + if input.state != KeyState::Pressed { + return false; + } + if input.key == Key::Escape && self.search.open { + self.hide_search(cx); + return true; + } + if !input.modifiers.command() { + return false; + } + match &input.key { + Key::Character(value) if value.eq_ignore_ascii_case("s") => self.save_active(cx), + Key::Character(value) if input.modifiers.shift && value.eq_ignore_ascii_case("f") => { + self.show_search(SearchMode::Text, cx); + } + Key::Character(value) if !input.modifiers.shift && value.eq_ignore_ascii_case("p") => { + self.show_search(SearchMode::Files, cx); + } + Key::Character(value) if value.eq_ignore_ascii_case("b") => { + self.toggle_explorer(cx); + } + Key::Character(value) if value.eq_ignore_ascii_case("j") => { + self.launch_terminal(cx); + } + _ => return false, + } + true + } + + /// Tracks the virtual scroll offsets owned by explorer and search result lists. + fn handle_scroll(&mut self, event: &UiEvent, cx: &mut Context) { + let UiEventKind::Scrolled { offset, .. } = event.kind else { + return; + }; + let rerender = match event.target_key() { + Some("project-tree") => { + let previous = self.explorer_offset; + self.explorer_offset = offset.y.max(0.0); + (previous / 30.0).floor() != (self.explorer_offset / 30.0).floor() + } + Some("workspace-search-results") => { + let previous = self.search.offset; + self.search.offset = offset.y.max(0.0); + let row_height = if self.search.mode == SearchMode::Text { + 60.0 + } else { + 46.0 + }; + (previous / row_height).floor() != (self.search.offset / row_height).floor() + } + _ => return, + }; + if rerender { + cx.notify(); + } + } + + /// Dispatches captured global key and virtual-scroll events. + fn handle_event(&mut self, event: &UiEvent, cx: &mut Context) { + if self.handle_shortcut(event, cx) { + let _ = event.prevent_default(); + event.stop_propagation(); + return; + } + self.handle_scroll(event, cx); + } + + /// Returns eased progress for the global-search overlay transition. + fn search_progress(&self) -> f32 { + let progress = self.search.progress; + progress * progress * (3.0 - 2.0 * progress) + } + + /// Returns eased progress for the current explorer reveal transition. + fn tree_open_progress(&self) -> f32 { + if self.reduced_motion || self.tree_reveal_root.is_none() { + return 1.0; + } + let progress = (self.tree_reveal_started_at.elapsed().as_secs_f32() + / TREE_ANIMATION_SECONDS) + .clamp(0.0, 1.0); + progress * progress * (3.0 - 2.0 * progress) + } +} + +impl Render for AstraEditor { + /// Builds the responsive editor tree and installs captured global listeners. + fn render(&mut self, cx: &mut Context) -> Element { + self.reduced_motion = cx.environment().reduced_motion; + if !self.focus_initialized { + self.focus_initialized = true; + cx.request_focus("code-editor"); + } + if self.reduced_motion { + self.search.progress = if self.search.open { 1.0 } else { 0.0 }; + } + if self.tree_reveal_root.is_some() && self.tree_open_progress() >= 1.0 { + self.tree_reveal_root = None; + self.tree_reveal_complete = true; + } + let themes = shadcn(cx.environment()); + let theme = themes.resolve(cx.environment().color_scheme); + let mut root = self + .view(theme, cx) + .safe_area(cx.environment().safe_area_insets); + for event_type in [EventType::Key, EventType::Scroll] { + root = root.on(cx.listener(event_type, Self::handle_event).capture(true)); + } + root + } + + /// Advances overlay presence and the brief descendant reveal while either is active. + fn animation_frame(&mut self, frame: Frame, cx: &mut Context) { + let search_target = if self.search.open { 1.0 } else { 0.0 }; + if self.search.progress != search_target { + let direction = if self.search.open { 1.0 } else { -1.0 }; + self.search.progress = (self.search.progress + + direction * frame.elapsed.as_secs_f64() as f32 / 0.14) + .clamp(0.0, 1.0); + cx.notify(); + } + let presenting_tree = self.tree_reveal_root.is_some() && !self.tree_reveal_complete; + if presenting_tree { + cx.notify(); + } else if self.search.progress == search_target { + cx.request_paint(); + } + } + + /// Returns whether the retained presentation currently needs display-linked frames. + fn wants_animation_frame(&self) -> bool { + self.search.progress != if self.search.open { 1.0 } else { 0.0 } + || (self.tree_reveal_root.is_some() && !self.tree_reveal_complete) + } + + /// Updates responsive breakpoints and the available split-pane dimensions. + fn layout_changed(&mut self, layout: &LayoutSnapshot, cx: &mut Context) { + let viewport = layout.viewport_size(); + let compact = viewport.width < 720.0; + if self.viewport != viewport || self.compact != compact { + self.viewport = viewport; + self.compact = compact; + if !compact { + self.explorer_revealed = false; + } + cx.notify(); + } + } + + /// Registers the embedded Tabler SVG subset used by the editor chrome. + fn vector_assets(&self) -> Vec { + self.assets.assets().to_vec() + } +} + +/// Builds stable tree rows once per installed project, including file-type SVG icons. +fn project_tree_nodes(project: &Project, assets: &WidgetAssets) -> Vec { + project + .entries + .iter() + .map(|entry| TreeNode { + key: entry.key.clone(), + label: entry.label.clone(), + depth: entry.depth, + icon: Some(assets.vector_id(match entry.kind { + EntryKind::Directory => TablerIcon::Folder, + EntryKind::File(document) if project.documents[document].extension() == "rs" => { + TablerIcon::Rust + } + EntryKind::File(_) => TablerIcon::File, + })), + }) + .collect() +} diff --git a/app_examples/astra-editor/src/app/editor.rs b/app_examples/astra-editor/src/app/editor.rs new file mode 100644 index 00000000..4d2fc93e --- /dev/null +++ b/app_examples/astra-editor/src/app/editor.rs @@ -0,0 +1,247 @@ +use argui::{ + paint::{Border, BorderWidths, CornerRadii, PaintStyle, QuadStyle}, + runtime::Context, + text::{FontFamily, TextStyle, TextWrap}, + ui::{ + AlignItems, Axes, Element, JustifyContent, Overflow, ScrollAxes, ScrollConfig, + ScrollPropagation, Sides, StylePatch, TextSelectionHighlight, length, percent, + }, + widgets::{Button, TablerIcon, TextArea, WidgetTheme}, +}; + +use super::AstraEditor; +use crate::ui::{accent, label, scroll_shadow}; + +impl AstraEditor { + /// Builds the editor workspace; terminal actions launch the platform's real terminal. + pub(super) fn editor_workspace(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + self.editor_surface(theme, cx) + } + + /// Builds tabs, the file toolbar, and the controlled multiline code editor. + fn editor_surface(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + Element::column([ + self.tab_strip(theme, cx), + self.file_toolbar(theme), + self.code_editor(theme, cx), + ]) + .keyed("editor-surface") + .width(percent(1.0)) + .height(percent(1.0)) + .min_width(length(0.0)) + .min_height(length(0.0)) + .background(theme.background) + } + + /// Builds horizontally scrollable open-file tabs with retained dirty state. + fn tab_strip(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + let tabs = self.open_documents.iter().map(|document| { + let document_id = *document; + let file = &self.project.documents[document_id]; + let active = document_id == self.active_document; + let select = cx.event_handler(move |editor, _, cx| { + editor.open_document(document_id, cx); + }); + let close = cx.event_handler(move |editor, event, cx| { + editor.close_document(document_id, cx); + event.stop_propagation(); + }); + let dirty = file.is_dirty().then(|| { + Element::container([]) + .width(length(6.0)) + .height(length(6.0)) + .background(accent(1.0)) + .radius(CornerRadii::all(99.0)) + }); + let content = Element::row(dirty.into_iter().chain(std::iter::once(label( + file.name(), + 11.5, + if active { + theme.foreground + } else { + theme.muted_foreground + }, + if active { 620 } else { 500 }, + )))) + .gap(7.0) + .align_items(AlignItems::CENTER); + let mut style = if active { + theme.outline_button() + } else { + theme.ghost_button() + }; + style.paint = PaintStyle::new( + QuadStyle::solid(if active { theme.background } else { theme.card }) + .border(Border { + widths: BorderWidths { + bottom: if active { 2.0 } else { 0.0 }, + ..BorderWidths::default() + }, + color: accent(1.0), + }) + .radius(CornerRadii::all(7.0)), + ); + let tab = Button::new( + format!("tab::{document_id}"), + format!("Open {}", file.name()), + style, + ) + .content(content) + .on_click(select) + .build() + .height(length(30.0)); + let close = Button::icon( + format!("close-tab::{document_id}"), + format!("Close {}", file.name()), + self.assets + .icon(TablerIcon::Close, 13.0) + .vector_color(theme.muted_foreground), + theme.ghost_button(), + ) + .on_click(close) + .build() + .width(length(26.0)) + .height(length(26.0)) + .padding(Sides::length(0.0)); + Element::row([tab, close]) + .gap(1.0) + .align_items(AlignItems::CENTER) + .padding(Sides { + left: length(2.0), + right: length(2.0), + top: length(3.0), + bottom: length(3.0), + }) + .shrink(0.0) + }); + let strip = Element::row(tabs) + .padding(Sides { + left: length(7.0), + right: length(7.0), + top: length(1.0), + bottom: length(1.0), + }) + .gap(2.0) + .background(theme.card) + .shrink(0.0); + Element::layout_boundary(strip) + .keyed("open-tabs") + .width(percent(1.0)) + .height(length(38.0)) + .min_width(length(0.0)) + .shrink(0.0) + .background(theme.card) + .border(Border { + widths: BorderWidths { + bottom: 1.0, + ..BorderWidths::default() + }, + color: theme.border, + }) + .overflow(Axes { + x: Overflow::Auto, + y: Overflow::Hidden, + }) + .scroll_config( + ScrollConfig::default() + .axes(ScrollAxes::Horizontal) + .propagation(ScrollPropagation::Contain) + .scrollbar(theme.scrollbar.clone()) + .effect(scroll_shadow(theme)), + ) + } + + /// Builds the active file breadcrumb and editor-dock controls. + fn file_toolbar(&self, theme: &WidgetTheme) -> Element { + let document = self.active_document(); + let state = if document.is_dirty() { + "Edited" + } else { + "Saved" + }; + let breadcrumb = Element::row([ + label(&document.path, 11.0, theme.muted_foreground, 500) + .min_width(length(0.0)) + .shrink(1.0), + label( + state, + 10.0, + if document.is_dirty() { + accent(1.0) + } else { + theme.muted_foreground + }, + 650, + ), + ]) + .gap(9.0) + .align_items(AlignItems::CENTER) + .min_width(length(0.0)); + Element::row([breadcrumb]) + .height(length(36.0)) + .padding(Sides { + left: length(if self.compact { 10.0 } else { 16.0 }), + right: length(if self.compact { 6.0 } else { 10.0 }), + top: length(4.0), + bottom: length(4.0), + }) + .gap(8.0) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::START) + .border(Border { + widths: BorderWidths { + bottom: 1.0, + ..BorderWidths::default() + }, + color: theme.border, + }) + } + + /// Builds the monospaced text area with non-wrapping code and rounded selection fragments. + fn code_editor(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + let document = self.active_document(); + let mut style = theme.input(); + let quad = QuadStyle::solid(theme.background); + style.paint = PaintStyle::new(quad.clone()); + style.hovered = StylePatch::from_quad(quad.clone()); + style.focused = StylePatch::from_quad(quad); + style.layout.padding = argui::ui::sides(if self.compact { 13.0 } else { 20.0 }, 16.0); + style.text = TextStyle { + color: theme.foreground, + font_size: if self.compact { 13.0 } else { 13.5 }, + line_height: if self.compact { 20.0 } else { 21.0 }, + family: FontFamily::Monospace, + weight: 430, + wrap: TextWrap::None, + ..TextStyle::default() + }; + style.placeholder = style.text.clone(); + style.placeholder.color = theme.muted_foreground; + style.selection = accent(0.28); + let mut editor = TextArea::new("code-editor", &document.content, "Start writing…", style) + .wrap(TextWrap::None) + .scroll_config( + ScrollConfig::default() + .propagation(ScrollPropagation::Contain) + .scrollbar(theme.scrollbar.clone()) + .effect(scroll_shadow(theme)), + ); + if let Some(content) = + document.highlighted_content(matches!(self.theme_mode, argui::theme::ThemeMode::Dark)) + { + editor = editor.rich_text(content); + } + editor + .on_edit(cx.edit_event_handler(|editor, edit, _, cx| { + editor.edit_active(edit, cx); + })) + .build() + .selection_highlight(TextSelectionHighlight::solid(accent(0.28)).radius(5.0)) + .width(percent(1.0)) + .min_width(length(0.0)) + .min_height(length(0.0)) + .flex_basis(length(0.0)) + .grow(1.0) + .shrink(1.0) + } +} diff --git a/app_examples/astra-editor/src/app/io.rs b/app_examples/astra-editor/src/app/io.rs new file mode 100644 index 00000000..befaa0d7 --- /dev/null +++ b/app_examples/astra-editor/src/app/io.rs @@ -0,0 +1,257 @@ +use argui::{ + platform::file_picker::{FileDialog, FilePickerMode}, + runtime::Context, +}; + +use super::AstraEditor; +use crate::workspace::Project; + +impl AstraEditor { + /// Starts the platform-appropriate project selection flow. + pub(super) fn choose_project(&mut self, cx: &mut Context) { + #[cfg(not(target_arch = "wasm32"))] + self.choose_native_project(cx); + #[cfg(target_arch = "wasm32")] + self.choose_browser_files(cx); + } + + /// Opens a native folder picker and scans the result away from the UI thread. + #[cfg(not(target_arch = "wasm32"))] + fn choose_native_project(&mut self, cx: &mut Context) { + let dialog = FileDialog::new(FilePickerMode::Folder).title("Open a project folder"); + self.loading_project = true; + self.notice = Some("Choose a project folder…".into()); + let started = cx.spawn_latest( + &mut self.picker_task, + dialog.open(), + |editor, result, cx| match result { + Ok(Ok(Some(files))) if !files.is_empty() => { + editor.scan_native_project(files[0].path().to_path_buf(), cx); + } + Ok(Ok(_)) => { + editor.loading_project = false; + editor.notice = Some("Folder selection cancelled".into()); + cx.notify(); + } + Ok(Err(error)) => { + editor.loading_project = false; + editor.notice = Some(error.to_string()); + cx.notify(); + } + Err(error) => { + editor.loading_project = false; + editor.notice = Some(format!("Could not open the picker: {error}")); + cx.notify(); + } + }, + ); + if let Err(error) = started { + self.loading_project = false; + self.notice = Some(format!("Could not start the picker: {error}")); + } + cx.notify(); + } + + /// Runs native project discovery on Argui's blocking task executor. + #[cfg(not(target_arch = "wasm32"))] + fn scan_native_project(&mut self, root: std::path::PathBuf, cx: &mut Context) { + if let Some(task) = self.scan_task.take() { + task.cancel(); + } + self.notice = Some(format!("Indexing {}…", root.display())); + match cx.spawn_blocking( + move |token| Project::from_path(root, || token.is_cancelled()), + |editor, result, cx| match result { + Ok(Ok(project)) => editor.install_project(project, cx), + Ok(Err(error)) => { + editor.loading_project = false; + editor.notice = Some(error.to_string()); + cx.notify(); + } + Err(error) => { + editor.loading_project = false; + editor.notice = Some(format!("Project indexing failed: {error}")); + cx.notify(); + } + }, + ) { + Ok(task) => self.scan_task = Some(task), + Err(error) => { + self.loading_project = false; + self.notice = Some(format!("Could not start project indexing: {error}")); + } + } + cx.notify(); + } + + /// Imports selected browser files into an in-memory editable project. + #[cfg(target_arch = "wasm32")] + fn choose_browser_files(&mut self, cx: &mut Context) { + let dialog = FileDialog::new(FilePickerMode::Files).title("Import project files"); + self.loading_project = true; + self.notice = Some("Choose one or more UTF-8 project files…".into()); + let started = cx.spawn_latest( + &mut self.picker_task, + async move { + let selected = dialog.open().await?; + let Some(files) = selected else { + return Ok::, argui::platform::file_picker::FileDialogError>( + None, + ); + }; + let mut documents = Vec::new(); + for file in files { + let name = file.file_name(); + if let Ok(content) = String::from_utf8(file.read().await) { + documents.push((name, content)); + } + } + Ok(Some(Project::from_imported_documents( + "browser-workspace", + documents, + ))) + }, + |editor, result, cx| match result { + Ok(Ok(Some(project))) if !project.documents.is_empty() => { + editor.install_project(project, cx); + } + Ok(Ok(_)) => { + editor.loading_project = false; + editor.notice = Some("No UTF-8 files were imported".into()); + cx.notify(); + } + Ok(Err(error)) => { + editor.loading_project = false; + editor.notice = Some(error.to_string()); + cx.notify(); + } + Err(error) => { + editor.loading_project = false; + editor.notice = Some(format!("Import failed: {error}")); + cx.notify(); + } + }, + ); + if let Err(error) = started { + self.loading_project = false; + self.notice = Some(format!("Could not start file import: {error}")); + } + cx.notify(); + } + + /// Saves the active document natively or updates the browser-session checkpoint. + pub(super) fn save_active(&mut self, cx: &mut Context) { + #[cfg(not(target_arch = "wasm32"))] + self.save_active_native(cx); + #[cfg(target_arch = "wasm32")] + { + self.project.documents[self.active_document].mark_saved(); + self.notice = Some("Saved in this browser session".into()); + cx.notify(); + } + } + + /// Writes the active document using Argui's blocking task executor. + #[cfg(not(target_arch = "wasm32"))] + fn save_active_native(&mut self, cx: &mut Context) { + let document = self.active_document; + let snapshot = self.project.documents[document].content.clone(); + let Some(path) = self.project.documents[document].absolute_path.clone() else { + self.project.documents[document].mark_saved(); + self.notice = Some("Saved in the bundled workspace session".into()); + cx.notify(); + return; + }; + if let Some(task) = self.save_task.take() { + task.cancel(); + } + let written = snapshot.clone(); + match cx.spawn_blocking( + move |_| std::fs::write(&path, written).map(|_| path), + move |editor, result, cx| { + match result { + Ok(Ok(path)) => { + if let Some(document) = editor + .project + .documents + .iter_mut() + .find(|document| document.absolute_path.as_deref() == Some(&path)) + { + document.mark_snapshot_saved(&snapshot); + editor.notice = Some(format!("Saved {}", path.display())); + } + } + Ok(Err(error)) => editor.notice = Some(format!("Save failed: {error}")), + Err(error) => editor.notice = Some(format!("Save task failed: {error}")), + } + cx.notify(); + }, + ) { + Ok(task) => self.save_task = Some(task), + Err(error) => self.notice = Some(format!("Could not start save: {error}")), + } + cx.notify(); + } + + /// Launches a system terminal at the active project root when available. + pub(super) fn launch_terminal(&mut self, cx: &mut Context) { + #[cfg(not(target_arch = "wasm32"))] + { + let directory = self + .project + .root + .clone() + .or_else(|| std::env::current_dir().ok()); + self.notice = Some(match directory { + Some(directory) => match open_terminal(&directory) { + Ok(()) => format!("Opened terminal at {}", directory.display()), + Err(error) => format!("Could not open terminal: {error}"), + }, + None => "Could not resolve the working directory".into(), + }); + } + #[cfg(target_arch = "wasm32")] + { + self.notice = Some("System terminals are available in the native build".into()); + } + cx.notify(); + } +} + +/// Starts the default terminal application in `directory` on supported desktop systems. +#[cfg(not(target_arch = "wasm32"))] +fn open_terminal(directory: &std::path::Path) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .args(["-a", "Terminal"]) + .arg(directory) + .spawn() + .map_err(|error| error.to_string())?; + return Ok(()); + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "start", "", "cmd", "/K"]) + .current_dir(directory) + .spawn() + .map_err(|error| error.to_string())?; + return Ok(()); + } + #[cfg(target_os = "linux")] + { + for terminal in ["x-terminal-emulator", "kgx", "gnome-terminal", "konsole"] { + if std::process::Command::new(terminal) + .current_dir(directory) + .spawn() + .is_ok() + { + return Ok(()); + } + } + return Err("no supported terminal application was found".into()); + } + #[allow(unreachable_code)] + Err("opening a terminal is unavailable on this platform".into()) +} diff --git a/app_examples/astra-editor/src/app/search.rs b/app_examples/astra-editor/src/app/search.rs new file mode 100644 index 00000000..6b8389e0 --- /dev/null +++ b/app_examples/astra-editor/src/app/search.rs @@ -0,0 +1,394 @@ +use argui::{ + core::Color, + paint::{Border, CornerRadii, LayerMask, LayerStyle, Shadow}, + runtime::Context, + ui::{ + AlignItems, Element, EventType, FocusScope, InitialFocus, Interaction, JustifyContent, + Role, Semantics, Sides, length, percent, + }, + widgets::{Button, Input, InputKind, Kbd, TablerIcon, VList, WidgetTheme}, +}; + +use super::{AstraEditor, SearchMode}; +use crate::ui::{accent, label, scroll_shadow}; + +impl AstraEditor { + /// Builds the animated global-search overlay while it is entering, open, or exiting. + pub(super) fn search_overlay( + &self, + theme: &WidgetTheme, + cx: &mut Context, + ) -> Option { + if !self.search.open && self.search.progress == 0.0 { + return None; + } + let progress = self.search_progress(); + let panel_width = if self.compact { 0.96 } else { 0.72 }; + let result_viewport = + (self.viewport.height - if self.compact { 210.0 } else { 260.0 }).clamp(180.0, 500.0); + let panel = Element::column([ + self.search_header(theme, cx), + self.search_mode_switcher(theme, cx), + self.search_results(result_viewport, theme, cx), + self.search_footer(theme), + ]) + .keyed("workspace-search-panel") + .width(percent(panel_width)) + .max_width(length(820.0)) + .min_width(length(0.0)) + .max_height(length(if self.compact { + self.viewport.height - 24.0 + } else { + 640.0 + })) + .background(theme.popover) + .border(Border::all(1.0, theme.popover_border)) + .radius(CornerRadii::all(if self.compact { 13.0 } else { 16.0 })) + .layer( + LayerStyle::new(Default::default()) + .shadow(Shadow::glow(24.0, Color::BLACK.with_alpha(0.18))) + .mask(LayerMask::Rounded(CornerRadii::all(if self.compact { + 13.0 + } else { + 16.0 + }))), + ) + .semantics(Semantics::new(Role::Dialog).label(self.search.mode.title())); + let panel = panel + .opacity(progress) + .transform(argui::core::Transform2D::IDENTITY.translate(0.0, 8.0 * (1.0 - progress))); + let backdrop = Element::container([]) + .keyed("workspace-search-backdrop") + .absolute(Sides::length(0.0)) + .background(Color::BLACK.with_alpha(0.44 * progress)) + .interaction(Interaction::blocker()) + .on(cx + .event_handler(|editor, event, cx| { + editor.hide_search(cx); + event.stop_propagation(); + }) + .listener(EventType::Click)); + let panel_layer = Element::row([panel]) + .width(percent(1.0)) + .height(percent(1.0)) + .padding(Sides { + left: length(0.0), + right: length(0.0), + top: length(if self.compact { 12.0 } else { 28.0 }), + bottom: length(12.0), + }) + .align_items(AlignItems::START) + .justify_content(JustifyContent::CENTER) + .absolute(Sides::length(0.0)) + .z_index(1); + Some( + Element::container([backdrop, panel_layer]) + .keyed("workspace-search-overlay") + .focus_scope(FocusScope::modal(InitialFocus::Target( + "workspace-search".into(), + ))) + .interaction(Interaction::blocker()) + .width(percent(1.0)) + .height(percent(1.0)) + .absolute(Sides::length(0.0)) + .z_index(100), + ) + } + + /// Builds the search title, query input, and close action. + fn search_header(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + let input = Input::new( + "workspace-search", + &self.search.query, + self.search.mode.placeholder(), + theme.input(), + ) + .kind(InputKind::Search) + .label(self.search.mode.title()) + .leading( + self.assets + .icon(TablerIcon::Search, 17.0) + .vector_color(theme.muted_foreground), + 40.0, + ) + .on_input(cx.input_callback(|editor, value| { + editor.search.query = value; + editor.refresh_search(); + })) + .on_submit(cx.submit_event_handler(|editor, _, _, cx| { + editor.activate_first_search_result(cx); + })) + .build() + .grow(1.0) + .min_width(length(0.0)); + let close = Button::icon( + "close-search", + "Close search", + self.assets + .icon(TablerIcon::Close, 17.0) + .vector_color(theme.foreground), + theme.ghost_button(), + ) + .on_click(cx.event_handler(|editor, _, cx| editor.hide_search(cx))) + .build() + .width(length(36.0)) + .height(length(36.0)) + .padding(Sides::length(0.0)); + Element::row([input, close]) + .padding(Sides::length(if self.compact { 10.0 } else { 14.0 })) + .gap(8.0) + .align_items(AlignItems::CENTER) + } + + /// Builds the text/file search mode selector. + fn search_mode_switcher(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + let modes = [ + (SearchMode::Text, "Text", "Search file contents"), + (SearchMode::Files, "Files", "Open a file by path"), + ]; + Element::row(modes.into_iter().map(|(mode, title, description)| { + let style = if self.search.mode == mode { + theme.button() + } else { + theme.ghost_button() + }; + Button::new(format!("search-mode::{mode:?}"), title, style) + .tooltip(description) + .on_click(cx.event_handler(move |editor, _, cx| { + editor.search.mode = mode; + editor.refresh_search(); + cx.request_focus("workspace-search"); + cx.notify(); + })) + .build() + .height(length(29.0)) + })) + .padding(Sides { + left: length(if self.compact { 10.0 } else { 14.0 }), + right: length(if self.compact { 10.0 } else { 14.0 }), + top: length(0.0), + bottom: length(10.0), + }) + .gap(5.0) + } + + /// Builds the virtualized content or file result list. + fn search_results( + &self, + viewport: f32, + theme: &WidgetTheme, + cx: &mut Context, + ) -> Element { + let count = match self.search.mode { + SearchMode::Text => self.search.text_matches.len(), + SearchMode::Files => self.search.file_matches.len(), + }; + if count == 0 { + return self.empty_search(viewport, theme); + } + let row_height = match self.search.mode { + SearchMode::Text => 60.0, + SearchMode::Files => 46.0, + }; + VList::new( + "workspace-search-results", + row_height, + viewport, + self.search.offset, + ) + .effect(scroll_shadow(theme)) + .build(count, theme, |index| match self.search.mode { + SearchMode::Text => self.text_result(index, theme, cx), + SearchMode::Files => self.file_result(index, theme, cx), + }) + .height(length(viewport)) + .border(Border { + widths: argui::paint::BorderWidths { + top: 1.0, + bottom: 1.0, + ..argui::paint::BorderWidths::default() + }, + color: theme.border, + }) + } + + /// Builds a text-search result that opens its source document. + fn text_result(&self, index: usize, theme: &WidgetTheme, cx: &mut Context) -> Element { + let result = &self.search.text_matches[index]; + let document = &self.project.documents[result.document]; + let document_id = result.document; + let content = Element::column([ + Element::row([ + label(&document.path, 11.0, theme.foreground, 620), + label( + format!("line {}", result.line), + 10.0, + theme.muted_foreground, + 500, + ), + ]) + .gap(8.0) + .align_items(AlignItems::CENTER), + highlighted_preview(&result.preview, &self.search.query, theme), + ]) + .gap(3.0) + .min_width(length(0.0)); + Button::new( + format!("search-result::{index}"), + format!("Open {} at line {}", document.path, result.line), + theme.ghost_button(), + ) + .content(content) + .on_click(cx.event_handler(move |editor, _, cx| { + editor.open_document(document_id, cx); + editor.hide_search(cx); + })) + .build() + .width(percent(1.0)) + .height(length(58.0)) + .justify_content(JustifyContent::START) + .radius(CornerRadii::all(0.0)) + } + + /// Builds one quick-open file result. + fn file_result(&self, index: usize, theme: &WidgetTheme, cx: &mut Context) -> Element { + let document_id = self.search.file_matches[index]; + let document = &self.project.documents[document_id]; + let icon = if document.extension() == "rs" { + TablerIcon::Rust + } else { + TablerIcon::File + }; + let content = Element::row([ + self.assets.icon(icon, 15.0).vector_color(accent(1.0)), + highlighted_preview(&document.path, &self.search.query, theme), + ]) + .gap(10.0) + .align_items(AlignItems::CENTER); + Button::new( + format!("file-result::{index}"), + format!("Open {}", document.path), + theme.ghost_button(), + ) + .content(content) + .on_click(cx.event_handler(move |editor, _, cx| { + editor.open_document(document_id, cx); + editor.hide_search(cx); + })) + .build() + .width(percent(1.0)) + .height(length(44.0)) + .justify_content(JustifyContent::START) + .radius(CornerRadii::all(0.0)) + } + + /// Builds the no-results state with shortcut discovery. + fn empty_search(&self, viewport: f32, theme: &WidgetTheme) -> Element { + let message = if self.search.query.trim().is_empty() { + match self.search.mode { + SearchMode::Text => format!( + "Search {} indexed files. Results update as you type.", + self.project.documents.len() + ), + SearchMode::Files => "Start typing or choose any indexed file below.".into(), + } + } else { + format!("No match for “{}”", self.search.query) + }; + Element::column([ + self.assets + .icon(TablerIcon::Search, 25.0) + .vector_color(theme.muted_foreground), + label(message, 12.0, theme.muted_foreground, 500), + Kbd::new("search-empty-shortcut", ["Ctrl", "⇧", "F"]) + .label("Control Shift F") + .size(20.0) + .build(theme), + ]) + .height(length(viewport)) + .padding(Sides::length(24.0)) + .gap(10.0) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::CENTER) + .border(Border { + widths: argui::paint::BorderWidths { + top: 1.0, + bottom: 1.0, + ..argui::paint::BorderWidths::default() + }, + color: theme.border, + }) + } + + /// Builds search performance metadata and the dismiss keycap. + fn search_footer(&self, theme: &WidgetTheme) -> Element { + let count = match self.search.mode { + SearchMode::Text => self.search.text_matches.len(), + SearchMode::Files => self.search.file_matches.len(), + }; + Element::row([ + label( + format!( + "{count} results · {} µs · in-memory index", + self.search.elapsed_micros + ), + 10.5, + theme.muted_foreground, + 500, + ), + Kbd::new("dismiss-search", ["Esc"]) + .label("Escape") + .size(19.0) + .build(theme), + ]) + .height(length(40.0)) + .padding(Sides { + left: length(14.0), + right: length(14.0), + top: length(8.0), + bottom: length(8.0), + }) + .gap(12.0) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::SPACE_BETWEEN) + } + + /// Opens the first current search result when the query is submitted. + fn activate_first_search_result(&mut self, cx: &mut Context) { + let document = match self.search.mode { + SearchMode::Text => self + .search + .text_matches + .first() + .map(|result| result.document), + SearchMode::Files => self.search.file_matches.first().copied(), + }; + if let Some(document) = document { + self.open_document(document, cx); + self.hide_search(cx); + } + } +} + +/// Builds a preview with the first ASCII query match tinted in the editor accent. +fn highlighted_preview(preview: &str, query: &str, theme: &WidgetTheme) -> Element { + let query = query.trim(); + let found = query + .is_ascii() + .then(|| { + preview + .to_ascii_lowercase() + .find(&query.to_ascii_lowercase()) + }) + .flatten(); + let Some(start) = found else { + return label(preview, 11.0, theme.muted_foreground, 450); + }; + let end = start + query.len(); + Element::row([ + label(&preview[..start], 11.0, theme.muted_foreground, 450), + label(&preview[start..end], 11.0, accent(1.0), 700), + label(&preview[end..], 11.0, theme.muted_foreground, 450), + ]) + .min_width(length(0.0)) +} diff --git a/app_examples/astra-editor/src/app/sidebar.rs b/app_examples/astra-editor/src/app/sidebar.rs new file mode 100644 index 00000000..7b48fa4a --- /dev/null +++ b/app_examples/astra-editor/src/app/sidebar.rs @@ -0,0 +1,160 @@ +use argui::{ + paint::{Border, BorderWidths}, + runtime::Context, + ui::{AlignItems, Element, JustifyContent, Sides, length, percent}, + widgets::{Button, Kbd, TablerIcon, TreeView, VList, WidgetTheme}, +}; + +use super::AstraEditor; +use crate::ui::{label, scroll_shadow}; + +impl AstraEditor { + /// Builds the virtualized project explorer for desktop and compact layouts. + pub(super) fn project_sidebar(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + let viewport = + (self.viewport.height - if self.compact { 142.0 } else { 168.0 }).clamp(120.0, 1_200.0); + let selected = + (!self.selected_tree_key.is_empty()).then_some(self.selected_tree_key.as_str()); + let select = cx.value_event_handler(|editor, key: String, _, cx| { + editor.selected_tree_key.clone_from(&key); + if let Some(document) = editor.project.document_for_key(&key) { + editor.open_document(document, cx); + return; + } + if editor.project.is_directory(&key) { + if editor.collapsed.remove(&key) { + editor.tree_reveal_root = Some(key); + editor.tree_reveal_started_at = web_time::Instant::now(); + editor.tree_reveal_complete = false; + } else { + editor.collapsed.insert(key); + editor.tree_reveal_root = None; + editor.tree_reveal_complete = true; + } + cx.notify(); + } + }); + let mut tree = TreeView::new( + &self.tree_nodes, + selected, + &self.collapsed, + VList::new("project-tree", 30.0, viewport, self.explorer_offset) + .effect(scroll_shadow(theme)), + ) + .disclosure(Some(self.assets.vector_id(TablerIcon::ChevronRight))) + .on_select(select); + if let Some(parent) = self.tree_reveal_root.as_deref() { + tree = tree.reveal_descendants(parent, self.tree_open_progress()); + } + let tree = tree + .build_cached(theme, &mut self.tree_cache.borrow_mut()) + .grow(1.0) + .min_height(length(0.0)); + let header = self.explorer_header(theme, cx); + let shortcuts = (!self.compact).then(|| { + Element::column([ + shortcut("Quick open", ["Ctrl", "P"], "explorer-quick-open", theme), + shortcut("Toggle explorer", ["Ctrl", "B"], "explorer-toggle", theme), + ]) + .padding(Sides { + left: length(12.0), + right: length(12.0), + top: length(8.0), + bottom: length(10.0), + }) + .gap(7.0) + .border(Border { + widths: BorderWidths { + top: 1.0, + ..BorderWidths::default() + }, + color: theme.border, + }) + }); + Element::column( + std::iter::once(header) + .chain(std::iter::once(tree)) + .chain(shortcuts), + ) + .keyed("project-explorer") + .width(percent(1.0)) + .height(percent(1.0)) + .min_width(length(0.0)) + .min_height(length(0.0)) + .background(theme.card) + } + + /// Builds the explorer title, indexed-file count, and compact close action. + fn explorer_header(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + let title = Element::column([ + label("EXPLORER", 10.5, theme.muted_foreground, 720), + label( + format!( + "{} · {} files", + self.project.name, + self.project.documents.len() + ), + 12.0, + theme.foreground, + 600, + ), + ]) + .gap(3.0) + .min_width(length(0.0)); + let action = if self.compact { + Button::icon( + "close-explorer", + "Close explorer", + self.assets + .icon(TablerIcon::Close, 16.0) + .vector_color(theme.foreground), + theme.ghost_button(), + ) + .on_click(cx.event_handler(|editor, _, cx| { + editor.explorer_revealed = false; + cx.notify(); + })) + .build() + .width(length(32.0)) + .height(length(32.0)) + .padding(Sides::length(0.0)) + } else { + Button::icon( + "refresh-project", + "Open another project", + self.assets + .icon(TablerIcon::ChevronRight, 16.0) + .vector_color(theme.muted_foreground), + theme.ghost_button(), + ) + .on_click(cx.event_handler(|editor, _, cx| editor.choose_project(cx))) + .build() + .width(length(32.0)) + .height(length(32.0)) + .padding(Sides::length(0.0)) + }; + Element::row([title, action]) + .height(length(66.0)) + .padding(Sides::length(12.0)) + .gap(8.0) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::SPACE_BETWEEN) + .border(Border { + widths: BorderWidths { + bottom: 1.0, + ..BorderWidths::default() + }, + color: theme.border, + }) + } +} + +/// Builds one explorer shortcut row with a real Kbd widget. +fn shortcut(title: &str, keys: [&str; 2], key: &str, theme: &WidgetTheme) -> Element { + Element::row([ + label(title, 10.5, theme.muted_foreground, 500), + Kbd::new(key, keys).label(title).size(18.0).build(theme), + ]) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::SPACE_BETWEEN) +} diff --git a/app_examples/astra-editor/src/app/view.rs b/app_examples/astra-editor/src/app/view.rs new file mode 100644 index 00000000..d8977c04 --- /dev/null +++ b/app_examples/astra-editor/src/app/view.rs @@ -0,0 +1,339 @@ +use argui::{ + paint::{Border, BorderWidths}, + runtime::Context, + ui::{ + AlignItems, Display, Element, FocusScope, InitialFocus, Interaction, JustifyContent, Role, + Semantics, Sides, length, percent, + }, + widgets::{Button, SplitAxis, SplitPane, TablerIcon, WidgetTheme}, +}; + +use super::{AstraEditor, SearchMode}; +use crate::ui::label; + +impl AstraEditor { + /// Composes the complete responsive application surface. + pub(super) fn view(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + let content = if self.compact { + if self.explorer_revealed { + self.project_sidebar(theme, cx) + .width(percent(1.0)) + .grow(1.0) + } else { + self.editor_workspace(theme, cx) + } + } else { + self.desktop_workspace(theme, cx) + }; + let workspace = if self.compact { + Element::column([ + content.grow(1.0).min_height(length(0.0)), + self.activity_bar(theme, cx), + ]) + } else { + Element::row([ + self.activity_bar(theme, cx), + content.grow(1.0).min_width(length(0.0)), + ]) + }; + let application = Element::column([workspace.grow(1.0), self.status_bar(theme)]) + .width(percent(1.0)) + .height(percent(1.0)) + .min_width(length(0.0)) + .min_height(length(0.0)); + let mut layers = vec![application]; + if let Some(search) = self.search_overlay(theme, cx) { + layers.push(search); + } + Element::container(layers) + .keyed(match self.theme_mode { + argui::theme::ThemeMode::Dark => "astra-editor-dark", + argui::theme::ThemeMode::Light | argui::theme::ThemeMode::System => { + "astra-editor-light" + } + }) + .focus_scope(FocusScope { + initial: Some(InitialFocus::Target("code-editor".into())), + ..FocusScope::restoring().restore(false) + }) + .interaction(Interaction::default().focus_policy(argui::ui::FocusPolicy::TabStop)) + .semantics( + Semantics::new(Role::Window) + .label("Astra Editor") + .description("Responsive Rust project editor built with Argui"), + ) + .width(percent(1.0)) + .height(percent(1.0)) + .min_width(length(0.0)) + .min_height(length(0.0)) + .background(theme.background) + .inspectable(true) + } + + /// Builds the resizable explorer and editor arrangement used on larger screens. + fn desktop_workspace(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + let pane = SplitPane::new( + "workspace-explorer-split", + SplitAxis::Horizontal, + self.explorer_width, + 180.0, + 420.0, + ) + .on_change(cx.value_callback(|editor, value| editor.explorer_width = value)); + let mut separator = pane.separator(theme); + if let Some(semantics) = &mut separator.semantics { + semantics.label = Some("Resize project explorer".into()); + } + if !self.explorer_open { + return Element::row([ + self.project_sidebar(theme, cx).display(Display::None), + separator.display(Display::None), + self.editor_workspace(theme, cx) + .grow(1.0) + .min_width(length(0.0)) + .min_height(length(0.0)), + ]) + .width(percent(1.0)) + .height(percent(1.0)) + .min_width(length(0.0)) + .min_height(length(0.0)); + } + pane.build( + self.project_sidebar(theme, cx), + separator, + self.editor_workspace(theme, cx), + self.viewport.width, + 360.0, + ) + .width(percent(1.0)) + .height(percent(1.0)) + .min_width(length(0.0)) + .min_height(length(0.0)) + } + + /// Builds the VS Code-style activity rail, horizontal on compact screens. + fn activity_bar(&self, theme: &WidgetTheme, cx: &mut Context) -> Element { + let explorer_active = if self.compact { + self.explorer_revealed + } else { + self.explorer_open + }; + let explorer = Button::icon( + "toggle-explorer", + "Explorer · Ctrl+B", + self.assets + .icon(TablerIcon::Sidebar, 19.0) + .vector_color(if explorer_active { + theme.foreground + } else { + theme.muted_foreground + }), + theme.ghost_button(), + ) + .tooltip("Explorer · Ctrl+B") + .on_click(cx.event_handler(|editor, _, cx| editor.toggle_explorer(cx))) + .build() + .width(length(40.0)) + .height(length(40.0)) + .padding(Sides::length(0.0)) + .background(if explorer_active { + theme.muted + } else { + argui::core::Color::TRANSPARENT + }) + .border(Border { + widths: BorderWidths { + left: if explorer_active && !self.compact { + 2.0 + } else { + 0.0 + }, + bottom: if explorer_active && self.compact { + 2.0 + } else { + 0.0 + }, + ..BorderWidths::default() + }, + color: theme.primary, + }); + let search = Button::icon( + "global-search", + "Search project · Ctrl+Shift+F", + self.assets + .icon(TablerIcon::Search, 19.0) + .vector_color(theme.muted_foreground), + theme.ghost_button(), + ) + .tooltip("Search project · Ctrl+Shift+F") + .on_click(cx.event_handler(|editor, _, cx| editor.show_search(SearchMode::Text, cx))) + .build() + .width(length(40.0)) + .height(length(40.0)) + .padding(Sides::length(0.0)); + let open = Button::icon( + "open-project", + if cfg!(target_arch = "wasm32") { + "Import project files" + } else { + "Open project folder" + }, + self.assets + .icon( + if self.loading_project { + TablerIcon::Loader + } else { + TablerIcon::Folder + }, + 19.0, + ) + .vector_color(theme.muted_foreground), + theme.ghost_button(), + ) + .tooltip(if cfg!(target_arch = "wasm32") { + "Import project files" + } else { + "Open project folder" + }) + .enabled(!self.loading_project) + .on_click(cx.event_handler(|editor, _, cx| editor.choose_project(cx))) + .build() + .width(length(40.0)) + .height(length(40.0)) + .padding(Sides::length(0.0)); + let save = Button::icon( + "save-document", + "Save · Ctrl+S", + self.assets + .icon(TablerIcon::Check, 19.0) + .vector_color(theme.muted_foreground), + theme.ghost_button(), + ) + .tooltip("Save · Ctrl+S") + .enabled(self.active_document().is_dirty()) + .on_click(cx.event_handler(|editor, _, cx| editor.save_active(cx))) + .build() + .width(length(40.0)) + .height(length(40.0)) + .padding(Sides::length(0.0)); + let terminal = Button::icon( + "activity-terminal", + "Open terminal · Ctrl+J", + self.assets + .icon(TablerIcon::Terminal, 19.0) + .vector_color(theme.muted_foreground), + theme.ghost_button(), + ) + .tooltip("Open terminal · Ctrl+J") + .on_click(cx.event_handler(|editor, _, cx| editor.launch_terminal(cx))) + .build() + .width(length(40.0)) + .height(length(40.0)) + .padding(Sides::length(0.0)); + let theme_icon = match self.theme_mode { + argui::theme::ThemeMode::Dark => TablerIcon::Sun, + argui::theme::ThemeMode::Light | argui::theme::ThemeMode::System => TablerIcon::Moon, + }; + let appearance = Button::icon( + "toggle-theme", + "Toggle light and dark theme", + self.assets + .icon(theme_icon, 17.0) + .vector_color(theme.foreground), + theme.ghost_button(), + ) + .tooltip("Toggle light and dark theme") + .on_click(cx.event_handler(|editor, _, cx| editor.toggle_theme(cx))) + .build() + .width(length(40.0)) + .height(length(40.0)) + .padding(Sides::length(0.0)); + let primary = vec![explorer, search, open, save]; + let secondary = vec![terminal, appearance]; + if self.compact { + Element::row(primary.into_iter().chain(secondary)) + .keyed("editor-activity-bar") + .height(length(46.0)) + .padding(Sides::length(3.0)) + .gap(3.0) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::CENTER) + .background(theme.card) + .border(Border { + widths: BorderWidths { + top: 1.0, + ..BorderWidths::default() + }, + color: theme.border, + }) + } else { + Element::column([ + Element::column(primary).gap(3.0), + Element::column(secondary).gap(3.0), + ]) + .keyed("editor-activity-bar") + .width(length(46.0)) + .height(percent(1.0)) + .padding(Sides::length(3.0)) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::SPACE_BETWEEN) + .background(theme.card) + .border(Border { + widths: BorderWidths { + right: 1.0, + ..BorderWidths::default() + }, + color: theme.border, + }) + } + } + + /// Builds the compact bottom status bar shared by every layout. + fn status_bar(&self, theme: &WidgetTheme) -> Element { + let document = self.active_document(); + let left = self.notice.as_deref().unwrap_or("Ready"); + let mut right = vec![label( + document.language_label(), + 10.5, + theme.muted_foreground, + 650, + )]; + if !self.compact { + right.insert( + 0, + label( + format!("{} lines", document.line_count()), + 10.5, + theme.muted_foreground, + 500, + ), + ); + right.push(label("UTF-8", 10.5, theme.muted_foreground, 500)); + } + Element::row([ + label(left, 10.5, theme.muted_foreground, 500) + .min_width(length(0.0)) + .shrink(1.0), + Element::row(right).gap(13.0).shrink(0.0), + ]) + .keyed("editor-status") + .height(length(26.0)) + .padding(Sides { + left: length(12.0), + right: length(12.0), + top: length(4.0), + bottom: length(4.0), + }) + .gap(12.0) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::SPACE_BETWEEN) + .background(theme.card) + .border(Border { + widths: BorderWidths { + top: 1.0, + ..BorderWidths::default() + }, + color: theme.border, + }) + } +} diff --git a/app_examples/astra-editor/src/lib.rs b/app_examples/astra-editor/src/lib.rs new file mode 100644 index 00000000..bb16a954 --- /dev/null +++ b/app_examples/astra-editor/src/lib.rs @@ -0,0 +1,133 @@ +//! Astra Editor, a product-shaped Argui application example. + +mod app; +mod syntax; +mod ui; +pub mod workspace; + +pub use app::AstraEditor; +use argui::{ + core::ColorScheme, + platform::{ + AppIcon, ApplicationConfig, ApplicationId, ApplicationIdentity, IconSet, + PreferenceOverrides, WindowConfig, + }, + render::RendererConfig, + runtime::{ + RuntimeEvent, SingleWindowModel, WindowRuntimeEvent, run_application_with_text_engine, + }, + text::TextEngine, +}; + +const NOTO_SANS: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoSans-Regular.ttf"); +const NOTO_ARABIC: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoSansArabic.ttf"); +const NOTO_HEBREW: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoSansHebrew.ttf"); +const NOTO_EMOJI: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoEmoji-Regular.ttf"); +const FIRA_MONO: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/FiraMono-Medium.ttf"); +const APP_ICON: &[u8] = include_bytes!("../../../crates/argui/examples/assets/astra-icon-256.png"); + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen( + inline_js = "export function renderer_state(state) { window.dispatchEvent(new CustomEvent('argui:renderer-state', { detail: { state } })); }" +)] +extern "C" { + /// Notifies the host page that the renderer became ready or failed. + fn renderer_state(state: &str); +} + +/// Publishes renderer readiness and reports runtime failures. +fn handle_event(event: RuntimeEvent) { + #[cfg(target_arch = "wasm32")] + if matches!( + &event, + RuntimeEvent::RendererReady + | RuntimeEvent::Window { + event: WindowRuntimeEvent::RendererReady, + .. + } + ) { + renderer_state("ready"); + } + let error = match event { + RuntimeEvent::RendererFailed(message) + | RuntimeEvent::LayoutFailed(message) + | RuntimeEvent::CommandFailed(message) => Some(message), + RuntimeEvent::Window { + event: + WindowRuntimeEvent::RendererFailed(message) | WindowRuntimeEvent::LayoutFailed(message), + .. + } => Some(message), + _ => None, + }; + if let Some(error) = error { + eprintln!("{error}"); + #[cfg(target_arch = "wasm32")] + renderer_state("error"); + } +} + +/// Starts Astra Editor as a native application or full-page WebAssembly canvas. +/// +/// # Errors +/// +/// Returns an error if the application identity, icon, renderer, or window runtime +/// cannot be initialized. +pub fn launch() -> Result<(), Box> { + let text = TextEngine::from_embedded_fonts( + [NOTO_SANS, NOTO_ARABIC, NOTO_HEBREW, NOTO_EMOJI, FIRA_MONO], + "Noto Sans", + "Noto Sans", + "Fira Mono", + ); + let identity = ApplicationIdentity::new( + ApplicationId::new("dev.argui.astra-editor")?, + "Astra Editor", + IconSet::single(AppIcon::from_png(APP_ICON)?), + ); + let config = ApplicationConfig::new( + identity, + WindowConfig { + title: "Astra Editor · Rust workspace".into(), + width: 1360.0, + height: 860.0, + focus_on_launch: true, + ..WindowConfig::default() + }, + ) + .with_preferences(PreferenceOverrides { + color_scheme: Some(ColorScheme::Light), + ..PreferenceOverrides::default() + }); + run_application_with_text_engine( + config, + RendererConfig::default() + .renderer_fallback(true) + .effects(argui_effects::registry()?), + text, + SingleWindowModel::new(argui::widgets::TooltipHost::new( + argui::widgets::SelectionHost::new(AstraEditor::default()), + )), + handle_event, + )?; + Ok(()) +} + +/// Starts the editor automatically when the WebAssembly module is loaded. +/// +/// # Errors +/// +/// Returns a JavaScript error when application startup fails. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() -> Result<(), wasm_bindgen::JsValue> { + std::panic::set_hook(Box::new(|info| { + renderer_state("error"); + eprintln!("{info}"); + })); + launch().map_err(|error| wasm_bindgen::JsValue::from_str(&error.to_string())) +} diff --git a/app_examples/astra-editor/src/main.rs b/app_examples/astra-editor/src/main.rs new file mode 100644 index 00000000..3e4d8c09 --- /dev/null +++ b/app_examples/astra-editor/src/main.rs @@ -0,0 +1,4 @@ +/// Launches the native Astra Editor executable. +fn main() -> Result<(), Box> { + argui_example_astra_editor::launch() +} diff --git a/app_examples/astra-editor/src/syntax.rs b/app_examples/astra-editor/src/syntax.rs new file mode 100644 index 00000000..fb5ee56b --- /dev/null +++ b/app_examples/astra-editor/src/syntax.rs @@ -0,0 +1,381 @@ +//! Cached Rust syntax highlighting used by the editable text surface. + +#[cfg(not(target_arch = "wasm32"))] +use std::cell::RefCell; + +use argui::{ + core::Color, + text::{TextContent, TextSpan, TextSpanStyle}, +}; +#[cfg(not(target_arch = "wasm32"))] +use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter}; + +const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024; +const HIGHLIGHT_NAMES: &[&str] = &[ + "attribute", + "boolean", + "comment", + "comment.documentation", + "constant", + "constant.builtin", + "constructor", + "escape", + "function", + "function.builtin", + "function.macro", + "function.method", + "keyword", + "keyword.control", + "keyword.function", + "keyword.operator", + "label", + "number", + "operator", + "property", + "punctuation.bracket", + "punctuation.delimiter", + "string", + "string.special", + "type", + "type.builtin", + "variable", + "variable.builtin", + "variable.parameter", +]; + +#[cfg(not(target_arch = "wasm32"))] +thread_local! { + static RUST_HIGHLIGHTER: RefCell> = + RefCell::new(RustHighlighter::new()); +} + +/// Light and dark rich-text variants for one Rust source snapshot. +#[derive(Clone, Debug)] +pub(crate) struct HighlightedCode { + light: TextContent, + dark: TextContent, +} + +impl HighlightedCode { + /// Returns the rich content matching `dark` mode. + #[must_use] + pub(crate) fn content(&self, dark: bool) -> TextContent { + if dark { + self.dark.clone() + } else { + self.light.clone() + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +struct RustHighlighter { + engine: Highlighter, + config: HighlightConfiguration, +} + +#[cfg(not(target_arch = "wasm32"))] +impl RustHighlighter { + /// Creates the reusable Rust parser and configured highlight query. + fn new() -> Option { + let mut config = HighlightConfiguration::new( + tree_sitter_rust::LANGUAGE.into(), + "rust", + tree_sitter_rust::HIGHLIGHTS_QUERY, + tree_sitter_rust::INJECTIONS_QUERY, + "", + ) + .ok()?; + config.configure(HIGHLIGHT_NAMES); + Some(Self { + engine: Highlighter::new(), + config, + }) + } + + /// Parses `source` once and returns styled source fragments. + fn highlight(&mut self, source: &str) -> Option)>> { + let events = self + .engine + .highlight(&self.config, source.as_bytes(), None, |_| None) + .ok()?; + let mut stack = Vec::new(); + let mut fragments = Vec::new(); + for event in events { + match event.ok()? { + HighlightEvent::HighlightStart(highlight) => stack.push(highlight.0), + HighlightEvent::HighlightEnd => { + stack.pop(); + } + HighlightEvent::Source { start, end } => { + fragments.push((start, end, stack.last().copied())); + } + } + } + Some(fragments) + } +} + +/// Highlights Rust `source` for both supported themes, or returns a plain-text fallback. +#[must_use] +pub(crate) fn highlight(path: &str, source: &str) -> Option { + if !path.ends_with(".rs") || source.len() > MAX_HIGHLIGHT_BYTES { + return None; + } + fragments(source).map(|fragments| HighlightedCode { + light: content(source, &fragments, false), + dark: content(source, &fragments, true), + }) +} + +/// Returns parsed source fragments using Tree-sitter on native platforms. +#[cfg(not(target_arch = "wasm32"))] +fn fragments(source: &str) -> Option)>> { + RUST_HIGHLIGHTER.with(|highlighter| highlighter.borrow_mut().as_mut()?.highlight(source)) +} + +/// Returns dependency-free Rust lexical fragments for the WebAssembly build. +#[cfg(target_arch = "wasm32")] +fn fragments(source: &str) -> Option)>> { + Some(lex_rust(source)) +} + +/// Tokenizes enough Rust syntax to preserve highlighting in dependency-free Wasm builds. +#[cfg(target_arch = "wasm32")] +fn lex_rust(source: &str) -> Vec<(usize, usize, Option)> { + let bytes = source.as_bytes(); + let mut output = Vec::new(); + let mut index = 0; + while index < bytes.len() { + let start = index; + let capture = if source[start..].starts_with("//") { + index = source[start..] + .find('\n') + .map_or(bytes.len(), |offset| start + offset); + capture_index("comment") + } else if source[start..].starts_with("/*") { + index = source[start + 2..] + .find("*/") + .map_or(bytes.len(), |offset| start + 2 + offset + 2); + capture_index("comment") + } else if bytes[index] == b'"' { + index = quoted_end(bytes, index, b'"'); + capture_index("string") + } else if bytes[index] == b'\'' { + index = quoted_end(bytes, index, b'\''); + capture_index(if index.saturating_sub(start) > 1 { + "string" + } else { + "label" + }) + } else if bytes[index].is_ascii_digit() { + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'_' | b'.')) + { + index += 1; + } + capture_index("number") + } else if bytes[index].is_ascii_alphabetic() || bytes[index] == b'_' { + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') + { + index += 1; + } + identifier_capture(&source[start..index], &source[index..]) + } else if bytes[index].is_ascii_whitespace() { + index += 1; + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + None + } else { + let character = source[index..] + .chars() + .next() + .expect("lexer index must stay on a UTF-8 boundary"); + index += character.len_utf8(); + match character { + '(' | ')' | '[' | ']' | '{' | '}' => capture_index("punctuation.bracket"), + ',' | ';' | ':' | '.' => capture_index("punctuation.delimiter"), + '+' | '-' | '*' | '/' | '%' | '=' | '!' | '&' | '|' | '^' | '<' | '>' => { + capture_index("operator") + } + _ => None, + } + }; + push_fragment(&mut output, start, index, capture); + } + output +} + +/// Finds the end of a simple escaped Rust string or character literal. +#[cfg(target_arch = "wasm32")] +fn quoted_end(bytes: &[u8], start: usize, quote: u8) -> usize { + let mut index = start + 1; + while index < bytes.len() { + if bytes[index] == b'\\' { + index = (index + 2).min(bytes.len()); + } else if bytes[index] == quote { + return index + 1; + } else { + index += 1; + } + } + bytes.len() +} + +/// Maps an identifier and following source to a syntax capture. +#[cfg(target_arch = "wasm32")] +fn identifier_capture(identifier: &str, rest: &str) -> Option { + if matches!(identifier, "true" | "false") { + return capture_index("boolean"); + } + if matches!( + identifier, + "as" | "async" + | "await" + | "break" + | "const" + | "continue" + | "crate" + | "dyn" + | "else" + | "enum" + | "extern" + | "fn" + | "for" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "match" + | "mod" + | "move" + | "mut" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "type" + | "unsafe" + | "use" + | "where" + | "while" + ) { + return capture_index("keyword"); + } + if matches!( + identifier, + "bool" + | "char" + | "f32" + | "f64" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "str" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + ) { + return capture_index("type.builtin"); + } + let next = rest.trim_start().as_bytes().first().copied(); + if next == Some(b'!') { + capture_index("function.macro") + } else if next == Some(b'(') { + capture_index("function") + } else if identifier.chars().next().is_some_and(char::is_uppercase) { + capture_index("type") + } else { + capture_index("variable") + } +} + +/// Coalesces adjacent fragments with the same capture to keep rich text compact. +#[cfg(target_arch = "wasm32")] +fn push_fragment( + output: &mut Vec<(usize, usize, Option)>, + start: usize, + end: usize, + capture: Option, +) { + if let Some((_, previous_end, previous_capture)) = output.last_mut() + && *previous_end == start + && *previous_capture == capture + { + *previous_end = end; + } else { + output.push((start, end, capture)); + } +} + +/// Resolves one known capture name to its stable style index. +#[cfg(target_arch = "wasm32")] +fn capture_index(name: &str) -> Option { + HIGHLIGHT_NAMES + .iter() + .position(|candidate| *candidate == name) +} + +/// Converts parsed source fragments into Argui rich-text spans. +fn content(source: &str, fragments: &[(usize, usize, Option)], dark: bool) -> TextContent { + TextContent::rich(fragments.iter().map(|&(start, end, highlight)| { + let span = TextSpan::new(&source[start..end]); + highlight.map_or(span.clone(), |highlight| { + span.style(TextSpanStyle::default().color(color(HIGHLIGHT_NAMES[highlight], dark))) + }) + })) +} + +/// Returns a VS Code-inspired color for one Tree-sitter capture name. +fn color(name: &str, dark: bool) -> Color { + let rgb = if dark { + match name { + "comment" | "comment.documentation" => (106, 153, 85), + "string" | "string.special" | "escape" => (206, 145, 120), + "number" | "boolean" => (181, 206, 168), + "type" | "type.builtin" | "constructor" => (78, 201, 176), + "function" | "function.builtin" | "function.method" | "function.macro" => { + (220, 220, 170) + } + "variable" | "variable.builtin" | "variable.parameter" | "property" => (156, 220, 254), + "constant" | "constant.builtin" => (79, 193, 255), + "attribute" => (197, 134, 192), + "keyword" | "keyword.control" | "keyword.function" | "keyword.operator" => { + (197, 134, 192) + } + _ => (212, 212, 212), + } + } else { + match name { + "comment" | "comment.documentation" => (0, 128, 0), + "string" | "string.special" | "escape" => (163, 21, 21), + "number" | "boolean" => (9, 134, 88), + "type" | "type.builtin" | "constructor" => (38, 127, 153), + "function" | "function.builtin" | "function.method" | "function.macro" => (121, 94, 38), + "variable" | "variable.builtin" | "variable.parameter" | "property" => (0, 16, 128), + "constant" | "constant.builtin" => (0, 112, 193), + "attribute" => (128, 64, 0), + "keyword" | "keyword.control" | "keyword.function" | "keyword.operator" => { + (175, 0, 219) + } + _ => (31, 31, 31), + } + }; + Color::from_srgb8(rgb.0, rgb.1, rgb.2) +} diff --git a/app_examples/astra-editor/src/ui.rs b/app_examples/astra-editor/src/ui.rs new file mode 100644 index 00000000..74b63d08 --- /dev/null +++ b/app_examples/astra-editor/src/ui.rs @@ -0,0 +1,45 @@ +use argui::widgets::WidgetTheme; +use argui::{ + core::Color, + text::{EllipsisPosition, TextColor, TextOverflow, TextStyle, TextWrap}, + ui::{Element, ScrollEffect}, +}; +use argui_effects::EdgeShadow; + +/// Builds a single-line text element for compact application chrome. +#[must_use] +pub(crate) fn label( + value: impl Into, + size: f32, + color: impl Into, + weight: u16, +) -> Element { + Element::text(value.into()).text_style(TextStyle { + font_size: size, + line_height: size * 1.35, + color: color.into(), + weight, + wrap: TextWrap::None, + overflow: TextOverflow::Ellipsis(EllipsisPosition::End), + ..TextStyle::default() + }) +} + +/// Returns the editor accent blue with the requested alpha. +#[must_use] +pub(crate) fn accent(alpha: f32) -> Color { + Color::from_srgb8(43, 110, 242).with_alpha(alpha) +} + +/// Returns a subtle GPU edge shadow bound to the current scroll offset. +#[must_use] +pub(crate) fn scroll_shadow(theme: &WidgetTheme) -> ScrollEffect { + let alpha = if theme.foreground.relative_luminance() > 0.5 { + 0.07 + } else { + 0.18 + }; + EdgeShadow::new(14.0, theme.foreground.with_alpha(alpha)) + .intensity(0.9) + .scroll() +} diff --git a/app_examples/astra-editor/src/workspace.rs b/app_examples/astra-editor/src/workspace.rs new file mode 100644 index 00000000..129105e4 --- /dev/null +++ b/app_examples/astra-editor/src/workspace.rs @@ -0,0 +1,477 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +mod document; +pub use document::Document; + +#[cfg(not(target_arch = "wasm32"))] +const MAX_PROJECT_FILES: usize = 8_000; +#[cfg(not(target_arch = "wasm32"))] +const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; + +/// Kind and optional document target represented by one project-tree row. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EntryKind { + /// A collapsible directory row. + Directory, + /// An editable file with its document index. + File(usize), +} + +/// One preorder row in the project navigator. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectEntry { + /// Stable path key shared with the tree widget. + pub key: String, + /// Final path component displayed to the user. + pub label: String, + /// Zero-based nesting level. + pub depth: usize, + /// Directory or document target represented by this row. + pub kind: EntryKind, +} + +/// A single line match returned by the in-memory project index. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TextMatch { + /// Matching document index. + pub document: usize, + /// One-based source line. + pub line: usize, + /// Trimmed line preview. + pub preview: String, +} + +/// Searchable project state shared by the explorer, tabs, and editor. +#[derive(Clone, Debug)] +pub struct Project { + /// Short root name shown in the toolbar. + pub name: String, + /// Native project root, absent for the bundled and browser workspaces. + pub root: Option, + /// Preorder tree rows. + pub entries: Vec, + /// Editable text documents. + pub documents: Vec, +} + +impl Project { + /// Creates the instant-start bundled Rust workspace used on every platform. + #[must_use] + pub fn demo() -> Self { + Self::from_documents( + "hello-argui", + [ + ( + "src/main.rs".into(), + r#"use argui::{prelude::*, widgets::{Button, shadcn}}; + +#[derive(Default)] +struct Counter { + value: u32, +} + +impl Render for Counter { + fn render(&mut self, cx: &mut Context) -> Element { + let themes = shadcn(cx.environment()); + let theme = themes.resolve(cx.environment().color_scheme); + Element::column([ + Element::text(format!("Count: {}", self.value)), + Button::new("increment", "Increment", theme.button()) + .on_click(cx.callback(|app| app.value += 1)) + .build(), + ]) + .gap(12.0) + } +} + +fn main() -> Result<(), Box> { + argui::runtime::run_app(Counter::default())?; + Ok(()) +} +"# + .into(), + ), + ( + "src/theme.rs".into(), + r#"use argui::core::Color; + +pub const ACCENT: Color = Color::from_srgb8(43, 110, 242); +pub const EDITOR_RADIUS: f32 = 12.0; +"# + .into(), + ), + ( + "tests/counter.rs".into(), + r#"use argui_testing::TestApp; + +#[test] +fn increments_from_the_real_button() { + let mut app = TestApp::new(Counter::default()); + app.click("increment").unwrap(); + app.assert_text("Count: 1"); +} +"# + .into(), + ), + ( + "Cargo.toml".into(), + r#"[package] +name = "hello-argui" +version = "0.1.0" +edition = "2024" + +[dependencies] +argui = { version = "0.3", features = ["widget-button"] } +"# + .into(), + ), + ( + "README.md".into(), + "# Hello Argui\n\nA tiny, native and WebAssembly counter application.\n".into(), + ), + ], + ) + } + + /// Builds a project from slash-separated in-memory files. + /// + /// `name` is the project label and `files` contains relative paths with UTF-8 text. + #[must_use] + pub fn from_documents( + name: impl Into, + files: impl IntoIterator, + ) -> Self { + Self::from_documents_inner(name, files, true) + } + + /// Builds imported project state and defers syntax work until documents are opened. + #[cfg(target_arch = "wasm32")] + pub(crate) fn from_imported_documents( + name: impl Into, + files: impl IntoIterator, + ) -> Self { + Self::from_documents_inner(name, files, false) + } + + /// Builds project state while deferring syntax work until a file is opened. + fn from_documents_inner( + name: impl Into, + files: impl IntoIterator, + highlight: bool, + ) -> Self { + let mut root = VirtualNode::default(); + let mut documents = Vec::new(); + for (path, content) in files { + let path = normalize_path(&path); + if path.is_empty() { + continue; + } + let id = documents.len(); + documents.push(Document::new(id, path.clone(), content, highlight)); + root.insert(&path, id); + } + let mut entries = Vec::new(); + root.emit("", 0, &mut entries); + Self { + name: name.into(), + root: None, + entries, + documents, + } + } + + /// Returns the document represented by a tree key, if the row is a file. + #[must_use] + pub fn document_for_key(&self, key: &str) -> Option { + self.entries.iter().find_map(|entry| { + (entry.key == key) + .then_some(entry.kind) + .and_then(|kind| match kind { + EntryKind::File(document) => Some(document), + EntryKind::Directory => None, + }) + }) + } + + /// Reports whether a tree key represents a directory. + #[must_use] + pub fn is_directory(&self, key: &str) -> bool { + self.entries + .iter() + .any(|entry| entry.key == key && entry.kind == EntryKind::Directory) + } + + /// Searches cached lowercase document text and returns at most `limit` line matches. + #[must_use] + pub fn search(&self, query: &str, limit: usize) -> Vec { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return Vec::new(); + } + let mut matches = Vec::new(); + for document in &self.documents { + let searchable_content = document.searchable_content(); + for (index, (line, searchable)) in document + .content + .lines() + .zip(searchable_content.lines()) + .enumerate() + { + if searchable.contains(&query) { + matches.push(TextMatch { + document: document.id, + line: index + 1, + preview: line.trim().to_owned(), + }); + if matches.len() == limit { + return matches; + } + } + } + } + matches + } + + /// Returns document indices whose paths contain `query`, capped at `limit`. + #[must_use] + pub fn matching_files(&self, query: &str, limit: usize) -> Vec { + let query = query.trim().to_lowercase(); + self.documents + .iter() + .filter(|document| query.is_empty() || document.path.to_lowercase().contains(&query)) + .take(limit) + .map(|document| document.id) + .collect() + } + + /// Loads all supported UTF-8 text files below a native directory. + /// + /// `root` is the selected project folder and `cancelled` is checked between entries. + /// + /// # Errors + /// + /// Returns an error when the root cannot be read or does not contain readable text files. + #[cfg(not(target_arch = "wasm32"))] + pub fn from_path( + root: PathBuf, + cancelled: impl Fn() -> bool, + ) -> Result { + let metadata = std::fs::metadata(&root).map_err(ProjectLoadError::ReadRoot)?; + if !metadata.is_dir() { + return Err(ProjectLoadError::NotDirectory(root)); + } + let mut files = Vec::new(); + collect_files(&root, &root, &cancelled, &mut files)?; + if files.is_empty() { + return Err(ProjectLoadError::NoTextFiles); + } + let name = root.file_name().map_or_else( + || root.display().to_string(), + |name| name.to_string_lossy().into(), + ); + let mut project = Self::from_documents_inner(name, files, false); + for document in &mut project.documents { + document.absolute_path = Some(root.join(&document.path)); + } + project.root = Some(root); + Ok(project) + } +} + +/// Failure to load a selected native project folder. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug)] +pub enum ProjectLoadError { + /// The selected path could not be inspected. + ReadRoot(std::io::Error), + /// A directory in the selected tree could not be enumerated. + ReadDirectory(std::io::Error), + /// The selected path is not a directory. + NotDirectory(PathBuf), + /// No supported UTF-8 text file was found. + NoTextFiles, +} + +#[cfg(not(target_arch = "wasm32"))] +impl std::fmt::Display for ProjectLoadError { + /// Formats a user-facing project-loading diagnostic. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ReadRoot(error) => write!(formatter, "could not inspect the folder: {error}"), + Self::ReadDirectory(error) => { + write!(formatter, "could not read part of the project: {error}") + } + Self::NotDirectory(path) => write!(formatter, "{} is not a folder", path.display()), + Self::NoTextFiles => formatter.write_str("no supported UTF-8 text files were found"), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl std::error::Error for ProjectLoadError {} + +#[derive(Default)] +struct VirtualNode { + document: Option, + children: BTreeMap, +} + +impl VirtualNode { + /// Inserts one slash-separated document path into the temporary tree. + fn insert(&mut self, path: &str, document: usize) { + let mut node = self; + for part in path.split('/').filter(|part| !part.is_empty()) { + node = node.children.entry(part.to_owned()).or_default(); + } + node.document = Some(document); + } + + /// Emits preorder directory and file rows below `prefix` at `depth`. + fn emit(&self, prefix: &str, depth: usize, output: &mut Vec) { + for (name, node) in &self.children { + if node.document.is_some() && node.children.is_empty() { + continue; + } + let path = join_path(prefix, name); + output.push(ProjectEntry { + key: path.clone(), + label: name.clone(), + depth, + kind: EntryKind::Directory, + }); + node.emit(&path, depth + 1, output); + } + for (name, node) in &self.children { + let Some(document) = node.document else { + continue; + }; + let path = join_path(prefix, name); + output.push(ProjectEntry { + key: path, + label: name.clone(), + depth, + kind: EntryKind::File(document), + }); + } + } +} + +/// Normalizes a user- or browser-supplied relative path to slash separators. +fn normalize_path(path: &str) -> String { + path.replace('\\', "/") + .split('/') + .filter(|part| !part.is_empty() && *part != ".") + .collect::>() + .join("/") +} + +/// Joins a normalized project prefix and one child name. +fn join_path(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}/{name}") + } +} + +/// Recursively collects supported UTF-8 files while honoring project limits. +#[cfg(not(target_arch = "wasm32"))] +fn collect_files( + root: &std::path::Path, + directory: &std::path::Path, + cancelled: &impl Fn() -> bool, + output: &mut Vec<(String, String)>, +) -> Result<(), ProjectLoadError> { + if cancelled() || output.len() >= MAX_PROJECT_FILES { + return Ok(()); + } + let mut entries = std::fs::read_dir(directory) + .map_err(ProjectLoadError::ReadDirectory)? + .filter_map(Result::ok) + .collect::>(); + entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_lowercase()); + for entry in entries { + if cancelled() || output.len() >= MAX_PROJECT_FILES { + break; + } + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_symlink() || ignored_name(&name) { + continue; + } + if kind.is_dir() { + collect_files(root, &path, cancelled, output)?; + continue; + } + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !kind.is_file() || metadata.len() > MAX_FILE_BYTES || !supported_file(&path) { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(content) = String::from_utf8(bytes) else { + continue; + }; + let Ok(relative) = path.strip_prefix(root) else { + continue; + }; + output.push((normalize_path(&relative.to_string_lossy()), content)); + } + Ok(()) +} + +/// Returns whether a directory name should be pruned from project discovery. +#[cfg(not(target_arch = "wasm32"))] +fn ignored_name(name: &str) -> bool { + matches!( + name, + ".git" | ".idea" | ".vscode" | "target" | "node_modules" | "dist" | "build" + ) +} + +/// Returns whether a path is expected to contain editable project text. +#[cfg(not(target_arch = "wasm32"))] +fn supported_file(path: &std::path::Path) -> bool { + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + matches!( + extension.as_str(), + "rs" | "toml" + | "md" + | "txt" + | "json" + | "yaml" + | "yml" + | "ron" + | "js" + | "jsx" + | "ts" + | "tsx" + | "css" + | "html" + | "htm" + | "wgsl" + | "sh" + | "bash" + | "zsh" + | "py" + | "go" + | "c" + | "h" + | "cpp" + | "hpp" + ) || path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| matches!(name, "Makefile" | "Dockerfile" | "LICENSE" | "NOTICE")) +} diff --git a/app_examples/astra-editor/src/workspace/document.rs b/app_examples/astra-editor/src/workspace/document.rs new file mode 100644 index 00000000..676fe0ec --- /dev/null +++ b/app_examples/astra-editor/src/workspace/document.rs @@ -0,0 +1,167 @@ +use std::{borrow::Cow, path::PathBuf}; + +use argui::ui::{TextEdit, TextEditError}; + +use crate::syntax::{self, HighlightedCode}; + +/// One editable text document in the active workspace. +#[derive(Clone, Debug)] +pub struct Document { + /// Stable index used by tabs and search results. + pub id: usize, + /// Slash-separated path relative to the project root. + pub path: String, + /// Current controlled editor value. + pub content: String, + saved_content: String, + dirty: bool, + pub(super) search_content: String, + revision: u64, + search_revision: u64, + highlight_revision: Option, + highlighted: Option, + /// Native destination when this document came from a folder scan. + pub absolute_path: Option, +} + +impl Document { + /// Creates a document and optionally computes its initial syntax styling. + pub(super) fn new(id: usize, path: String, content: String, highlight: bool) -> Self { + let highlighted = highlight + .then(|| syntax::highlight(&path, &content)) + .flatten(); + Self { + id, + path, + search_content: content.to_lowercase(), + saved_content: content.clone(), + dirty: false, + revision: 0, + search_revision: 0, + highlight_revision: highlight.then_some(0), + highlighted, + content, + absolute_path: None, + } + } + + /// Returns the final path component displayed in tabs. + #[must_use] + pub fn name(&self) -> &str { + self.path.rsplit('/').next().unwrap_or(&self.path) + } + + /// Returns the lowercase extension used for the editor language label. + #[must_use] + pub fn extension(&self) -> &str { + self.name() + .rsplit_once('.') + .map_or("text", |(_, extension)| extension) + } + + /// Returns a concise uppercase language label for the status bar. + #[must_use] + pub fn language_label(&self) -> &'static str { + match self.extension() { + "rs" => "RUST", + "toml" => "TOML", + "md" => "MARKDOWN", + "json" => "JSON", + "js" | "jsx" => "JAVASCRIPT", + "ts" | "tsx" => "TYPESCRIPT", + "css" => "CSS", + "html" => "HTML", + "yaml" | "yml" => "YAML", + _ => "PLAIN TEXT", + } + } + + /// Returns the current number of logical lines, including one empty line for an empty file. + #[must_use] + pub fn line_count(&self) -> usize { + self.content.lines().count().max(1) + } + + /// Reports whether the controlled value differs from the last saved snapshot. + #[must_use] + pub const fn is_dirty(&self) -> bool { + self.dirty + } + + /// Applies one accepted editor replacement and invalidates derived data. + /// + /// Returns an error if `edit` does not address valid UTF-8 boundaries in the + /// document's current revision. The document remains unchanged on error. + pub fn apply_edit(&mut self, edit: &TextEdit) -> Result<(), TextEditError> { + edit.apply_to(&mut self.content)?; + self.revision = self.revision.wrapping_add(1); + self.dirty = + self.content.len() != self.saved_content.len() || self.content != self.saved_content; + Ok(()) + } + + /// Returns an owned snapshot suitable for deferred derived-data work. + pub(crate) fn derived_snapshot(&self) -> (u64, String, String) { + (self.revision, self.path.clone(), self.content.clone()) + } + + /// Applies derived data only when `revision` still identifies the current text. + pub(crate) fn apply_derived( + &mut self, + revision: u64, + search_content: String, + highlighted: Option, + ) -> bool { + if revision != self.revision { + return false; + } + self.search_content = search_content; + self.search_revision = revision; + self.highlighted = highlighted; + self.highlight_revision = Some(revision); + true + } + + /// Reports whether search or syntax data still needs refreshing. + pub(crate) fn needs_derived_refresh(&self) -> bool { + self.search_revision != self.revision || self.highlight_revision != Some(self.revision) + } + + /// Returns lowercase text from the cache or derives it for an outstanding edit. + pub(super) fn searchable_content(&self) -> Cow<'_, str> { + if self.search_revision == self.revision { + Cow::Borrowed(&self.search_content) + } else { + Cow::Owned(self.content.to_lowercase()) + } + } + + /// Returns cached rich source text for the requested editor theme when current. + #[must_use] + pub fn highlighted_content(&self, dark: bool) -> Option { + (self.highlight_revision == Some(self.revision)) + .then(|| { + self.highlighted + .as_ref() + .map(|content| content.content(dark)) + }) + .flatten() + } + + /// Marks the current value as the last saved snapshot. + pub fn mark_saved(&mut self) { + self.saved_content.clone_from(&self.content); + self.dirty = false; + } + + /// Marks `content` saved only when it is still the current value. + /// + /// This prevents an asynchronous native save from clearing the dirty marker after + /// the user has already typed something newer. + pub fn mark_snapshot_saved(&mut self, content: &str) { + if self.content == content { + self.saved_content = content.to_owned(); + self.dirty = false; + } + } +} diff --git a/app_examples/astra-editor/tests/behavior.rs b/app_examples/astra-editor/tests/behavior.rs new file mode 100644 index 00000000..290cfc76 --- /dev/null +++ b/app_examples/astra-editor/tests/behavior.rs @@ -0,0 +1,141 @@ +use argui::{ + accessibility::Role, + core::{ColorScheme, Key, Modifiers, Point, ScrollDelta, Size}, +}; +use argui_example_astra_editor::{AstraEditor, workspace::Project}; +use argui_testing::TestApp; + +#[test] +fn editing_marks_the_document_dirty_and_session_save_clears_it() { + let mut app = TestApp::new(AstraEditor::default()); + + app.get_by_role(Role::TextArea, "Start writing…") + .replace_text("fn main() { println!(\"edited\"); }") + .unwrap(); + app.assert_text("Edited"); + app.click("save-document").unwrap(); + app.assert_text("Saved in the bundled workspace session"); + app.assert_text("Saved"); +} + +#[test] +fn tabs_switch_between_real_controlled_documents() { + let mut app = TestApp::new(AstraEditor::default()); + + app.click("tab::3").unwrap(); + + app.assert_text("Cargo.toml"); + app.assert_text("TOML"); + app.assert_text("[package]"); +} + +#[test] +fn global_search_opens_a_matching_source_file() { + let mut app = TestApp::new(AstraEditor::default()); + app.shortcut( + Key::Character("f".into()), + Modifiers { + control: true, + shift: true, + ..Modifiers::default() + }, + ) + .unwrap(); + assert!(app.bounds("workspace-search-panel").unwrap().size.height > 200.0); + app.get_by_role(Role::SearchInput, "Search across files") + .replace_text("EDITOR_RADIUS") + .unwrap(); + + app.assert_text("src/theme.rs"); + app.click("search-result::0").unwrap(); + app.assert_text("pub const EDITOR_RADIUS"); + app.assert_no_text("in-memory index"); +} + +#[test] +fn appearance_toggle_updates_the_runtime_color_scheme() { + let mut app = TestApp::new(AstraEditor::default()); + + app.click("toggle-theme").unwrap(); + + assert_eq!(app.environment().color_scheme, ColorScheme::Dark); +} + +#[test] +fn compact_layout_reveals_the_explorer_on_demand() { + let mut app = TestApp::new(AstraEditor::default()); + app.resize(Size::new(390.0, 760.0)).unwrap(); + app.assert_no_text("EXPLORER"); + + app.click("toggle-explorer").unwrap(); + + app.assert_text("EXPLORER"); + app.assert_text("src"); +} + +#[test] +fn control_b_toggles_the_desktop_explorer() { + let mut app = TestApp::new(AstraEditor::default()); + app.assert_text("EXPLORER"); + + app.shortcut( + Key::Character("b".into()), + Modifiers { + control: true, + ..Modifiers::default() + }, + ) + .unwrap(); + app.assert_no_text("EXPLORER"); + + app.shortcut( + Key::Character("b".into()), + Modifiers { + control: true, + ..Modifiers::default() + }, + ) + .unwrap(); + app.assert_text("EXPLORER"); +} + +#[test] +fn search_backdrop_is_modal_and_dismisses_without_clicking_through() { + let mut app = TestApp::new(AstraEditor::default()); + app.click("global-search").unwrap(); + app.assert_text("in-memory index"); + + app.click("workspace-search-backdrop").unwrap(); + + app.assert_no_text("in-memory index"); + assert_eq!(app.environment().color_scheme, ColorScheme::Light); +} + +#[test] +fn a_preloaded_highlighted_rust_file_scrolls_to_its_real_document_end() { + let source = include_str!("../../docs-examples/src/examples/custom_elements.rs"); + assert!(source.lines().count() > 500); + let project = Project::from_documents( + "argui", + [("custom_elements.rs".into(), source.to_owned())], + ); + assert!(project.documents[0].highlighted_content(false).is_some()); + let mut app = TestApp::new(AstraEditor::with_project(project)); + let editor = app.bounds("code-editor").unwrap(); + let center = Point::new( + editor.origin.x + editor.size.width * 0.5, + editor.origin.y + editor.size.height * 0.5, + ); + + app.wheel( + center, + ScrollDelta::Pixels(Point::new(0.0, -50_000.0)), + ) + .unwrap(); + + let offset = app.scroll_offset("code-editor").unwrap(); + assert!( + offset.y > 9_000.0, + "editor stopped at {offset:?} inside {editor:?}" + ); +} diff --git a/app_examples/astra-editor/tests/browser.py b/app_examples/astra-editor/tests/browser.py new file mode 100644 index 00000000..65a1489c --- /dev/null +++ b/app_examples/astra-editor/tests/browser.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Exercise Astra Editor's WebAssembly build in a private visible Chromium.""" + +import io +import os +from pathlib import Path +import time + +from PIL import Image +from playwright.sync_api import sync_playwright + + +def contrasting(png: bytes) -> bool: + """Return whether a PNG contains enough contrast to be a real rendered frame.""" + image = Image.open(io.BytesIO(png)).convert("RGB") + return max(high - low for low, high in image.getextrema()) > 100 + + +def main() -> None: + """Open, interact with, resize, and capture the browser editor.""" + assert os.environ.get("ARGUI_HIDDEN_DISPLAY") == "1" + assert "DISPLAY" not in os.environ + output = Path(os.environ.get("SCREENSHOT_DIR", "target/astra-editor-browser")) + output.mkdir(parents=True, exist_ok=True) + url = os.environ.get( + "ASTRA_EDITOR_URL", "http://127.0.0.1:8795/examples/astra-editor/" + ) + errors: list[str] = [] + + with sync_playwright() as playwright: + browser = playwright.chromium.launch( + executable_path=os.environ["CHROME_PATH"], + headless=False, + args=[ + "--ozone-platform=wayland", + "--enable-unsafe-webgpu", + "--ignore-gpu-blocklist", + "--enable-features=Vulkan", + "--use-angle=vulkan", + ], + ) + try: + page = browser.new_page(viewport={"width": 1280, "height": 800}) + page.on("pageerror", lambda error: errors.append(str(error))) + page.on( + "console", + lambda message: errors.append(message.text) + if message.type == "error" + else None, + ) + page.goto(url, wait_until="networkidle", timeout=90_000) + page.wait_for_function( + "document.querySelector('#status')?.hidden === true", timeout=90_000 + ) + canvas = page.locator("canvas") + canvas.wait_for(state="visible") + bounds = canvas.bounding_box() + assert bounds and bounds["width"] == 1280 and bounds["height"] == 800 + initial = page.screenshot(path=output / "desktop.png") + assert contrasting(initial), "blank desktop WebAssembly capture" + + canvas.focus() + page.keyboard.press("Control+b") + time.sleep(0.35) + explorer_hidden = page.screenshot(path=output / "explorer-hidden.png") + assert explorer_hidden != initial, "Ctrl+B did not change the browser frame" + page.keyboard.press("Control+b") + page.keyboard.press("Control+Shift+f") + page.get_by_role("dialog", name="Search across files").wait_for( + state="visible" + ) + page.keyboard.press("Escape") + + page.set_viewport_size({"width": 430, "height": 860}) + time.sleep(0.4) + compact = page.screenshot(path=output / "compact.png") + assert contrasting(compact), "blank compact WebAssembly capture" + assert page.evaluate("document.documentElement.scrollWidth === innerWidth") + assert errors == [], errors + finally: + browser.close() + + +if __name__ == "__main__": + main() diff --git a/app_examples/astra-editor/tests/workspace.rs b/app_examples/astra-editor/tests/workspace.rs new file mode 100644 index 00000000..62f6bfc4 --- /dev/null +++ b/app_examples/astra-editor/tests/workspace.rs @@ -0,0 +1,71 @@ +use argui::ui::TextEdit; +use argui_example_astra_editor::workspace::{EntryKind, Project}; + +#[test] +fn virtual_documents_form_a_stable_preorder_tree() { + let project = Project::from_documents( + "sample", + [ + ("src/view.rs".into(), "view".into()), + ("Cargo.toml".into(), "manifest".into()), + ("src/main.rs".into(), "main".into()), + ], + ); + + assert_eq!(project.documents.len(), 3); + assert_eq!(project.entries[0].key, "src"); + assert_eq!(project.entries[0].kind, EntryKind::Directory); + assert_eq!(project.entries[1].key, "src/main.rs"); + assert_eq!(project.entries[2].key, "src/view.rs"); + assert_eq!(project.entries[3].key, "Cargo.toml"); + assert_eq!(project.document_for_key("src/view.rs"), Some(0)); + assert!( + project.documents[0] + .highlighted_content(false) + .unwrap() + .is_rich() + ); +} + +#[test] +fn search_index_follows_controlled_edits() { + let mut project = Project::from_documents( + "sample", + [( + "src/main.rs".into(), + "fn main() {\n launch();\n}\n".into(), + )], + ); + + let matches = project.search("LAUNCH", 10); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].line, 2); + + let start = project.documents[0].content.find("launch").unwrap(); + project.documents[0] + .apply_edit(&TextEdit::new(start..start + "launch".len(), "render")) + .unwrap(); + assert!(project.search("launch", 10).is_empty()); + assert_eq!(project.search("render", 10)[0].line, 2); + assert!(project.documents[0].is_dirty()); + project.documents[0].mark_saved(); + assert!(!project.documents[0].is_dirty()); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_scan_prunes_build_outputs_and_binary_files() { + let temporary = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temporary.path().join("src")).unwrap(); + std::fs::create_dir_all(temporary.path().join("target")).unwrap(); + std::fs::write(temporary.path().join("src/lib.rs"), "pub fn render() {}\n").unwrap(); + std::fs::write(temporary.path().join("target/generated.rs"), "ignored\n").unwrap(); + std::fs::write(temporary.path().join("image.png"), [0, 159, 146, 150]).unwrap(); + + let project = Project::from_path(temporary.path().to_path_buf(), || false).unwrap(); + + assert_eq!(project.documents.len(), 1); + assert_eq!(project.documents[0].path, "src/lib.rs"); + assert_eq!(project.root.as_deref(), Some(temporary.path())); + assert!(project.documents[0].highlighted_content(false).is_none()); +} diff --git a/app_examples/docs-examples/Cargo.toml b/app_examples/docs-examples/Cargo.toml index e6cafaf3..ae546eb5 100644 --- a/app_examples/docs-examples/Cargo.toml +++ b/app_examples/docs-examples/Cargo.toml @@ -17,10 +17,16 @@ argui = { workspace = true, features = [ "tasks", "widget-button", "widget-dialog", + "widget-popover", + "widget-range", + "widget-textarea", ] } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen.workspace = true +[dev-dependencies] +argui-testing.workspace = true + [lints] workspace = true diff --git a/app_examples/docs-examples/src/examples/accessibility.rs b/app_examples/docs-examples/src/examples/accessibility.rs index c14d460a..dcf0488a 100644 --- a/app_examples/docs-examples/src/examples/accessibility.rs +++ b/app_examples/docs-examples/src/examples/accessibility.rs @@ -2,7 +2,7 @@ use argui::{ accessibility::{LiveRegion, Role, Semantics}, runtime::{Context, Render}, text::TextStyle, - ui::{Element, EventType, Sides, UiEventKind, percent}, + ui::{Element, Sides, percent}, widgets::{Button, default_theme}, }; @@ -23,25 +23,26 @@ impl Render for Example { ..TextStyle::default() }) .semantics(Semantics::new(Role::Heading).level(1)), - Button::new("announce", "Announce update", theme.button()).build(), - Element::text(status) + Button::new("announce", "Announce update", theme.button()) + .on_click(cx.callback(|app| { + app.announcements = app.announcements.saturating_add(1); + })) + .build(), + Element::text(status.clone()) .text_style(TextStyle { color: theme.foreground, ..TextStyle::default() }) - .semantics(Semantics::new(Role::Status).live(LiveRegion::Polite)), + .semantics( + Semantics::new(Role::Status) + .label(status) + .live(LiveRegion::Polite), + ), ]) .width(percent(1.0)) .height(percent(1.0)) .padding(Sides::length(28.0)) .gap(16.0) .background(theme.background) - .on(cx.listener(EventType::Click, |app, event, cx| { - if event.target_key() == Some("announce") && matches!(event.kind, UiEventKind::Click(_)) - { - app.announcements = app.announcements.saturating_add(1); - cx.notify(); - } - })) } } diff --git a/app_examples/docs-examples/src/examples/animation.rs b/app_examples/docs-examples/src/examples/animation.rs index 7c553921..c7dbdafa 100644 --- a/app_examples/docs-examples/src/examples/animation.rs +++ b/app_examples/docs-examples/src/examples/animation.rs @@ -4,8 +4,8 @@ use argui::{ runtime::{Context, LayoutSnapshot, Render}, text::TextStyle, ui::{ - AlignItems, Axes, Element, EventType, FlexWrap, JustifyContent, Overflow, ScrollConfig, - Sides, UiEventKind, length, percent, + AlignItems, Axes, Element, FlexWrap, JustifyContent, Overflow, ScrollConfig, Sides, length, + percent, }, vector::VectorLibrary, widgets::{Button, WidgetTheme, default_theme}, @@ -312,6 +312,7 @@ impl Render for Example { theme.button(), ) .enabled(!self.reduced_motion) + .on_click(cx.callback(|app| app.running = !app.running)) .build(), ]) .width(percent(1.0)) @@ -343,14 +344,6 @@ impl Render for Example { .height(percent(1.0)) .min_height(length(0.0)) .background(theme.background) - .on(cx.listener(EventType::Click, |app, event, cx| { - if event.target_key() == Some("toggle-animation") - && matches!(event.kind, UiEventKind::Click(_)) - { - app.running = !app.running; - cx.notify(); - } - })) } fn layout_changed(&mut self, layout: &LayoutSnapshot, cx: &mut Context) { diff --git a/app_examples/docs-examples/src/examples/clean_code.rs b/app_examples/docs-examples/src/examples/clean_code.rs index e6b22fa3..b548dad5 100644 --- a/app_examples/docs-examples/src/examples/clean_code.rs +++ b/app_examples/docs-examples/src/examples/clean_code.rs @@ -1,10 +1,17 @@ use argui::{ + paint::{Border, CornerRadii}, runtime::{Context, Render}, text::TextStyle, - ui::{Element, EventType, Sides, UiEventKind, percent}, + ui::{AlignItems, Element, Sides, length, percent}, widgets::{Button, default_theme}, }; +const CHECKS: [&str; 3] = [ + "State is explicit", + "Views stay pure", + "Effects have owners", +]; + #[derive(Default)] pub struct Example { completed: usize, @@ -14,21 +21,45 @@ impl Render for Example { fn render(&mut self, cx: &mut Context) -> Element { let themes = default_theme(cx.environment()); let theme = themes.resolve(cx.environment().color_scheme); - let checks = [ - "State is explicit", - "Views stay pure", - "Effects have owners", - ]; - let rows = checks.into_iter().enumerate().map(|(index, label)| { - let mark = if index < self.completed { "✓" } else { "○" }; - Element::text(format!("{mark} {label}")).text_style(TextStyle { - color: theme.foreground, - ..TextStyle::default() - }) + let rows = CHECKS.into_iter().enumerate().map(|(index, label)| { + let complete = index < self.completed; + Element::row([ + Element::text(if complete { "Done" } else { "Pending" }) + .width(length(68.0)) + .text_style(TextStyle { + color: if complete { + theme.primary + } else { + theme.muted_foreground + }, + font_size: 12.0, + weight: 700, + ..TextStyle::default() + }), + Element::text(label).text_style(TextStyle { + color: theme.foreground, + ..TextStyle::default() + }), + ]) + .width(percent(1.0)) + .align_items(AlignItems::CENTER) + .padding(Sides::length(12.0)) + .gap(12.0) + .background(theme.card) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(8.0)) }); + let next_label = if self.completed == CHECKS.len() { + "All complete".to_owned() + } else { + format!("Complete next ({} of {})", self.completed + 1, CHECKS.len()) + }; Element::column( - rows.chain([Button::new("next", "Complete next", theme.button()) - .enabled(self.completed < checks.len()) + rows.chain([Button::new("next", next_label, theme.button()) + .enabled(self.completed < CHECKS.len()) + .on_click(cx.callback(|app| { + app.completed = (app.completed + 1).min(CHECKS.len()); + })) .build()]), ) .width(percent(1.0)) @@ -36,11 +67,5 @@ impl Render for Example { .padding(Sides::length(28.0)) .gap(14.0) .background(theme.background) - .on(cx.listener(EventType::Click, |app, event, cx| { - if event.target_key() == Some("next") && matches!(event.kind, UiEventKind::Click(_)) { - app.completed = (app.completed + 1).min(3); - cx.notify(); - } - })) } } diff --git a/app_examples/docs-examples/src/examples/counter.rs b/app_examples/docs-examples/src/examples/counter.rs index 1972257e..64032283 100644 --- a/app_examples/docs-examples/src/examples/counter.rs +++ b/app_examples/docs-examples/src/examples/counter.rs @@ -1,7 +1,7 @@ use argui::{ runtime::{Context, Render}, text::TextStyle, - ui::{AlignItems, Element, EventType, Sides, UiEventKind, percent}, + ui::{AlignItems, Element, Sides, percent}, widgets::{Button, default_theme}, }; @@ -22,7 +22,9 @@ impl Render for Example { weight: 700, ..TextStyle::default() }), - Button::new("increment", "Increment", theme.button()).build(), + Button::new("increment", "Increment", theme.button()) + .on_click(cx.callback(|app| app.count = app.count.saturating_add(1))) + .build(), ]) .width(percent(1.0)) .height(percent(1.0)) @@ -30,13 +32,5 @@ impl Render for Example { .padding(Sides::length(28.0)) .gap(18.0) .background(theme.background) - .on(cx.listener(EventType::Click, |app, event, cx| { - if event.target_key() == Some("increment") - && matches!(event.kind, UiEventKind::Click(_)) - { - app.count = app.count.saturating_add(1); - cx.notify(); - } - })) } } diff --git a/app_examples/docs-examples/src/examples/custom_elements.rs b/app_examples/docs-examples/src/examples/custom_elements.rs index 651ca792..8a5caac3 100644 --- a/app_examples/docs-examples/src/examples/custom_elements.rs +++ b/app_examples/docs-examples/src/examples/custom_elements.rs @@ -1,75 +1,528 @@ use argui::{ - core::{Color, Point, Rect, Size}, - paint::QuadStyle, + accessibility::{Role, SemanticAction, SemanticValue, Semantics}, + core::{Color, Key, KeyState, Point, Rect, Size}, + paint::{Border, CornerRadii, QuadStyle}, runtime::{Context, Render}, + text::{TextStyle, TextWrap}, ui::{ - CustomElement, CustomLayoutContext, CustomMeasurement, CustomPaintContext, Element, Sides, - length, percent, + Axes, CursorIcon, CustomElement, CustomLayoutContext, CustomMeasurement, + CustomPaintContext, Element, EventType, FocusPolicy, GestureCapture, GestureKind, + GesturePhase, GestureSet, HitTestStyle, Interaction, Overflow, PanGesture, Sides, + UiEventKind, UserSelect, length, percent, sides, }, - widgets::default_theme, + widgets::{Button, default_theme}, }; +const DURATION: f32 = 20.0; +const PIXELS_PER_SECOND: f32 = 32.0; +const GUTTER: f32 = 76.0; +const RULER_HEIGHT: f32 = 32.0; +const TRACK_HEIGHT: f32 = 58.0; +const TIMELINE_WIDTH: f32 = GUTTER + DURATION * PIXELS_PER_SECOND + 48.0; +const TIME_LABELS: usize = 5; +const TRACK_LABELS: usize = 3; + +#[derive(Clone, Copy, Debug)] +struct Clip { + label: &'static str, + start: f32, + duration: f32, + track: usize, +} + +const CLIPS: [Clip; 5] = [ + Clip { + label: "Intro", + start: 0.0, + duration: 5.0, + track: 0, + }, + Clip { + label: "Interview", + start: 5.0, + duration: 9.0, + track: 0, + }, + Clip { + label: "B-roll", + start: 14.0, + duration: 6.0, + track: 0, + }, + Clip { + label: "Voiceover", + start: 2.5, + duration: 11.0, + track: 1, + }, + Clip { + label: "Music bed", + start: 0.0, + duration: 20.0, + track: 2, + }, +]; + #[derive(Debug)] -struct Ruler { +struct EditorTimeline { background: Color, - ticks: Color, + track: Color, + grid: Color, + playhead: Color, + seconds: f32, + clips: [Clip; 5], } -impl CustomElement for Ruler { +impl CustomElement for EditorTimeline { type State = Vec; + fn create_state(&self) -> Self::State { Vec::new() } + fn layout_revision(&self) -> u64 { - 0 + self.clips + .iter() + .fold(u64::from(self.seconds.to_bits()), |revision, clip| { + revision + .wrapping_mul(31) + .wrapping_add(u64::from(clip.start.to_bits())) + .wrapping_mul(31) + .wrapping_add(clip.track as u64) + }) } + fn paint_revision(&self) -> u64 { - 0 + [self.background, self.track, self.grid, self.playhead] + .into_iter() + .fold(u64::from(self.seconds.to_bits()), |revision, color| { + revision + .wrapping_mul(31) + .wrapping_add(u64::from(u32::from_le_bytes(color.to_srgba8()))) + }) } + fn layout( &self, _: &mut Self::State, - _: &mut dyn CustomLayoutContext, + cx: &mut dyn CustomLayoutContext, ) -> Result { + for index in 0..TIME_LABELS { + cx.place_child( + index, + Rect::new( + Point::new(GUTTER + index as f32 * 5.0 * PIXELS_PER_SECOND + 4.0, 7.0), + Size::new(42.0, 20.0), + ), + )?; + } + for index in 0..TRACK_LABELS { + cx.place_child( + TIME_LABELS + index, + Rect::new( + Point::new(10.0, RULER_HEIGHT + index as f32 * TRACK_HEIGHT + 18.0), + Size::new(56.0, 20.0), + ), + )?; + } + for (index, clip) in self.clips.into_iter().enumerate() { + cx.place_child( + TIME_LABELS + TRACK_LABELS + index, + Rect::new( + Point::new( + GUTTER + clip.start * PIXELS_PER_SECOND, + RULER_HEIGHT + clip.track as f32 * TRACK_HEIGHT + 6.0, + ), + Size::new(clip.duration * PIXELS_PER_SECOND, TRACK_HEIGHT - 12.0), + ), + )?; + } + cx.place_child( + TIME_LABELS + TRACK_LABELS + CLIPS.len(), + Rect::new( + Point::new(GUTTER + self.seconds * PIXELS_PER_SECOND - 12.0, 0.0), + Size::new(24.0, RULER_HEIGHT + TRACK_LABELS as f32 * TRACK_HEIGHT), + ), + )?; Ok(CustomMeasurement { - size: Size::new(720.0, 180.0), + size: Size::new( + TIMELINE_WIDTH, + RULER_HEIGHT + TRACK_LABELS as f32 * TRACK_HEIGHT, + ), baseline: None, }) } - fn prepare(&self, ticks: &mut Self::State, size: Size) { + + fn prepare(&self, ticks: &mut Self::State, _: Size) { ticks.clear(); - ticks.extend((0..=(size.width / 40.0) as usize).map(|tick| tick as f32 * 40.0)); + ticks.extend( + (0..=DURATION as usize).map(|second| GUTTER + second as f32 * PIXELS_PER_SECOND), + ); } + fn paint(&self, ticks: &mut Self::State, cx: &mut CustomPaintContext<'_>) { cx.quad( Rect::new(Point::default(), cx.bounds.size), QuadStyle::solid(self.background), ); - for (index, x) in ticks.iter().copied().enumerate() { - let height = if index.is_multiple_of(5) { 52.0 } else { 24.0 }; + for track in 0..TRACK_LABELS { cx.quad( - Rect::new(Point::new(x, 0.0), Size::new(2.0, height)), - QuadStyle::solid(self.ticks), + Rect::new( + Point::new(0.0, RULER_HEIGHT + track as f32 * TRACK_HEIGHT + 2.0), + Size::new(cx.bounds.size.width, TRACK_HEIGHT - 4.0), + ), + QuadStyle::solid(self.track).radius(CornerRadii::all(6.0)), ); } + for (second, x) in ticks.iter().copied().enumerate() { + cx.quad( + Rect::new( + Point::new( + x, + if second.is_multiple_of(5) { + 24.0 + } else { + RULER_HEIGHT + }, + ), + Size::new( + if second.is_multiple_of(5) { 1.5 } else { 1.0 }, + cx.bounds.size.height - RULER_HEIGHT, + ), + ), + QuadStyle::solid(self.grid), + ); + } + let playhead_x = GUTTER + self.seconds * PIXELS_PER_SECOND; + cx.quad( + Rect::new( + Point::new(playhead_x - 1.0, 10.0), + Size::new(2.0, cx.bounds.size.height - 10.0), + ), + QuadStyle::solid(self.playhead), + ); + cx.quad( + Rect::new(Point::new(playhead_x - 6.0, 4.0), Size::new(12.0, 12.0)), + QuadStyle::solid(self.playhead).radius(CornerRadii::all(3.0)), + ); } } -pub struct Example; +pub struct Example { + clips: [Clip; 5], + selected_clip: usize, + clip_drag_start: Clip, + playhead: f32, + playhead_drag_start: f32, +} + +impl Default for Example { + fn default() -> Self { + Self { + clips: CLIPS, + selected_clip: 0, + clip_drag_start: CLIPS[0], + playhead: 7.5, + playhead_drag_start: 7.5, + } + } +} impl Render for Example { fn render(&mut self, cx: &mut Context) -> Element { let themes = default_theme(cx.environment()); let theme = themes.resolve(cx.environment().color_scheme); - Element::container([Element::custom(Ruler { - background: theme.card, - ticks: theme.primary, - }) + let text = |value: String, size: f32, weight: u16, color: Color| { + Element::text(value).text_style(TextStyle { + color, + font_size: size, + weight, + ..TextStyle::default() + }) + }; + let mut children: Vec = (0..TIME_LABELS) + .map(|index| { + text( + format!("0:{:02}", index * 5), + 11.0, + 600, + theme.muted_foreground, + ) + }) + .collect(); + children.extend( + ["VIDEO 1", "VIDEO 2", "AUDIO"] + .into_iter() + .map(|label| text(label.to_owned(), 11.0, 700, theme.muted_foreground)), + ); + let clip_colors = [ + Color::from_srgb8(37, 99, 235), + Color::from_srgb8(124, 58, 237), + Color::from_srgb8(8, 145, 178), + Color::from_srgb8(219, 39, 119), + Color::from_srgb8(5, 150, 105), + ]; + children.extend(self.clips.into_iter().zip(clip_colors).enumerate().map( + |(index, (clip, color))| { + let selected = self.selected_clip == index; + Element::column([ + Element::text(clip.label).text_style(TextStyle { + color: Color::WHITE, + font_size: 13.0, + line_height: 16.0, + weight: 700, + wrap: TextWrap::None, + ..TextStyle::default() + }), + Element::text(format!("{:.1}s", clip.duration)).text_style(TextStyle { + color: Color::WHITE.with_alpha(0.82), + font_size: 10.0, + line_height: 12.0, + weight: 500, + wrap: TextWrap::None, + ..TextStyle::default() + }), + ]) + .keyed(format!("editor-clip-{index}")) + .gap(2.0) + .padding(sides(8.0, 4.0)) + .background(color) + .border(Border::all( + if selected { 2.0 } else { 1.0 }, + if selected { + theme.ring + } else { + Color::WHITE.with_alpha(0.25) + }, + )) + .radius(CornerRadii::all(6.0)) + .overflow(Axes { + x: Overflow::Hidden, + y: Overflow::Hidden, + }) + .z_index(i32::from(selected)) + .interaction( + Interaction::default() + .focus_policy(FocusPolicy::TabStop) + .cursor(CursorIcon::Grab) + .gestures( + GestureSet::EMPTY.pan( + PanGesture::default() + .immediate() + .capture(GestureCapture::OnPress), + ), + ), + ) + .semantics( + Semantics::new(Role::Button) + .label(format!( + "{} clip at {:.1}s on track {}", + clip.label, + clip.start, + clip.track + 1 + )) + .description( + "Drag horizontally or between tracks; arrow keys move the clip", + ) + .action(SemanticAction::Focus), + ) + .user_select(UserSelect::None) + .on(cx.listener(EventType::Gesture, move |app, event, cx| { + let UiEventKind::Gesture(gesture) = event.kind else { + return; + }; + let GestureKind::Pan { total, .. } = gesture.kind else { + return; + }; + if gesture.phase == GesturePhase::Started { + app.selected_clip = index; + app.clip_drag_start = app.clips[index]; + } + app.clips[index] = if gesture.phase == GesturePhase::Cancelled { + app.clip_drag_start + } else { + let mut next = app.clip_drag_start; + next.start = (next.start + total.x / PIXELS_PER_SECOND) + .clamp(0.0, DURATION - next.duration); + next.track = (next.track as f32 + total.y / TRACK_HEIGHT) + .round() + .clamp(0.0, (TRACK_LABELS - 1) as f32) + as usize; + next + }; + event.stop_propagation(); + cx.notify(); + })) + .on(cx.listener(EventType::Key, move |app, event, cx| { + let UiEventKind::KeyInput(input) = &event.kind else { + return; + }; + if input.state != KeyState::Pressed { + return; + } + let clip = &mut app.clips[index]; + match input.key { + Key::ArrowLeft => clip.start = (clip.start - 0.5).max(0.0), + Key::ArrowRight => { + clip.start = (clip.start + 0.5).min(DURATION - clip.duration) + } + Key::ArrowUp => clip.track = clip.track.saturating_sub(1), + Key::ArrowDown => clip.track = (clip.track + 1).min(TRACK_LABELS - 1), + _ => return, + } + app.selected_clip = index; + event.stop_propagation(); + cx.notify(); + })) + }, + )); + let playhead = Element::custom_region( + "editor-playhead", + Interaction::default() + .focus_policy(FocusPolicy::TabStop) + .cursor(CursorIcon::EwResize) + .gestures( + GestureSet::EMPTY.pan( + PanGesture::default() + .immediate() + .capture(GestureCapture::OnPress), + ), + ), + Semantics::new(Role::Slider) + .label("Timeline playhead") + .description("Drag horizontally or use Left and Right to scrub") + .value(SemanticValue::Number { + value: f64::from(self.playhead), + minimum: Some(0.0), + maximum: Some(f64::from(DURATION)), + step: Some(0.5), + }) + .action(SemanticAction::Focus) + .action(SemanticAction::Increment) + .action(SemanticAction::Decrement) + .action(SemanticAction::SetValue), + ) + .hit_test(HitTestStyle::default().slop(Sides::length(8.0))) + .user_select(UserSelect::None) + .on(cx.listener(EventType::Gesture, |app, event, cx| { + let UiEventKind::Gesture(gesture) = event.kind else { + return; + }; + let GestureKind::Pan { total, .. } = gesture.kind else { + return; + }; + if gesture.phase == GesturePhase::Started { + app.playhead_drag_start = app.playhead; + } + app.playhead = if gesture.phase == GesturePhase::Cancelled { + app.playhead_drag_start + } else { + (app.playhead_drag_start + total.x / PIXELS_PER_SECOND).clamp(0.0, DURATION) + }; + event.stop_propagation(); + cx.notify(); + })) + .on(cx.listener(EventType::Key, |app, event, cx| { + let UiEventKind::KeyInput(input) = &event.kind else { + return; + }; + if input.state != KeyState::Pressed { + return; + } + let delta = match input.key { + Key::ArrowLeft => -0.5, + Key::ArrowRight => 0.5, + _ => return, + }; + app.playhead = (app.playhead + delta).clamp(0.0, DURATION); + event.stop_propagation(); + cx.notify(); + })) + .on(cx.listener(EventType::SemanticAction, |app, event, cx| { + let UiEventKind::SemanticAction { action, value } = &event.kind else { + return; + }; + app.playhead = match (action, value) { + (SemanticAction::Increment, _) => app.playhead + 0.5, + (SemanticAction::Decrement, _) => app.playhead - 0.5, + (SemanticAction::SetValue, Some(SemanticValue::Number { value, .. })) => { + *value as f32 + } + _ => return, + } + .clamp(0.0, DURATION); + event.stop_propagation(); + cx.notify(); + })); + children.push(playhead); + + let back = Button::new("playhead-back", "Back 1s", theme.outline_button()) + .on_click(cx.callback(|app| app.playhead = (app.playhead - 1.0).max(0.0))) + .build(); + let forward = Button::new("playhead-forward", "Forward 1s", theme.outline_button()) + .on_click(cx.callback(|app| { + app.playhead = (app.playhead + 1.0).min(DURATION); + })) + .build(); + let timeline = Element::custom_container( + EditorTimeline { + background: theme.card, + track: theme.muted, + grid: theme.border, + playhead: theme.destructive, + seconds: self.playhead, + clips: self.clips, + }, + children, + ) + .keyed("video-editor-timeline") .width(percent(1.0)) - .height(length(180.0))]) + .height(length(RULER_HEIGHT + TRACK_LABELS as f32 * TRACK_HEIGHT)); + + Element::column([ + Element::row([ + Element::column([ + text( + "Product launch edit".to_owned(), + 22.0, + 750, + theme.foreground, + ), + text( + "Drag clips to retime them or move tracks; drag the red playhead to scrub." + .to_owned(), + 13.0, + 400, + theme.muted_foreground, + ), + ]) + .gap(3.0) + .grow(1.0), + text( + format!("00:{:04.1} / 00:20.0", self.playhead), + 13.0, + 700, + theme.foreground, + ), + ]), + Element::row([back, forward]).gap(8.0), + text( + format!( + "Selected: {} · {:.1}s · track {}", + self.clips[self.selected_clip].label, + self.clips[self.selected_clip].start, + self.clips[self.selected_clip].track + 1 + ), + 12.0, + 600, + theme.muted_foreground, + ), + timeline, + ]) .width(percent(1.0)) .height(percent(1.0)) .padding(Sides::length(24.0)) + .gap(14.0) .background(theme.background) } } diff --git a/app_examples/docs-examples/src/examples/i18n.rs b/app_examples/docs-examples/src/examples/i18n.rs index 8606781f..77f8c073 100644 --- a/app_examples/docs-examples/src/examples/i18n.rs +++ b/app_examples/docs-examples/src/examples/i18n.rs @@ -2,7 +2,7 @@ use argui::{ i18n::{Catalog, FluentArgs, Localizer, langid}, runtime::{Context, Render}, text::TextStyle, - ui::{Element, EventType, FlexWrap, Sides, UiEventKind, WritingDirection, percent}, + ui::{Element, FlexWrap, Sides, WritingDirection, percent}, widgets::{Button, default_theme}, }; @@ -60,38 +60,33 @@ impl Render for Example { .direction_scope(direction); Element::column([ Element::row([ - Button::new("en", "English", theme.outline_button()).build(), - Button::new("fr", "Français", theme.outline_button()).build(), - Button::new("ar", "العربية", theme.outline_button()).build(), + Button::new("en", "English", theme.outline_button()) + .on_click(cx.callback(|app| { + app.localizer.select([langid!("en-US")]); + })) + .build(), + Button::new("fr", "Français", theme.outline_button()) + .on_click(cx.callback(|app| { + app.localizer.select([langid!("fr")]); + })) + .build(), + Button::new("ar", "العربية", theme.outline_button()) + .on_click(cx.callback(|app| { + app.localizer.select([langid!("ar")]); + })) + .build(), ]) .gap(8.0) .flex_wrap(FlexWrap::Wrap), localized, - Button::new("more", "Add message", theme.button()).build(), + Button::new("more", "Add message", theme.button()) + .on_click(cx.callback(|app| app.count += 1)) + .build(), ]) .width(percent(1.0)) .height(percent(1.0)) .padding(Sides::length(28.0)) .gap(18.0) .background(theme.background) - .on(cx.listener(EventType::Click, |app, event, cx| { - if !matches!(event.kind, UiEventKind::Click(_)) { - return; - } - match event.target_key() { - Some("en") => { - app.localizer.select([langid!("en-US")]); - } - Some("fr") => { - app.localizer.select([langid!("fr")]); - } - Some("ar") => { - app.localizer.select([langid!("ar")]); - } - Some("more") => app.count += 1, - _ => return, - } - cx.notify(); - })) } } diff --git a/app_examples/docs-examples/src/examples/interaction_api.rs b/app_examples/docs-examples/src/examples/interaction_api.rs new file mode 100644 index 00000000..13639f43 --- /dev/null +++ b/app_examples/docs-examples/src/examples/interaction_api.rs @@ -0,0 +1,303 @@ +use argui::{ + paint::{Border, CornerRadii, PaintStyle, QuadStyle}, + runtime::{Context, Render}, + text::TextStyle, + ui::{ + AlignItems, Axes, ContinuousValuePhase, Element, EventType, JustifyContent, Overflow, + RangeHandlerValue, Sides, StateSelector, StylePatch, StyleTransition, TextEdit, + ValueHandler, VisualState, auto, length, percent, property, + }, + widgets::{ + Button, RANGE_SCOPE, RangeAxis, RangeBehavior, RangeConfig, RangeDirection, RangePart, + TextArea, WidgetTheme, default_theme, + }, +}; + +struct BigSlider { + behavior: RangeBehavior, + change_handlers: Vec>, + commit_handlers: Vec>, +} + +impl BigSlider { + /// Creates the large custom slider used by this interaction example. + fn new(key: &str, label: &str, value: f32, config: RangeConfig) -> Self { + Self { + behavior: RangeBehavior::new(key, label, value, config), + change_handlers: Vec::new(), + commit_handlers: Vec::new(), + } + } + + /// Adds a callback for each intermediate value produced while interacting. + fn on_change(mut self, handler: ValueHandler) -> Self { + self.change_handlers.push(handler); + self + } + + /// Adds a callback for the final value produced by an interaction. + fn on_commit(mut self, handler: ValueHandler) -> Self { + self.commit_handlers.push(handler); + self + } + + /// Builds a fully custom visual while preserving the standard range contract. + fn build(self, theme: &WidgetTheme) -> Element { + let ratio = self.behavior.ratio(); + let ticks = Element::row((0..9).map(|_| { + Element::container([]) + .width(length(1.0)) + .height(length(12.0)) + .paint_style(PaintStyle::new( + QuadStyle::solid(theme.foreground).opacity(0.12), + )) + .when( + StateSelector::scope(RANGE_SCOPE, VisualState::Hovered), + StylePatch::new().set(property::Opacity, 0.42), + ) + .when( + StateSelector::scope(RANGE_SCOPE, VisualState::Pressed), + StylePatch::new().set(property::Opacity, 0.7), + ) + .transition(StyleTransition::default()) + })) + .absolute(Sides { + left: length(18.0), + right: length(18.0), + top: auto(), + bottom: auto(), + }) + .height(percent(1.0)) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::SPACE_BETWEEN); + let fill = Element::container([]) + .absolute(Sides::length(0.0)) + .width(percent(1.0)) + .height(percent(1.0)) + .background(theme.primary.with_alpha(0.26)); + let thumb = Element::container([]) + .absolute(Sides { + left: auto(), + right: length(0.0), + top: length(7.0), + bottom: length(7.0), + }) + .width(length(3.0)) + .background(theme.primary) + .radius(CornerRadii::all(2.0)); + let progress = Element::container([fill, thumb]) + .absolute(Sides { + left: length(0.0), + right: auto(), + top: length(0.0), + bottom: length(0.0), + }) + .width(percent(ratio)); + let track = self.behavior.decorate( + RangePart::Track, + Element::container([progress, ticks]) + .width(percent(1.0)) + .height(percent(1.0)), + ); + let mut control = self.behavior.decorate( + RangePart::Control, + Element::container([track]) + .absolute(Sides::length(0.0)) + .width(percent(1.0)) + .height(percent(1.0)), + ); + let config = self.behavior.config(); + for (phase, handlers) in [ + (ContinuousValuePhase::Change, &self.change_handlers), + (ContinuousValuePhase::Commit, &self.commit_handlers), + ] { + let source = RangeHandlerValue::new( + self.behavior.value(), + config.minimum, + config.maximum, + config.step, + config.axis == RangeAxis::Vertical, + config.direction == RangeDirection::Reverse, + phase, + ); + for handler in handlers { + for event in [ + EventType::Key, + EventType::Gesture, + EventType::SemanticAction, + ] { + control = + control.on(handler.direct_listener(event).range_handler_value(source)); + } + } + } + self.behavior.decorate( + RangePart::Root, + Element::container([ + control, + Element::text(format!("{:.0}%", self.behavior.value())) + .absolute(Sides { + left: auto(), + right: length(14.0), + top: length(18.0), + bottom: auto(), + }) + .text_style(TextStyle { + color: theme.foreground, + weight: 700, + ..TextStyle::default() + }) + .semantic_hidden(true), + ]) + .width(percent(1.0)) + .height(length(58.0)) + .background(theme.card) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(12.0)) + .overflow(Axes { + x: Overflow::Hidden, + y: Overflow::Hidden, + }), + ) + } +} + +pub struct Example { + live_volume: f32, + committed_volume: f32, + saves: u32, + source: String, + edit_count: u32, + last_edit: String, +} + +impl Default for Example { + fn default() -> Self { + Self { + live_volume: 35.0, + committed_volume: 35.0, + saves: 0, + source: "fn main() {\n println!(\"fast edits\");\n}".into(), + edit_count: 0, + last_edit: "No edits delivered yet".into(), + } + } +} + +impl Render for Example { + fn render(&mut self, cx: &mut Context) -> Element { + let themes = default_theme(cx.environment()); + let theme = themes.resolve(cx.environment().color_scheme); + let label = |value: String, color, weight| { + Element::text(value).text_style(TextStyle { + color, + weight, + ..TextStyle::default() + }) + }; + + let live_value = cx.value_callback(|app, value| app.live_volume = value); + let committed_value = cx.value_callback(|app, value| { + app.live_volume = value; + app.committed_volume = value; + }); + let save = cx.callback(|app| app.saves = app.saves.saturating_add(1)); + let edit_source = cx.edit_callback(|app, edit: TextEdit| { + let summary = format!( + "bytes {}..{} → {} byte(s)", + edit.range.start, + edit.range.end, + edit.replacement.len() + ); + if edit.apply_to(&mut app.source).is_ok() { + app.edit_count = app.edit_count.saturating_add(1); + app.last_edit = summary; + } else { + app.last_edit = "Rejected stale edit".into(); + } + }); + + Element::column([ + label("Choose callbacks by intent".into(), theme.foreground, 700), + label( + "on_change previews continuously; on_commit stores the final value.".into(), + theme.muted_foreground, + 450, + ), + Element::column([ + BigSlider::new( + "volume", + "Preview volume", + self.live_volume, + RangeConfig::new(0.0, 100.0, 1.0), + ) + .on_change(live_value) + .on_commit(committed_value) + .build(theme), + label( + format!("Live value: {:.0}%", self.live_volume), + theme.foreground, + 600, + ), + label( + format!("Committed value: {:.0}%", self.committed_volume), + theme.muted_foreground, + 500, + ), + ]) + .padding(Sides::length(18.0)) + .gap(10.0) + .background(theme.card) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(10.0)), + Element::column([ + label( + "Use deltas for document-sized text".into(), + theme.foreground, + 650, + ), + label( + "on_edit sends one UTF-8 range replacement instead of cloning the whole value." + .into(), + theme.muted_foreground, + 450, + ), + TextArea::new( + "incremental-source", + &self.source, + "Paste or type Rust…", + theme.input(), + ) + .on_edit(edit_source) + .build() + .height(length(118.0)), + label( + format!( + "Incremental edits: {} · {}", + self.edit_count, self.last_edit + ), + theme.muted_foreground, + 500, + ), + ]) + .padding(Sides::length(18.0)) + .gap(10.0) + .background(theme.card) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(10.0)), + Button::new("save", "Save preset", theme.button()) + .on_click(save) + .build(), + label( + format!("Saved {} time(s)", self.saves), + theme.muted_foreground, + 500, + ), + ]) + .width(percent(1.0)) + .height(percent(1.0)) + .padding(Sides::length(28.0)) + .gap(16.0) + .background(theme.background) + } +} diff --git a/app_examples/docs-examples/src/examples/mod.rs b/app_examples/docs-examples/src/examples/mod.rs index 850da772..e9915115 100644 --- a/app_examples/docs-examples/src/examples/mod.rs +++ b/app_examples/docs-examples/src/examples/mod.rs @@ -9,9 +9,13 @@ pub mod events; pub mod first_window; pub mod i18n; pub mod installation; +pub mod interaction_api; pub mod layout; pub mod mental_model; pub mod overlays; +pub mod performance; +pub mod platform_roadmap; +pub mod platform_support; pub mod project_structure; pub mod styling; pub mod tasks; diff --git a/app_examples/docs-examples/src/examples/overlays.rs b/app_examples/docs-examples/src/examples/overlays.rs index 96f4e6c5..95fe552b 100644 --- a/app_examples/docs-examples/src/examples/overlays.rs +++ b/app_examples/docs-examples/src/examples/overlays.rs @@ -1,59 +1,195 @@ use argui::{ + core::Color, + paint::{Border, CornerRadii, PaintStyle, QuadStyle}, runtime::{Context, Render}, text::TextStyle, - ui::{Element, EventType, Sides, UiEventKind, percent}, - widgets::{Button, Dialog, DialogBehavior, default_theme}, + ui::{Axes, Element, FlexWrap, Overflow, ScrollConfig, Sides, auto, length, percent}, + widgets::{Button, Dialog, DialogBehavior, Popover, default_theme}, }; #[derive(Default)] pub struct Example { - open: bool, + dialog_open: bool, + solid_open: bool, + blurred_open: bool, } impl Render for Example { fn render(&mut self, cx: &mut Context) -> Element { let themes = default_theme(cx.environment()); let theme = themes.resolve(cx.environment().color_scheme); - let close_key = - DialogBehavior::new("example-dialog", "Example dialog", self.open).close_key(); - let content = Element::column([ - Element::text("A real modal portal").text_style(TextStyle { - color: theme.foreground, - font_size: 24.0, - weight: 700, + let text = |value: &str, size: f32, weight: u16, color: Color| { + Element::text(value).text_style(TextStyle { + color, + font_size: size, + weight, ..TextStyle::default() - }), - Element::text("Focus stays inside until the dialog closes.").text_style(TextStyle { - color: theme.muted_foreground, - ..TextStyle::default() - }), + }) + }; + let preview = |overlay: Element| { + let backdrop = Element::column([ + Element::container([]) + .height(length(30.0)) + .background(Color::from_srgb8(91, 115, 241)), + Element::container([]) + .height(length(30.0)) + .background(Color::from_srgb8(216, 96, 165)), + Element::container([]) + .height(length(30.0)) + .background(Color::from_srgb8(32, 168, 133)), + ]) + .gap(8.0) + .padding(Sides { + top: length(68.0), + ..Sides::length(16.0) + }); + Element::container([ + backdrop, + overlay.absolute(Sides { + top: length(18.0), + left: length(16.0), + right: auto(), + bottom: auto(), + }), + ]) + .height(length(198.0)) + .width(percent(1.0)) + .background(theme.muted) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(10.0)) + }; + let card = |title: &str, description: &str, overlay: Element| { + Element::column([ + text(title, 16.0, 650, theme.foreground), + text(description, 13.0, 400, theme.muted_foreground).min_height(length(38.0)), + preview(overlay), + ]) + .width(length(232.0)) + .max_width(percent(1.0)) + .grow(1.0) + .gap(10.0) + }; + + let solid_content = Element::column([ + text("Solid surface", 18.0, 700, theme.foreground), + text( + "An opaque panel with backdrop blur disabled.", + 13.0, + 400, + theme.muted_foreground, + ), + Button::new("close-solid", "Close solid popover", theme.outline_button()) + .on_click(cx.callback(|app| app.solid_open = false)) + .build(), + ]) + .gap(12.0); + let solid = Popover::new( + "solid-popover", + "Solid popover", + self.solid_open, + Button::new("solid-popover", "Open solid", theme.outline_button()).build(), + solid_content, + ) + .on_open_change(cx.value_callback(|app, open| app.solid_open = open)) + .backdrop_blur(0.0) + .size(238.0, 220.0) + .build(theme); + + let blurred_content = Element::column([ + text("Backdrop blur", 18.0, 700, theme.foreground), + text( + "Translucent paint keeps the colored backdrop visible through the blur.", + 13.0, + 400, + theme.muted_foreground, + ), + Button::new( + "close-blurred", + "Close blurred popover", + theme.outline_button(), + ) + .on_click(cx.callback(|app| app.blurred_open = false)) + .build(), + ]) + .gap(12.0); + let blurred = Popover::new( + "blurred-popover", + "Blurred popover", + self.blurred_open, + Button::new("blurred-popover", "Open with blur", theme.outline_button()).build(), + blurred_content, + ) + .on_open_change(cx.value_callback(|app, open| app.blurred_open = open)) + .paint(PaintStyle::new( + QuadStyle::solid(theme.popover.with_alpha(0.78)) + .border(Border::all(1.0, theme.popover_border)) + .radius(CornerRadii::all(8.0)), + )) + .backdrop_blur(12.0) + .size(258.0, 240.0) + .build(theme); + + let behavior = DialogBehavior::new("example-dialog", "Example dialog", self.dialog_open); + let close_key = behavior.close_key(); + let trigger_key = behavior.trigger_key(); + let dialog_content = Element::column([ + text("A real modal portal", 24.0, 700, theme.foreground), + text( + "Focus stays inside until the dialog closes.", + 14.0, + 400, + theme.muted_foreground, + ), Button::new(close_key, "Close dialog", theme.outline_button()).build(), ]) .gap(14.0); - Dialog::new( + let dialog = Dialog::new( "example-dialog", "Example dialog", - self.open, - Button::new("open-dialog", "Open dialog", theme.button()).build(), - content, + self.dialog_open, + Button::new(trigger_key, "Open dialog", theme.button()).build(), + dialog_content, ) - .build(theme) + .on_open_change(cx.value_callback(|app, open| app.dialog_open = open)) + .build(theme); + + Element::column([ + text("Overlay surfaces", 28.0, 760, theme.foreground), + text( + "Compare an opaque popover, a translucent blurred popover, and a focus-trapping dialog.", + 14.0, + 400, + theme.muted_foreground, + ), + Element::row([ + card( + "No blur", + "Opaque surface over the same colored stage.", + solid, + ), + card( + "With blur", + "Translucent surface using a 12 px backdrop blur.", + blurred, + ), + card( + "Modal dialog", + "Backdrop, focus trap, Escape, and focus restoration.", + dialog, + ), + ]) + .flex_wrap(FlexWrap::Wrap) + .gap(16.0), + ]) .width(percent(1.0)) .height(percent(1.0)) .padding(Sides::length(28.0)) + .gap(18.0) .background(theme.background) - .on(cx.listener(EventType::Click, |app, event, cx| { - if !matches!(event.kind, UiEventKind::Click(_)) { - return; - } - match event.target_key() { - Some("open-dialog") => app.open = true, - Some(key) if key.ends_with("::close") || key.ends_with("::backdrop") => { - app.open = false - } - _ => return, - } - cx.notify(); - })) + .overflow(Axes { + x: Overflow::Hidden, + y: Overflow::Auto, + }) + .scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone())) } } diff --git a/app_examples/docs-examples/src/examples/performance.rs b/app_examples/docs-examples/src/examples/performance.rs new file mode 100644 index 00000000..1caa9ca2 --- /dev/null +++ b/app_examples/docs-examples/src/examples/performance.rs @@ -0,0 +1,142 @@ +use argui::{ + accessibility::{Role, Semantics}, + core::{Color, Point, Transform2D}, + paint::{Border, CornerRadii}, + runtime::{Context, Render}, + text::TextStyle, + ui::{ + AlignItems, CursorIcon, Element, EventType, FocusPolicy, GestureCapture, GestureDelivery, + GestureKind, GesturePhase, GestureSet, Interaction, JustifyContent, PanGesture, Sides, + UiEventKind, UserSelect, length, percent, + }, + widgets::default_theme, +}; + +#[derive(Default)] +pub struct Example { + offset: Point, + velocity: Point, + delivered_updates: u64, + dragging: bool, +} + +impl Render for Example { + fn render(&mut self, cx: &mut Context) -> Element { + let themes = default_theme(cx.environment()); + let theme = themes.resolve(cx.environment().color_scheme); + let orb = Element::row([ + Element::text(if self.dragging { "HOLDING" } else { "DRAG" }).text_style(TextStyle { + color: Color::WHITE, + font_size: 11.0, + line_height: 15.0, + weight: 760, + ..TextStyle::default() + }), + ]) + .absolute(Sides { + left: length(220.0), + right: argui::ui::auto(), + top: length(72.0), + bottom: argui::ui::auto(), + }) + .width(length(88.0)) + .height(length(56.0)) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::CENTER) + .background(theme.primary) + .radius(CornerRadii::all(14.0)) + .transform(Transform2D::IDENTITY.translate(self.offset.x, self.offset.y)); + let pad = Element::container([orb]) + .keyed("frame-coalesced-pad") + .width(percent(1.0)) + .height(length(200.0)) + .background(theme.muted) + .border(Border::all(1.0, theme.border)) + .radius(CornerRadii::all(14.0)) + .user_select(UserSelect::None) + .interaction( + Interaction::default() + .focus_policy(FocusPolicy::TabStop) + .cursor(if self.dragging { + CursorIcon::Grabbing + } else { + CursorIcon::Grab + }) + .gestures( + GestureSet::EMPTY.pan( + PanGesture::default() + .immediate() + .capture(GestureCapture::OnPress) + .delivery(GestureDelivery::FrameCoalesced), + ), + ), + ) + .semantics( + Semantics::new(Role::Group) + .label("Frame-coalesced drag pad") + .description("Drag to observe one visual update per available frame"), + ) + .on(cx.listener(EventType::Gesture, |example, event, cx| { + let UiEventKind::Gesture(gesture) = event.kind else { + return; + }; + let GestureKind::Pan { + total, velocity, .. + } = gesture.kind + else { + return; + }; + if gesture.phase == GesturePhase::Started { + example.delivered_updates = 0; + example.dragging = true; + } + if gesture.phase == GesturePhase::Changed { + example.delivered_updates = example.delivered_updates.saturating_add(1); + } + example.offset = + Point::new(total.x.clamp(-210.0, 210.0), total.y.clamp(-68.0, 68.0)); + example.velocity = velocity; + if matches!(gesture.phase, GesturePhase::Ended | GesturePhase::Cancelled) { + example.dragging = false; + } + event.stop_propagation(); + cx.notify(); + })); + let status = format!( + "Delivered frame updates: {} · velocity {:.0}, {:.0} px/s", + self.delivered_updates, self.velocity.x, self.velocity.y + ); + + Element::column([ + Element::text("Frame-coalesced continuous input").text_style(TextStyle { + color: theme.foreground, + font_size: 18.0, + line_height: 24.0, + weight: 700, + ..TextStyle::default() + }), + Element::text( + "Move quickly: position, velocity and this status are delivered together at most once per available display frame.", + ) + .text_style(TextStyle { + color: theme.muted_foreground, + ..TextStyle::default() + }), + pad, + Element::text(status.clone()) + .text_style(TextStyle { + color: theme.muted_foreground, + font_size: 13.0, + line_height: 18.0, + weight: 550, + ..TextStyle::default() + }) + .semantics(Semantics::new(Role::Status).label(status)), + ]) + .width(percent(1.0)) + .height(percent(1.0)) + .padding(Sides::length(28.0)) + .gap(14.0) + .background(theme.background) + } +} diff --git a/app_examples/docs-examples/src/examples/platform_roadmap.rs b/app_examples/docs-examples/src/examples/platform_roadmap.rs new file mode 100644 index 00000000..e438f27a --- /dev/null +++ b/app_examples/docs-examples/src/examples/platform_roadmap.rs @@ -0,0 +1,131 @@ +use argui::{ + runtime::{Context, Render}, + text::TextStyle, + ui::{AlignItems, Axes, Element, JustifyContent, Overflow, ScrollConfig, Sides, percent}, + widgets::{Button, default_theme}, +}; + +const SHIPPING: [(&str, &str); 4] = [ + ("Text input / IME", "Shipping"), + ("Safe areas, keyboard, and system bars", "Shipping"), + ("Live activity progress", "Shipping"), + ("Background activity foundation", "Shipping"), +]; + +const PLANNED: [(&str, &str); 29] = [ + ("Accessibility device validation", "Highest priority"), + ("Camera", "Medium priority"), + ("Clipboard", "High priority"), + ("Drag and drop", "High priority"), + ("Haptics", "High priority"), + ("File picker", "High priority"), + ("Photo picker", "High priority"), + ("Share sheet", "High priority"), + ("Biometrics", "High priority"), + ("Passkeys and credentials", "High priority"), + ("Secure storage", "High priority"), + ("Notifications", "High priority"), + ("Home-screen widgets", "High priority"), + ("Location", "Medium priority"), + ("Motion and sensors", "Medium priority"), + ("Bluetooth LE", "Medium priority"), + ("NFC", "Medium priority"), + ("UWB and ranging", "High priority"), + ("Audio input and output", "High priority"), + ("Video encode and decode", "Medium priority"), + ("Mobile WebView", "High priority"), + ("Deep links", "High priority"), + ("Network status", "Medium priority"), + ("Gamepads", "Medium priority"), + ("Mouse and stylus validation", "High priority"), + ("Store and in-app purchases", "Medium priority"), + ("Speech and text to speech", "Later"), + ("Contacts and calendar", "Later"), + ("Health integrations", "Later"), +]; + +#[derive(Default)] +pub struct Example { + expanded: bool, +} + +impl Render for Example { + fn render(&mut self, cx: &mut Context) -> Element { + let themes = default_theme(cx.environment()); + let theme = themes.resolve(cx.environment().color_scheme); + let rows = SHIPPING + .into_iter() + .map(|(capability, status)| (capability, status, true)) + .chain( + self.expanded + .then_some(PLANNED) + .into_iter() + .flatten() + .map(|(capability, priority)| (capability, priority, false)), + ) + .map(|(capability, status, shipping)| { + Element::row([ + Element::text(capability).text_style(TextStyle { + color: theme.foreground, + weight: 600, + ..TextStyle::default() + }), + Element::text(status).text_style(TextStyle { + color: if shipping { + theme.primary + } else { + theme.muted_foreground + }, + font_size: 12.0, + weight: 650, + ..TextStyle::default() + }), + ]) + .align_items(AlignItems::CENTER) + .justify_content(JustifyContent::SPACE_BETWEEN) + .gap(12.0) + .padding(Sides::length(12.0)) + .background(theme.card) + .border(argui::paint::Border::all(1.0, theme.border)) + .radius(argui::paint::CornerRadii::all(8.0)) + }); + Element::column([ + Element::text("Mobile integration roadmap").text_style(TextStyle { + color: theme.foreground, + font_size: 28.0, + weight: 760, + ..TextStyle::default() + }), + Element::text( + "Shipping means implemented now. Every other row is planned, not promised as available.", + ) + .text_style(TextStyle { + color: theme.muted_foreground, + font_size: 14.0, + ..TextStyle::default() + }), + Button::new( + "toggle-roadmap", + if self.expanded { + "Show shipping only" + } else { + "Show the full planned roadmap" + }, + theme.outline_button(), + ) + .on_click(cx.callback(|app| app.expanded = !app.expanded)) + .build(), + Element::column(rows).gap(8.0), + ]) + .width(percent(1.0)) + .height(percent(1.0)) + .padding(Sides::length(24.0)) + .gap(14.0) + .background(theme.background) + .overflow(Axes { + x: Overflow::Hidden, + y: Overflow::Auto, + }) + .scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone())) + } +} diff --git a/app_examples/docs-examples/src/examples/platform_support.rs b/app_examples/docs-examples/src/examples/platform_support.rs new file mode 100644 index 00000000..ca7edeab --- /dev/null +++ b/app_examples/docs-examples/src/examples/platform_support.rs @@ -0,0 +1,102 @@ +use argui::{ + runtime::{Context, Render}, + text::TextStyle, + ui::{Axes, Element, Overflow, ScrollConfig, Sides, length, percent}, + widgets::default_theme, +}; + +const PLATFORMS: [(&str, &str, &str); 6] = [ + ( + "Linux", + "Supported · runtime-tested", + "Native WGPU, input, accessibility, windows, and the complete gallery.", + ), + ( + "Windows", + "Supported · CI-compiled", + "Native WGPU with DirectX 12 and Vulkan fallback paths.", + ), + ( + "macOS", + "Supported · CI-compiled", + "Native AppKit windowing, Metal rendering, and desktop integration.", + ), + ( + "WebAssembly", + "Supported · browser-tested", + "WebGPU rendering, browser semantics, and the complete live gallery.", + ), + ( + "Android", + "Preview", + "Cross-compilation, APK/AAB packaging, IME, safe areas, and activity progress.", + ), + ( + "iOS", + "Preview", + "XCFramework/Simulator packaging, safe areas, IME, and ActivityKit progress.", + ), +]; + +pub struct Example; + +impl Render for Example { + fn render(&mut self, cx: &mut Context) -> Element { + let themes = default_theme(cx.environment()); + let theme = themes.resolve(cx.environment().color_scheme); + let cards = PLATFORMS.into_iter().map(|(platform, status, detail)| { + Element::column([ + Element::text(platform).text_style(TextStyle { + color: theme.foreground, + font_size: 18.0, + weight: 700, + ..TextStyle::default() + }), + Element::text(status).text_style(TextStyle { + color: theme.primary, + font_size: 13.0, + weight: 650, + ..TextStyle::default() + }), + Element::text(detail).text_style(TextStyle { + color: theme.muted_foreground, + font_size: 13.0, + ..TextStyle::default() + }), + ]) + .padding(Sides::length(16.0)) + .gap(7.0) + .background(theme.card) + .border(argui::paint::Border::all(1.0, theme.border)) + .radius(argui::paint::CornerRadii::all(10.0)) + }); + Element::column([ + Element::text("Argui platform support").text_style(TextStyle { + color: theme.foreground, + font_size: 28.0, + weight: 760, + ..TextStyle::default() + }), + Element::text( + "Desktop and Web are supported today. Android and iOS are explicit preview targets.", + ) + .text_style(TextStyle { + color: theme.muted_foreground, + font_size: 14.0, + ..TextStyle::default() + }), + Element::column(cards).gap(10.0), + ]) + .width(percent(1.0)) + .height(percent(1.0)) + .min_height(length(0.0)) + .padding(Sides::length(24.0)) + .gap(16.0) + .background(theme.background) + .overflow(Axes { + x: Overflow::Hidden, + y: Overflow::Auto, + }) + .scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone())) + } +} diff --git a/app_examples/docs-examples/src/examples/styling.rs b/app_examples/docs-examples/src/examples/styling.rs index 6befa4dd..1c69a265 100644 --- a/app_examples/docs-examples/src/examples/styling.rs +++ b/app_examples/docs-examples/src/examples/styling.rs @@ -6,7 +6,7 @@ use argui::{ runtime::{Context, Render}, text::TextStyle, theme::{ThemeOverrides, ThemeValue}, - ui::{Element, EventType, FlexWrap, Sides, UiEventKind, percent}, + ui::{Element, EventHandler, FlexWrap, Sides, percent}, widgets::{Button, WidgetTheme, default_theme}, }; @@ -55,13 +55,19 @@ fn custom_overrides(scheme: ColorScheme) -> Arc { Arc::new(tokens) } -fn choice(key: &'static str, label: &'static str, selected: bool, theme: &WidgetTheme) -> Element { +fn choice( + key: &'static str, + label: &'static str, + selected: bool, + theme: &WidgetTheme, + handler: EventHandler, +) -> Element { let style = if selected { theme.button() } else { theme.outline_button() }; - Button::new(key, label, style).build() + Button::new(key, label, style).on_click(handler).build() } impl Render for Example { @@ -85,26 +91,52 @@ impl Render for Example { }; let schemes = Element::row([ - choice("system", "System", self.scheme.is_none(), theme), + choice( + "system", + "System", + self.scheme.is_none(), + theme, + cx.callback(|app| app.scheme = None), + ), choice( "light", "Light", self.scheme == Some(ColorScheme::Light), theme, + cx.callback(|app| app.scheme = Some(ColorScheme::Light)), ), choice( "dark", "Dark", self.scheme == Some(ColorScheme::Dark), theme, + cx.callback(|app| app.scheme = Some(ColorScheme::Dark)), ), ]) .gap(8.0) .flex_wrap(FlexWrap::Wrap); let accents = Element::row([ - choice("blue", "Blue", self.accent == 0, theme), - choice("violet", "Violet", self.accent == 1, theme), - choice("amber", "Amber", self.accent == 2, theme), + choice( + "blue", + "Blue", + self.accent == 0, + theme, + cx.callback(|app| app.accent = 0), + ), + choice( + "violet", + "Violet", + self.accent == 1, + theme, + cx.callback(|app| app.accent = 1), + ), + choice( + "amber", + "Amber", + self.accent == 2, + theme, + cx.callback(|app| app.accent = 2), + ), choice( "tokens", if self.custom_tokens { @@ -114,6 +146,7 @@ impl Render for Example { }, self.custom_tokens, theme, + cx.callback(|app| app.custom_tokens = !app.custom_tokens), ), ]) .gap(8.0) @@ -159,21 +192,5 @@ impl Render for Example { .padding(Sides::length(20.0)) .gap(14.0) .background(theme.background) - .on(cx.listener(EventType::Click, |app, event, cx| { - if !matches!(event.kind, UiEventKind::Click(_)) { - return; - } - match event.target_key() { - Some("system") => app.scheme = None, - Some("light") => app.scheme = Some(ColorScheme::Light), - Some("dark") => app.scheme = Some(ColorScheme::Dark), - Some("blue") => app.accent = 0, - Some("violet") => app.accent = 1, - Some("amber") => app.accent = 2, - Some("tokens") => app.custom_tokens = !app.custom_tokens, - _ => return, - } - cx.notify(); - })) } } diff --git a/app_examples/docs-examples/src/examples/tasks.rs b/app_examples/docs-examples/src/examples/tasks.rs index c270526e..1390bd44 100644 --- a/app_examples/docs-examples/src/examples/tasks.rs +++ b/app_examples/docs-examples/src/examples/tasks.rs @@ -6,7 +6,7 @@ use argui::{ tasks::{TaskHandle, sleep}, }, text::TextStyle, - ui::{Element, EventType, Sides, UiEventKind, percent}, + ui::{Element, Sides, percent}, widgets::{Button, default_theme}, }; @@ -24,6 +24,21 @@ impl Render for Example { Element::column([ Button::new("load", "Load asynchronously", theme.button()) .enabled(self.task.is_none()) + .on_click(cx.event_handler(|app, _, cx| { + app.result = Some("Loading…".into()); + app.task = cx + .spawn( + async { sleep(Duration::from_millis(650)).await }, + |app, result, cx| { + result.expect("the local timer completes"); + app.result = Some("Loaded without blocking the UI".into()); + app.task = None; + cx.notify(); + }, + ) + .ok(); + cx.notify(); + })) .build(), Element::text(status).text_style(TextStyle { color: theme.foreground, @@ -35,23 +50,5 @@ impl Render for Example { .padding(Sides::length(28.0)) .gap(16.0) .background(theme.background) - .on(cx.listener(EventType::Click, |app, event, cx| { - if event.target_key() != Some("load") || !matches!(event.kind, UiEventKind::Click(_)) { - return; - } - app.result = Some("Loading…".into()); - app.task = cx - .spawn( - async { sleep(Duration::from_millis(650)).await }, - |app, result, cx| { - result.expect("the local timer completes"); - app.result = Some("Loaded without blocking the UI".into()); - app.task = None; - cx.notify(); - }, - ) - .ok(); - cx.notify(); - })) } } diff --git a/app_examples/docs-examples/src/lib.rs b/app_examples/docs-examples/src/lib.rs index fe4bd1e8..64db76e3 100644 --- a/app_examples/docs-examples/src/lib.rs +++ b/app_examples/docs-examples/src/lib.rs @@ -1,4 +1,4 @@ -mod examples; +pub mod examples; use argui::{ platform::{ApplicationConfig, ApplicationIdentity, WindowConfig}, @@ -12,6 +12,12 @@ use argui::{ const NOTO_SANS: &[u8] = include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoSans-Regular.ttf"); +const NOTO_ARABIC: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoSansArabic.ttf"); +const NOTO_HEBREW: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoSansHebrew.ttf"); +const NOTO_EMOJI: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoEmoji-Regular.ttf"); #[cfg(target_arch = "wasm32")] #[wasm_bindgen::prelude::wasm_bindgen( @@ -49,7 +55,12 @@ fn events(event: RuntimeEvent) { } fn launch(title: &str, model: M) -> Result<(), Box> { - let text = TextEngine::from_embedded_fonts([NOTO_SANS], "Noto Sans", "Noto Sans", "Noto Sans"); + let text = TextEngine::from_embedded_fonts( + [NOTO_SANS, NOTO_ARABIC, NOTO_HEBREW, NOTO_EMOJI], + "Noto Sans", + "Noto Sans", + "Noto Sans", + ); run_application_with_text_engine( ApplicationConfig::new( ApplicationIdentity::development(title), @@ -76,17 +87,30 @@ pub fn launch_example(example: &str) -> Result<(), Box> { "counter" => launch("Counter", examples::counter::Example::default()), "layout" => launch("Responsive layout", examples::layout::Example::default()), "events" => launch("Events", examples::events::Example::default()), + "interaction-api" => launch( + "Interaction API", + examples::interaction_api::Example::default(), + ), "styling" => launch("Styling", examples::styling::Example::default()), "accessibility" => launch("Accessibility", examples::accessibility::Example::default()), "tasks" => launch("Tasks", examples::tasks::Example::default()), "data" => launch("Data", examples::data::Example::default()), "overlays" => launch("Overlays", examples::overlays::Example::default()), + "performance" => launch("Performance", examples::performance::Example::default()), + "platform-support" => launch("Platform support", examples::platform_support::Example), + "platform-roadmap" => launch( + "Platform roadmap", + examples::platform_roadmap::Example::default(), + ), "animation" => launch("Animation", examples::animation::Example::default()), "i18n" => launch("Internationalization", examples::i18n::Example::default()), "mental-model" => launch("Mental model", examples::mental_model::Example), "project-structure" => launch("Project structure", examples::project_structure::Example), "clean-code" => launch("Clean code", examples::clean_code::Example::default()), - "custom-elements" => launch("Custom elements", examples::custom_elements::Example), + "custom-elements" => launch( + "Custom elements", + examples::custom_elements::Example::default(), + ), _ => Err(format!("unknown documentation example: {example}").into()), } } diff --git a/app_examples/docs-examples/tests/behavior.rs b/app_examples/docs-examples/tests/behavior.rs new file mode 100644 index 00000000..cf306fc7 --- /dev/null +++ b/app_examples/docs-examples/tests/behavior.rs @@ -0,0 +1,190 @@ +use argui::{ + accessibility::{Role, SemanticAction, SemanticValue}, + core::{Key, Point, ScrollDelta}, +}; +use argui_example_docs::examples; +use argui_testing::TestApp; + +#[test] +fn counter_uses_a_direct_button_callback() { + let mut app = TestApp::new(examples::counter::Example::default()); + app.get_by_role(Role::Button, "Increment").click().unwrap(); + app.assert_text("Count: 1"); +} + +#[test] +fn accessibility_example_announces_each_activation() { + let mut app = TestApp::new(examples::accessibility::Example::default()); + app.click("announce").unwrap(); + app.assert_text("1 accessible activations"); +} + +#[test] +fn animation_example_toggles_locally() { + let mut app = TestApp::new(examples::animation::Example::default()); + app.click("toggle-animation").unwrap(); + app.assert_text("Animations paused"); + app.assert_text("Resume animations"); +} + +#[test] +fn clean_code_example_completes_one_item() { + let mut app = TestApp::new(examples::clean_code::Example::default()); + app.assert_text("Pending"); + app.click("next").unwrap(); + app.assert_text("Done"); + app.assert_text("Complete next (2 of 3)"); + app.click("next").unwrap(); + app.click("next").unwrap(); + app.assert_text("All complete"); +} + +#[test] +fn custom_timeline_scrubs_with_buttons_and_a_real_pan_gesture() { + let mut app = TestApp::new(examples::custom_elements::Example::default()); + app.assert_text("00:07.5 / 00:20.0"); + let clip = app.bounds("editor-clip-2").unwrap(); + let clip_start = Point::new( + clip.origin.x + clip.size.width * 0.5, + clip.origin.y + clip.size.height * 0.5, + ); + app.drag(clip_start, Point::new(clip_start.x, clip_start.y + 58.0), 4) + .unwrap(); + app.assert_text("Selected: B-roll · 14.0s · track 2"); + + app.click("playhead-forward").unwrap(); + app.assert_text("00:08.5 / 00:20.0"); + + let bounds = app.bounds("editor-playhead").unwrap(); + let start = Point::new( + bounds.origin.x + bounds.size.width * 0.5, + bounds.origin.y + bounds.size.height * 0.5, + ); + app.drag(start, Point::new(start.x + 64.0, start.y), 3) + .unwrap(); + app.assert_text("00:10.5 / 00:20.0"); +} + +#[test] +fn event_delegation_example_remains_explicit() { + let mut app = TestApp::new(examples::events::Example::default()); + app.click("save").unwrap(); + app.assert_text("Received Click(save)"); +} + +#[test] +fn interaction_api_distinguishes_change_commit_and_click() { + let mut app = TestApp::new(examples::interaction_api::Example::default()); + app.accessibility_action( + "volume", + SemanticAction::SetValue, + Some(SemanticValue::Number { + value: 64.0, + minimum: None, + maximum: None, + step: None, + }), + ) + .unwrap(); + app.assert_text("Live value: 64%"); + app.assert_text("Committed value: 64%"); + app.click("save").unwrap(); + app.assert_text("Saved 1 time(s)"); + + app.get_by_role(Role::TextArea, "Paste or type Rust…") + .replace_text("fn edited() {}") + .unwrap(); + app.assert_text("Incremental edits: 1"); + app.assert_text("bytes 0..41 → 14 byte(s)"); +} + +#[test] +fn virtual_data_example_updates_from_real_wheel_scrolling() { + let mut app = TestApp::new(examples::data::Example::default()); + let bounds = app.bounds("records").unwrap(); + let point = Point::new( + bounds.origin.x + bounds.size.width * 0.5, + bounds.origin.y + bounds.size.height * 0.5, + ); + + app.wheel(point, ScrollDelta::Pixels(Point::new(0.0, -900.0))) + .unwrap(); + + app.assert_no_text("Record #00001"); + app.assert_text("Record #00022"); +} + +#[test] +fn i18n_controls_update_the_controlled_locale_and_count() { + let mut app = TestApp::new(examples::i18n::Example::default()); + app.click("fr").unwrap(); + app.assert_text("Bonjour"); + app.click("more").unwrap(); + app.assert_text("messages"); +} + +#[test] +fn overlays_open_and_close_through_their_typed_state_handlers() { + let mut app = TestApp::new(examples::overlays::Example::default()); + app.click("solid-popover").unwrap(); + app.assert_text("An opaque panel with backdrop blur disabled."); + app.click("close-solid").unwrap(); + app.assert_no_text("An opaque panel with backdrop blur disabled."); + + app.click("blurred-popover").unwrap(); + app.assert_text("Translucent paint keeps the colored backdrop visible through the blur."); + app.click("close-blurred").unwrap(); + app.assert_no_text("Translucent paint keeps the colored backdrop visible through the blur."); + + app.click("example-dialog::trigger").unwrap(); + app.assert_text("A real modal portal"); + app.get_by_role(Role::Button, "Close dialog") + .click() + .unwrap(); + app.assert_no_text("A real modal portal"); +} + +#[test] +fn performance_example_coalesces_many_moves_into_one_available_frame() { + let mut app = TestApp::new(examples::performance::Example::default()); + let bounds = app.bounds("frame-coalesced-pad").unwrap(); + let start = Point::new( + bounds.origin.x + bounds.size.width * 0.5, + bounds.origin.y + bounds.size.height * 0.5, + ); + app.drag(start, Point::new(start.x + 120.0, start.y + 40.0), 12) + .unwrap(); + app.assert_text("Delivered frame updates: 1"); +} + +#[test] +fn platform_support_distinguishes_supported_and_preview_targets() { + let app = TestApp::new(examples::platform_support::Example); + app.assert_text("Supported · runtime-tested"); + app.assert_text("Supported · browser-tested"); + app.assert_text("Preview"); +} + +#[test] +fn platform_roadmap_reveals_capabilities_marked_as_planned() { + let mut app = TestApp::new(examples::platform_roadmap::Example::default()); + app.assert_no_text("Health integrations"); + app.click("toggle-roadmap").unwrap(); + app.assert_text("Health integrations"); + app.assert_text("Later"); +} + +#[test] +fn styling_controls_replace_their_own_label() { + let mut app = TestApp::new(examples::styling::Example::default()); + app.click("tokens").unwrap(); + app.assert_text("Reset tokens"); +} + +#[test] +fn async_example_enters_loading_without_blocking_input() { + let mut app = TestApp::new(examples::tasks::Example::default()); + app.click("load").unwrap(); + app.assert_text("Loading…"); + app.key(Key::Tab, Default::default()).unwrap(); +} diff --git a/app_examples/fake-ai-harness/src/app.rs b/app_examples/fake-ai-harness/src/app.rs index f2f7899c..df636d5f 100644 --- a/app_examples/fake-ai-harness/src/app.rs +++ b/app_examples/fake-ai-harness/src/app.rs @@ -191,34 +191,6 @@ impl AiHarness { } } - fn handle_input(&mut self, event: &argui::ui::UiEvent, cx: &mut Context) { - if event.target_key() == Some("prompt") - && let UiEventKind::TextChanged(value) = &event.kind - { - self.prompt.clone_from(value); - cx.notify(); - } - } - - fn handle_submit(&mut self, event: &argui::ui::UiEvent, cx: &mut Context) { - if event.target_key() == Some("prompt") { - self.start(cx); - } - } - - fn handle_click(&mut self, event: &argui::ui::UiEvent, cx: &mut Context) { - match event.target_key() { - Some("conversation::latest") => { - let request = self.message_scroll.latest("conversation"); - self.conversation_offset = self.scroll_maximum; - cx.scroll(request); - cx.notify(); - } - Some("run") => self.start(cx), - _ => {} - } - } - fn handle_scroll(&mut self, event: &argui::ui::UiEvent, cx: &mut Context) { let mut window_changed = false; if event.target_key() == Some("conversation") @@ -245,9 +217,6 @@ impl Render for AiHarness { let theme = themes.resolve(environment.color_scheme); self.view(cx, theme) .safe_area(environment.safe_area_insets) - .on(cx.listener(EventType::Input, Self::handle_input)) - .on(cx.listener(EventType::Submit, Self::handle_submit)) - .on(cx.listener(EventType::Click, Self::handle_click)) .on(cx.listener(EventType::Scroll, Self::handle_scroll)) } diff --git a/app_examples/fake-ai-harness/src/app/view.rs b/app_examples/fake-ai-harness/src/app/view.rs index 7bc1a3d7..4941df79 100644 --- a/app_examples/fake-ai-harness/src/app/view.rs +++ b/app_examples/fake-ai-harness/src/app/view.rs @@ -17,6 +17,12 @@ use argui::{ impl AiHarness { pub(super) fn view(&self, cx: &mut Context, theme: &WidgetTheme) -> Element { + let latest_handler = cx.event_handler(|app, _, cx| { + let request = app.message_scroll.latest("conversation"); + app.conversation_offset = app.scroll_maximum; + cx.scroll(request); + cx.notify(); + }); let mut body_children = vec![ self.conversation(cx, theme) .grow(1.0) @@ -29,6 +35,12 @@ impl AiHarness { .width(percent(1.0)) .gap(if self.compact { 0.0 } else { 16.0 }) .align_items(AlignItems::START); + let mut body_layers = vec![body]; + body_layers.extend(self.latest_button(theme, latest_handler)); + let body = Element::container(body_layers) + .width(percent(1.0)) + .min_width(length(0.0)) + .position(Position::Relative); Element::column([self.header(theme), body]) .width(percent(1.0)) .height(percent(1.0)) @@ -73,6 +85,9 @@ impl AiHarness { } fn conversation(&self, cx: &mut Context, theme: &WidgetTheme) -> Element { + let input_handler = cx.input_callback(|app, prompt| app.prompt = prompt); + let submit_handler = cx.submit_event_handler(|app, _, _, cx| app.start(cx)); + let run_handler = cx.event_handler(|app, _, cx| app.start(cx)); let messages = if self.submitted_prompt.is_empty() { Element::column([ text("Send a message", 20.0, theme.foreground, 650), @@ -116,6 +131,8 @@ impl AiHarness { ) .kind(InputKind::Text) .label("Prompt") + .on_input(input_handler) + .on_submit(submit_handler) .build() .width(auto()) .flex_basis(percent(0.8)) @@ -132,6 +149,7 @@ impl AiHarness { theme.button(), ) .enabled(!self.prompt.trim().is_empty()) + .on_click(run_handler) .build() .width(auto()) .flex_basis(percent(0.2)) @@ -142,18 +160,18 @@ impl AiHarness { .align_items(AlignItems::CENTER); let content = Element::column([messages, controls]).gap(if self.compact { 7.0 } else { 10.0 }); - let conversation = panel("Conversation", content, theme) + panel("Conversation", content, theme) .padding(Sides::length(if self.compact { 10.0 } else { 15.0 })) - .gap(if self.compact { 8.0 } else { 13.0 }); - let mut children = vec![conversation]; - children.extend(self.latest_button(theme)); - Element::container(children) + .gap(if self.compact { 8.0 } else { 13.0 }) .width(percent(1.0)) .min_width(length(0.0)) - .position(Position::Relative) } - fn latest_button(&self, theme: &WidgetTheme) -> Option { + fn latest_button( + &self, + theme: &WidgetTheme, + handler: argui::ui::EventHandler, + ) -> Option { (!self.message_scroll.following).then(|| { let mut button = Button::icon( "conversation::latest", @@ -163,6 +181,7 @@ impl AiHarness { .vector_color(theme.foreground), theme.outline_button(), ) + .on_click(handler) .tooltip(format!( "Go to latest message · {} unread", self.message_scroll.unread diff --git a/app_examples/fake-ai-harness/src/lib.rs b/app_examples/fake-ai-harness/src/lib.rs index 82aca79a..d1efb8e6 100644 --- a/app_examples/fake-ai-harness/src/lib.rs +++ b/app_examples/fake-ai-harness/src/lib.rs @@ -13,6 +13,12 @@ use argui::{ const NOTO_SANS: &[u8] = include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoSans-Regular.ttf"); +const NOTO_ARABIC: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoSansArabic.ttf"); +const NOTO_HEBREW: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoSansHebrew.ttf"); +const NOTO_EMOJI: &[u8] = + include_bytes!("../../../crates/argui-web-demo/assets/fonts/NotoEmoji-Regular.ttf"); #[cfg(target_arch = "wasm32")] #[wasm_bindgen::prelude::wasm_bindgen( @@ -63,7 +69,12 @@ pub fn start() -> Result<(), wasm_bindgen::JsValue> { } pub fn launch() -> Result<(), Box> { - let text = TextEngine::from_embedded_fonts([NOTO_SANS], "Noto Sans", "Noto Sans", "Noto Sans"); + let text = TextEngine::from_embedded_fonts( + [NOTO_SANS, NOTO_ARABIC, NOTO_HEBREW, NOTO_EMOJI], + "Noto Sans", + "Noto Sans", + "Noto Sans", + ); run_application_with_text_engine( ApplicationConfig::new( ApplicationIdentity::development("Argui AI Harness"), diff --git a/crates/argui-android/android/src/main/java/dev/argui/android/ForegroundTaskService.java b/crates/argui-android/android/src/main/java/dev/argui/android/ForegroundTaskService.java new file mode 100644 index 00000000..2b3d0280 --- /dev/null +++ b/crates/argui-android/android/src/main/java/dev/argui/android/ForegroundTaskService.java @@ -0,0 +1,136 @@ +package dev.argui.android; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; + +/** Owns an ongoing notification while an Argui task continues outside its activity window. */ +public final class ForegroundTaskService extends Service { + static final String ACTION_START = "dev.argui.android.action.START"; + static final String ACTION_UPDATE = "dev.argui.android.action.UPDATE"; + static final String EXTRA_TITLE = "dev.argui.android.extra.TITLE"; + static final String EXTRA_MESSAGE = "dev.argui.android.extra.MESSAGE"; + static final String EXTRA_PROGRESS = "dev.argui.android.extra.PROGRESS"; + + private static final String CHANNEL_ID = "argui_background_activity"; + private static final int NOTIFICATION_ID = 6108; + + private String title = "Argui activity"; + private String message = "Working in the background"; + private int progress; + private int notificationIcon; + + @Override + public void onCreate() { + super.onCreate(); + notificationIcon = getResources().getIdentifier( + "ic_notification_activity", "drawable", getPackageName()); + if (notificationIcon == 0) { + throw new IllegalStateException("ic_notification_activity drawable is required"); + } + createNotificationChannel(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + stopSelf(startId); + return START_NOT_STICKY; + } + String action = intent.getAction(); + if (ACTION_START.equals(action)) { + String nextTitle = intent.getStringExtra(EXTRA_TITLE); + String nextMessage = intent.getStringExtra(EXTRA_MESSAGE); + if (nextTitle != null) { + title = nextTitle; + } + if (nextMessage != null) { + message = nextMessage; + } + progress = 0; + } else if (ACTION_UPDATE.equals(action)) { + progress = Math.max(0, Math.min(100, intent.getIntExtra(EXTRA_PROGRESS, progress))); + String nextMessage = intent.getStringExtra(EXTRA_MESSAGE); + if (nextMessage != null) { + message = nextMessage; + } + } + startAsForeground(); + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + stopForeground(STOP_FOREGROUND_REMOVE); + super.onDestroy(); + } + + /** Stops cleanly when Android expires the foreground-service time allowance. */ + @Override + public void onTimeout(int startId, int foregroundServiceType) { + stopForeground(STOP_FOREGROUND_REMOVE); + stopSelf(startId); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + /** Creates the silent, low-importance notification channel used by this service. */ + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT < 26) { + return; + } + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, "Background activity", NotificationManager.IMPORTANCE_LOW); + channel.setDescription("Progress for work started in Argui"); + channel.setSound(null, null); + channel.enableVibration(false); + getSystemService(NotificationManager.class).createNotificationChannel(channel); + } + + /** Publishes the foreground-service notification with the current task progress. */ + private void startAsForeground() { + Notification notification = buildNotification(); + if (Build.VERSION.SDK_INT >= 29) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + } else { + startForeground(NOTIFICATION_ID, notification); + } + } + + /** Builds the ongoing progress notification and its tap-to-return action. */ + private Notification buildNotification() { + Intent launchIntent = getPackageManager().getLaunchIntentForPackage(getPackageName()); + PendingIntent contentIntent = launchIntent == null + ? null + : PendingIntent.getActivity( + this, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + Notification.Builder builder = new Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(notificationIcon) + .setContentTitle(title) + .setContentText(message) + .setCategory(Notification.CATEGORY_PROGRESS) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setProgress(100, progress, false); + if (contentIntent != null) { + builder.setContentIntent(contentIntent); + } + return builder.build(); + } +} diff --git a/crates/argui-android/android/src/main/java/dev/argui/android/MobileActivityHost.java b/crates/argui-android/android/src/main/java/dev/argui/android/MobileActivityHost.java new file mode 100644 index 00000000..d9670945 --- /dev/null +++ b/crates/argui-android/android/src/main/java/dev/argui/android/MobileActivityHost.java @@ -0,0 +1,55 @@ +package dev.argui.android; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; + +/** JNI entry points that start and update Argui's Android foreground activity. */ +public final class MobileActivityHost { + private static final int NOTIFICATION_PERMISSION_REQUEST = 6107; + + private MobileActivityHost() {} + + /** Requests Android 13+ notification access, returning whether it is already granted. */ + public static boolean prepareNotificationPermission(Activity activity) { + if (Build.VERSION.SDK_INT < 33 + || activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + return true; + } + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + activity.requestPermissions( + new String[] {Manifest.permission.POST_NOTIFICATIONS}, + NOTIFICATION_PERMISSION_REQUEST); + } + }); + return false; + } + + /** Starts the declared foreground service with its initial notification content. */ + public static void start(Activity activity, String title, String message) { + Intent intent = new Intent(activity, ForegroundTaskService.class) + .setAction(ForegroundTaskService.ACTION_START) + .putExtra(ForegroundTaskService.EXTRA_TITLE, title) + .putExtra(ForegroundTaskService.EXTRA_MESSAGE, message); + activity.startForegroundService(intent); + } + + /** Sends a progress update to the existing foreground service. */ + public static void update(Activity activity, int percent, String message) { + Intent intent = new Intent(activity, ForegroundTaskService.class) + .setAction(ForegroundTaskService.ACTION_UPDATE) + .putExtra(ForegroundTaskService.EXTRA_PROGRESS, percent) + .putExtra(ForegroundTaskService.EXTRA_MESSAGE, message); + activity.startService(intent); + } + + /** Stops the service and removes its notification. */ + public static void finish(Activity activity) { + activity.stopService(new Intent(activity, ForegroundTaskService.class)); + } +} diff --git a/crates/argui-core/src/input.rs b/crates/argui-core/src/input.rs index 7b86b9ba..6badb3e1 100644 --- a/crates/argui-core/src/input.rs +++ b/crates/argui-core/src/input.rs @@ -5,6 +5,7 @@ use crate::{Modifiers, Point}; pub struct PointerSettings { multi_click_interval: std::time::Duration, multi_click_distance: f32, + activation_slop: f32, long_press_interval: std::time::Duration, touch_slop: f32, } @@ -14,6 +15,7 @@ impl Default for PointerSettings { Self { multi_click_interval: std::time::Duration::from_millis(500), multi_click_distance: 5.0, + activation_slop: 10.0, long_press_interval: std::time::Duration::from_millis(500), touch_slop: 10.0, } @@ -34,6 +36,21 @@ impl PointerSettings { self } + /// Sets the maximum movement allowed before a pointer press stops activating its target. + /// + /// * `distance` — maximum movement in logical pixels from the initial press position. + /// + /// Returns the updated pointer settings. + /// + /// # Panics + /// Panics if `distance` is negative or not finite. + #[must_use] + pub fn activation_slop(mut self, distance: f32) -> Self { + assert!(distance.is_finite() && distance >= 0.0); + self.activation_slop = distance; + self + } + #[must_use] /// Sets the long-press delay and maximum movement tolerated for touch input. /// * `interval` — minimum press duration before recognition. @@ -59,6 +76,12 @@ impl PointerSettings { self.multi_click_distance } + /// Returns the maximum movement in logical pixels that preserves pointer activation. + #[must_use] + pub const fn activation_slop_distance(self) -> f32 { + self.activation_slop + } + #[must_use] /// Returns the configured long-press interval. pub const fn long_press_interval(self) -> std::time::Duration { diff --git a/crates/argui-core/tests/input.rs b/crates/argui-core/tests/input.rs index e3ca8495..5edc01c6 100644 --- a/crates/argui-core/tests/input.rs +++ b/crates/argui-core/tests/input.rs @@ -15,12 +15,14 @@ fn scroll_units_stay_explicit_until_ui_dispatch() { fn pointer_thresholds_are_configurable_as_one_policy() { let settings = PointerSettings::default() .multi_click(std::time::Duration::from_millis(240), 3.0) + .activation_slop(8.0) .long_press(std::time::Duration::from_millis(650), 12.0); assert_eq!( settings.multi_click_interval(), std::time::Duration::from_millis(240) ); assert_eq!(settings.multi_click_distance(), 3.0); + assert_eq!(settings.activation_slop_distance(), 8.0); assert_eq!( settings.long_press_interval(), std::time::Duration::from_millis(650) @@ -52,6 +54,18 @@ fn touch_slop_rejects_nan() { let _ = PointerSettings::default().long_press(std::time::Duration::from_millis(650), f32::NAN); } +#[test] +#[should_panic] +fn activation_slop_rejects_negative_values() { + let _ = PointerSettings::default().activation_slop(-1.0); +} + +#[test] +#[should_panic] +fn activation_slop_rejects_nan() { + let _ = PointerSettings::default().activation_slop(f32::NAN); +} + #[test] fn pointer_events_keep_device_identity_and_contact_data() { let event = PointerEvent { diff --git a/crates/argui-devtools/src/app/rendering.rs b/crates/argui-devtools/src/app/rendering.rs index 2451de81..ec523032 100644 --- a/crates/argui-devtools/src/app/rendering.rs +++ b/crates/argui-devtools/src/app/rendering.rs @@ -6,6 +6,7 @@ impl DevtoolsApp { window: &WindowKey, environment: WindowEnvironment, ) -> Option { + let safe_area = environment.safe_area_insets; { let mut tools = self.tools.borrow_mut(); tools.reduced_motion = environment.reduced_motion; @@ -36,7 +37,7 @@ impl DevtoolsApp { return Some(app); } let tools = self.tools.borrow(); - let mut root = view::host(&tools, app, theme, None); + let mut root = view::host(&tools, app, theme, None, safe_area); if let Some(error) = &self.error { root.children.push(Element::text(format!( "Could not open developer tools window: {error}" diff --git a/crates/argui-devtools/src/host.rs b/crates/argui-devtools/src/host.rs index 1c22efdf..6ec01f07 100644 --- a/crates/argui-devtools/src/host.rs +++ b/crates/argui-devtools/src/host.rs @@ -404,7 +404,13 @@ impl Render for DevtoolsHost { } let app = cx.entity_in(&self.app, self.theme_editing.environment()); let themes = argui_widgets::shadcn(&environment); - let mut root = view::host(self, app, themes.resolve(environment.color_scheme), None); + let mut root = view::host( + self, + app, + themes.resolve(environment.color_scheme), + None, + environment.safe_area_insets, + ); for event in EventType::ALL { root = root.on(cx .listener(event, |host, event, cx| { diff --git a/crates/argui-devtools/src/host/tree.rs b/crates/argui-devtools/src/host/tree.rs index 222e7a9a..e1ad9cc0 100644 --- a/crates/argui-devtools/src/host/tree.rs +++ b/crates/argui-devtools/src/host/tree.rs @@ -48,14 +48,14 @@ impl DevtoolsHost { nodes: &'a [TreeNode], selected: Option<&'a str>, ) -> TreeView<'a> { - TreeView { + TreeView::new( nodes, selected, - collapsed: &self.collapsed, - list: VList::new("__devtools-tree", 28.0, self.tree_height, self.tree_offset) + &self.collapsed, + VList::new("__devtools-tree", 28.0, self.tree_height, self.tree_offset) .effects(self.scroll_effect.clone()), - disclosure: Some(self.icons.chevron), - } + ) + .disclosure(Some(self.icons.chevron)) } } diff --git a/crates/argui-devtools/src/view.rs b/crates/argui-devtools/src/view.rs index db6c8a60..2982c67a 100644 --- a/crates/argui-devtools/src/view.rs +++ b/crates/argui-devtools/src/view.rs @@ -1,3 +1,4 @@ +use argui_core::Insets; use argui_inspect::NodeSnapshot; use argui_paint::{Border, Color, CornerRadii, LayerStyle, PaintStyle, QuadStyle, VectorId}; use argui_text::{TextColor, TextStyle, TextWrap}; @@ -24,6 +25,7 @@ pub(crate) fn host( app: Element, theme: &WidgetTheme, splitter_listener: Option, + safe_area: Insets, ) -> Element { let application = Element::container([app .width(percent(1.0)) @@ -39,7 +41,7 @@ pub(crate) fn host( y: Overflow::Hidden, }); let mut toggle = toggle_button(false, theme) - .absolute(top_right(14.0, 14.0)) + .absolute(top_right(14.0 + safe_area.top, 14.0 + safe_area.right)) .z_index(30_000); if tools.open || tools.sheet_progress > 0.001 { if let Some(interaction) = &mut toggle.interaction { @@ -49,7 +51,7 @@ pub(crate) fn host( } let mut children = vec![application, toggle]; if tools.dock_mode != crate::DockMode::Detached { - children.push(dock_surface(tools, theme, splitter_listener)); + children.push(dock_surface(tools, theme, splitter_listener, safe_area)); } if tools.picking { children.push(picker_surface(tools)); @@ -71,6 +73,7 @@ fn dock_surface( tools: &DevtoolsHost, theme: &WidgetTheme, splitter_listener: Option, + safe_area: Insets, ) -> Element { let extent = tools.dock_extent(); let height = extent + 6.0; @@ -96,6 +99,21 @@ fn dock_surface( .width(if right { length(height) } else { percent(1.0) }) .height(if right { percent(1.0) } else { length(height) }) .shrink(0.0); + let safe_padding = if right { + Sides { + left: length(0.0), + right: length(safe_area.right * tools.sheet_progress), + top: length(safe_area.top * tools.sheet_progress), + bottom: length(safe_area.bottom * tools.sheet_progress), + } + } else { + Sides { + left: length(safe_area.left * tools.sheet_progress), + right: length(safe_area.right * tools.sheet_progress), + top: length(0.0), + bottom: length(safe_area.bottom * tools.sheet_progress), + } + }; Element::container([surface]) .keyed("__devtools-surface") .width(if right { @@ -109,6 +127,8 @@ fn dock_surface( length(height * tools.sheet_progress) }) .shrink(0.0) + .padding(safe_padding) + .background(theme.background) .overflow(Axes { x: Overflow::Hidden, y: Overflow::Hidden, diff --git a/crates/argui-devtools/tests/view.rs b/crates/argui-devtools/tests/view.rs index 747d8479..d04f9bd0 100644 --- a/crates/argui-devtools/tests/view.rs +++ b/crates/argui-devtools/tests/view.rs @@ -1,9 +1,9 @@ use argui_animation::{Duration, Frame, Time}; -use argui_core::{Point, Size}; +use argui_core::{Insets, Point, Size}; use argui_devtools::DevtoolsHost; use argui_inspect::{AdapterRecord, FrameRecord, GpuFrameRecord, GpuPassRecord}; use argui_layout::LayoutEngine; -use argui_runtime::{LayoutBounds, LayoutSnapshot, ViewUpdate}; +use argui_runtime::{LayoutBounds, LayoutSnapshot, ViewUpdate, WindowEnvironment}; use argui_showcase::{StateShowcase, text_engine}; use argui_ui::{Element, UiEvent, UiEventKind, UiTree}; @@ -109,7 +109,13 @@ fn open_dock_reserves_application_viewport_space() { let host = argui_runtime::Entity::new(DevtoolsHost::new(StateShowcase::default()).open(true)) .mount() .unwrap(); - let mut tree = UiTree::new(host.render(Default::default()).unwrap()); + let mut tree = UiTree::new( + host.render(WindowEnvironment { + safe_area_insets: Insets::new(44.0, 12.0, 24.0, 8.0), + ..WindowEnvironment::default() + }) + .unwrap(), + ); let mut layout = LayoutEngine::new(); let output = layout .compute(&mut tree, &mut text_engine(), Size::new(1_100.0, 700.0)) @@ -127,12 +133,24 @@ fn open_dock_reserves_application_viewport_space() { let application = keyed("__devtools-app-root"); let surface = keyed("__devtools-surface"); let splitter = keyed("__devtools-splitter"); + let (surface_index, surface_node) = tree + .node_ids() + .iter() + .copied() + .enumerate() + .find(|(_, node)| tree.key(*node) == Some("__devtools-surface")) + .unwrap(); assert!(application.size.height < output.viewport.size.height); assert_eq!( surface.origin.y + surface.size.height, output.viewport.size.height ); assert_eq!(splitter.origin.y, surface.origin.y); + assert!( + tree.resolved_quad(surface_node, tree.element_at(surface_index).unwrap()) + .background + .is_some() + ); } #[test] @@ -167,6 +185,42 @@ fn closed_dock_keeps_a_visible_overlay_button_without_stealing_app_height() { assert!((1_100.0 - toggle.origin.x - toggle.size.width - 14.0).abs() < 0.01); } +#[test] +fn devtools_overlay_respects_native_safe_area_insets() { + let host = argui_runtime::Entity::new(DevtoolsHost::new(StateShowcase::default())) + .mount() + .unwrap(); + let insets = Insets::new(44.0, 12.0, 24.0, 8.0); + let mut tree = UiTree::new( + host.render(WindowEnvironment { + safe_area_insets: insets, + ..WindowEnvironment::default() + }) + .unwrap(), + ); + let output = LayoutEngine::new() + .compute(&mut tree, &mut text_engine(), Size::new(390.0, 844.0)) + .unwrap(); + let bounds = |key: &str| { + tree.node_ids() + .iter() + .copied() + .enumerate() + .find_map(|(index, node)| { + (tree.key(node) == Some(key)).then(|| output.nodes[index].bounds) + }) + .unwrap() + }; + let toggle = bounds("__devtools-toggle"); + let application = bounds("__devtools-app-root"); + let surface = bounds("__devtools-surface"); + + assert!((toggle.origin.y - insets.top - 14.0).abs() < 0.01); + assert!((390.0 - insets.right - toggle.origin.x - toggle.size.width - 14.0).abs() < 0.01); + assert_eq!(application.size.height, 844.0); + assert_eq!(surface.size.height, 0.0); +} + #[test] fn real_showcase_lowers_both_devtools_button_and_page_scrollbar() { let host = argui_runtime::Entity::new(DevtoolsHost::new(StateShowcase::default())) diff --git a/crates/argui-inspect/src/records.rs b/crates/argui-inspect/src/records.rs index b013d41b..3cd12c13 100644 --- a/crates/argui-inspect/src/records.rs +++ b/crates/argui-inspect/src/records.rs @@ -135,6 +135,7 @@ impl FrameRecord { pub enum Invalidation { #[default] None, + Composite, Paint, Layout, } diff --git a/crates/argui-inspect/src/trace.rs b/crates/argui-inspect/src/trace.rs index cdfddcf1..5aa075a5 100644 --- a/crates/argui-inspect/src/trace.rs +++ b/crates/argui-inspect/src/trace.rs @@ -116,6 +116,7 @@ struct TraceGpuPass { #[serde(rename_all = "lowercase")] enum TraceInvalidation { None, + Composite, Paint, Layout, } @@ -329,6 +330,7 @@ impl From for TraceInvalidation { fn from(value: Invalidation) -> Self { match value { Invalidation::None => Self::None, + Invalidation::Composite => Self::Composite, Invalidation::Paint => Self::Paint, Invalidation::Layout => Self::Layout, } @@ -339,6 +341,7 @@ impl From for Invalidation { fn from(value: TraceInvalidation) -> Self { match value { TraceInvalidation::None => Self::None, + TraceInvalidation::Composite => Self::Composite, TraceInvalidation::Paint => Self::Paint, TraceInvalidation::Layout => Self::Layout, } diff --git a/crates/argui-inspect/tests/lib.rs b/crates/argui-inspect/tests/lib.rs index 1f21dea5..87d95189 100644 --- a/crates/argui-inspect/tests/lib.rs +++ b/crates/argui-inspect/tests/lib.rs @@ -362,7 +362,7 @@ fn gpu_trace_round_trip_preserves_strict_timeline_data() { }); inspector.select(Some(InspectNodeId(9))); inspector.record_ui(FrameRecord { - update: Invalidation::Paint, + update: Invalidation::Composite, vector_atlas_entries: 5, vector_atlas_hits: 8, vector_rasterizations: 1, @@ -399,7 +399,7 @@ fn gpu_trace_round_trip_preserves_strict_timeline_data() { imported.import_trace_json(&json).unwrap(); let frame = imported.frames().pop().unwrap(); let pass = &frame.gpu.unwrap().passes[0]; - assert_eq!(frame.update, Invalidation::Paint); + assert_eq!(frame.update, Invalidation::Composite); assert_eq!(frame.adapter.features, "TIMESTAMP_QUERY"); assert_eq!(frame.vector_atlas_entries, 5); assert_eq!(frame.vector_atlas_hits, 8); diff --git a/crates/argui-ios/swift/activity-kit/app/ArguiActivityBridge.swift b/crates/argui-ios/swift/activity-kit/app/ArguiActivityBridge.swift new file mode 100644 index 00000000..eeb051cd --- /dev/null +++ b/crates/argui-ios/swift/activity-kit/app/ArguiActivityBridge.swift @@ -0,0 +1,117 @@ +import ActivityKit +import Foundation + +private enum ArguiActivityBridge { + @MainActor private static var nextIdentifier: UInt64 = 1 + @available(iOS 16.1, *) + @MainActor private static var activities: [UInt64: Activity] = [:] + + static func onMain( + _ operation: @escaping @MainActor @Sendable () -> T + ) -> T { + if Thread.isMainThread { + return MainActor.assumeIsolated(operation) + } + return DispatchQueue.main.sync { + MainActor.assumeIsolated(operation) + } + } + + @MainActor static func start(title: String, message: String) -> UInt64 { + guard #available(iOS 16.1, *), ActivityAuthorizationInfo().areActivitiesEnabled else { + return 0 + } + + let attributes = ArguiActivityAttributes(title: title) + let state = ArguiActivityAttributes.ContentState(message: message, progress: 0) + do { + let activity = try Activity.request( + attributes: attributes, + contentState: state, + pushType: nil + ) + let identifier = nextIdentifier + nextIdentifier = nextIdentifier == UInt64.max ? 1 : nextIdentifier + 1 + activities[identifier] = activity + return identifier + } catch { + return 0 + } + } + + @available(iOS 16.1, *) + @MainActor static func update( + identifier: UInt64, + percent: UInt8, + message: String + ) -> Int32 { + guard let activity = activities[identifier] else { + return 0 + } + let state = ArguiActivityAttributes.ContentState( + message: message, + progress: Double(min(percent, 100)) / 100 + ) + Task { + await activity.update(using: state) + } + return 1 + } + + @available(iOS 16.1, *) + @MainActor static func finish(identifier: UInt64) -> Int32 { + guard let activity = activities.removeValue(forKey: identifier) else { + return 0 + } + Task { + await activity.end(using: nil, dismissalPolicy: .immediate) + } + return 1 + } +} + +@_cdecl("argui_ios_activity_start") +public func arguiIOSActivityStart( + _ title: UnsafePointer?, + _ message: UnsafePointer? +) -> UInt64 { + guard let title, let message else { + return 0 + } + let titleText = String(cString: title) + let messageText = String(cString: message) + return ArguiActivityBridge.onMain { + ArguiActivityBridge.start(title: titleText, message: messageText) + } +} + +@_cdecl("argui_ios_activity_update") +public func arguiIOSActivityUpdate( + _ identifier: UInt64, + _ percent: UInt8, + _ message: UnsafePointer? +) -> Int32 { + guard let message else { + return 0 + } + let messageText = String(cString: message) + return ArguiActivityBridge.onMain { + guard #available(iOS 16.1, *) else { + return 0 + } + ArguiActivityBridge.update(identifier: identifier, percent: percent, message: messageText) + } +} + +@_cdecl("argui_ios_activity_finish") +public func arguiIOSActivityFinish(_ identifier: UInt64) -> Int32 { + ArguiActivityBridge.onMain { + guard #available(iOS 16.1, *) else { + return 0 + } + ArguiActivityBridge.finish(identifier: identifier) + } +} + +@_cdecl("argui_ios_activity_bridge_anchor") +public func arguiIOSActivityBridgeAnchor() {} diff --git a/crates/argui-ios/swift/activity-kit/extension/ArguiActivityWidget.swift b/crates/argui-ios/swift/activity-kit/extension/ArguiActivityWidget.swift new file mode 100644 index 00000000..ea8e22fb --- /dev/null +++ b/crates/argui-ios/swift/activity-kit/extension/ArguiActivityWidget.swift @@ -0,0 +1,162 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +private enum ArguiActivityPalette { + static let accent = Color(red: 0.16, green: 0.47, blue: 0.96) + static let accentSoft = Color(red: 0.16, green: 0.47, blue: 0.96).opacity(0.18) + static let background = Color(red: 0.055, green: 0.067, blue: 0.09) + static let foreground = Color.white + static let secondary = Color.white.opacity(0.7) +} + +private extension ArguiActivityAttributes.ContentState { + var boundedProgress: Double { + min(max(progress, 0), 1) + } + + var percentage: Int { + Int((boundedProgress * 100).rounded()) + } + + var isComplete: Bool { + boundedProgress >= 1 + } +} + +private struct ArguiActivityMark: View { + let compact: Bool + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: compact ? 7 : 10, style: .continuous) + .fill(ArguiActivityPalette.accentSoft) + Image(systemName: "square.stack.3d.up.fill") + .font(compact ? .caption2 : .subheadline) + .foregroundStyle(ArguiActivityPalette.accent) + } + .frame(width: compact ? 24 : 36, height: compact ? 24 : 36) + .accessibilityHidden(true) + } +} + +private struct ArguiProgressBadge: View { + let state: ArguiActivityAttributes.ContentState + + var body: some View { + Text(state.isComplete ? "Terminé" : "\(state.percentage)%") + .font(.caption.weight(.semibold).monospacedDigit()) + .foregroundStyle(state.isComplete ? Color.green : ArguiActivityPalette.accent) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background( + Capsule(style: .continuous) + .fill((state.isComplete ? Color.green : ArguiActivityPalette.accent).opacity(0.16)) + ) + .accessibilityLabel(state.isComplete ? "Activité terminée" : "Progression \(state.percentage) pour cent") + } +} + +private struct ArguiLockScreenActivityView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + ArguiActivityMark(compact: false) + VStack(alignment: .leading, spacing: 2) { + Text(context.attributes.title) + .font(.headline.weight(.semibold)) + .foregroundStyle(ArguiActivityPalette.foreground) + .lineLimit(1) + Text(context.state.message) + .font(.subheadline) + .foregroundStyle(ArguiActivityPalette.secondary) + .lineLimit(2) + } + Spacer(minLength: 8) + ArguiProgressBadge(state: context.state) + } + + ProgressView(value: context.state.boundedProgress) + .progressViewStyle(.linear) + .tint(context.state.isComplete ? .green : ArguiActivityPalette.accent) + .accessibilityLabel("Progression de l’activité") + .accessibilityValue("\(context.state.percentage) pour cent") + + HStack(spacing: 5) { + Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "bolt.horizontal.circle.fill") + Text(context.state.isComplete ? "Synchronisation terminée" : "Argui continue en arrière-plan") + } + .font(.caption2.weight(.medium)) + .foregroundStyle(ArguiActivityPalette.secondary) + } + .padding(16) + .activityBackgroundTint(ArguiActivityPalette.background) + .activitySystemActionForegroundColor(ArguiActivityPalette.accent) + } +} + +@main +struct ArguiActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: ArguiActivityAttributes.self) { context in + ArguiLockScreenActivityView(context: context) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack(spacing: 7) { + ArguiActivityMark(compact: true) + Text("ARGUI") + .font(.caption2.weight(.bold)) + .foregroundStyle(ArguiActivityPalette.secondary) + } + } + DynamicIslandExpandedRegion(.trailing) { + ArguiProgressBadge(state: context.state) + } + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.title) + .font(.headline.weight(.semibold)) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 8) { + Text(context.state.message) + .font(.subheadline) + .foregroundStyle(ArguiActivityPalette.secondary) + .lineLimit(2) + ProgressView(value: context.state.boundedProgress) + .progressViewStyle(.linear) + .tint(context.state.isComplete ? .green : ArguiActivityPalette.accent) + } + .padding(.top, 2) + } + } compactLeading: { + Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "square.stack.3d.up.fill") + .foregroundStyle(context.state.isComplete ? .green : ArguiActivityPalette.accent) + } compactTrailing: { + Text("\(context.state.percentage)%") + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(context.state.isComplete ? .green : ArguiActivityPalette.accent) + } minimal: { + ZStack { + Circle() + .stroke(ArguiActivityPalette.secondary.opacity(0.35), lineWidth: 2) + Circle() + .trim(from: 0, to: context.state.boundedProgress) + .stroke( + context.state.isComplete ? Color.green : ArguiActivityPalette.accent, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + Image(systemName: context.state.isComplete ? "checkmark" : "arrow.up") + .font(.system(size: 8, weight: .bold)) + } + .padding(4) + .accessibilityLabel("Progression \(context.state.percentage) pour cent") + } + .keylineTint(ArguiActivityPalette.accent) + } + } +} diff --git a/crates/argui-ios/swift/activity-kit/shared/ArguiActivityAttributes.swift b/crates/argui-ios/swift/activity-kit/shared/ArguiActivityAttributes.swift new file mode 100644 index 00000000..39fcdfbf --- /dev/null +++ b/crates/argui-ios/swift/activity-kit/shared/ArguiActivityAttributes.swift @@ -0,0 +1,11 @@ +import ActivityKit + +@available(iOS 16.1, *) +struct ArguiActivityAttributes: ActivityAttributes, Sendable { + struct ContentState: Codable, Hashable, Sendable { + var message: String + var progress: Double + } + + var title: String +} diff --git a/crates/argui-layout/src/composite.rs b/crates/argui-layout/src/composite.rs new file mode 100644 index 00000000..f0f09c46 --- /dev/null +++ b/crates/argui-layout/src/composite.rs @@ -0,0 +1,333 @@ +use std::{collections::HashMap, mem::size_of}; + +use argui_core::{Affine2D, Point, Rect}; +use argui_paint::{ClipChain, CompositorId, CompositorPatch, DisplayCommand, DisplayList}; +use argui_ui::{HitRegion, NodeId, ScrollRegion, UiTree}; + +use crate::{DesktopBackdropRegion, LayoutOutput, TextRegion, engine::LayoutEngine}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct CompositeGeometry { + owners: HashMap, + nodes: HashMap, + carets: HashMap, + hit_regions: Vec, + semantic_bounds: Vec<(NodeId, argui_core::Rect)>, + text_regions: Vec, + scroll_regions: Vec, + desktop_backdrops: Vec, +} + +impl CompositeGeometry { + /// Captures the painted presentation baseline and compositor ownership map. + pub(crate) fn capture(output: &LayoutOutput) -> Self { + Self { + owners: output.compositor_owners.clone(), + nodes: output + .nodes + .iter() + .enumerate() + .filter_map(|(index, node)| { + let id = CompositorId::new(node.node.get()); + (output.compositor_owners.get(&node.node) == Some(&id)).then_some((id, index)) + }) + .collect(), + carets: output + .text_inputs + .iter() + .enumerate() + .filter_map(|(index, region)| { + (region.caret.is_some() + && region + .caret_style + .animation + .as_ref() + .is_some_and(argui_ui::CaretAnimation::supports_composition)) + .then_some((crate::input::caret_compositor_id(region.node), index)) + }) + .collect(), + hit_regions: output.hit_regions.clone(), + semantic_bounds: output.semantic_bounds.clone(), + text_regions: output.text_regions.clone(), + scroll_regions: output.scroll_regions.clone(), + desktop_backdrops: output.desktop_backdrops.clone(), + } + } + + /// Returns retained compositor baseline capacity in bytes, excluding shared clip storage. + fn storage_bytes(&self) -> usize { + self.owners.capacity() * size_of::<(NodeId, CompositorId)>() + + self.nodes.capacity() * size_of::<(CompositorId, usize)>() + + self.carets.capacity() * size_of::<(CompositorId, usize)>() + + self.hit_regions.capacity() * size_of::() + + self.semantic_bounds.capacity() * size_of::<(NodeId, argui_core::Rect)>() + + self.text_regions.capacity() * size_of::() + + self.scroll_regions.capacity() * size_of::() + + self.desktop_backdrops.capacity() * size_of::() + } +} + +impl LayoutOutput { + #[doc(hidden)] + /// Returns the retained compositor baseline's allocated capacity in bytes. + #[must_use] + pub fn compositor_storage_bytes(&self) -> usize { + self.composite_geometry.storage_bytes() + } +} + +impl LayoutEngine { + /// Applies transform and group-opacity changes to retained paint output. + /// + /// This path does not rebuild layout, paint primitives, or shaped text. It + /// updates compositor commands and presentation geometry from the baseline + /// captured by the latest paint pass. + /// + /// * `ui` — retained tree containing the current animated presentation values. + /// * `output` — previously painted output receiving lightweight patches. + /// + /// Returns `false` when a retained layer cannot represent the requested + /// transform, in which case the caller must fall back to a normal repaint. + pub fn composite(&mut self, ui: &UiTree, output: &mut LayoutOutput) -> bool { + let (patches, mut deltas) = + match compositor_patches(ui, output, &output.display_list, output.viewport) { + Some(value) => value, + None => return false, + }; + let mut native_patches = Vec::with_capacity(output.native_surfaces.len()); + for surface in &output.native_surfaces { + let bounds = Rect::new(Point::new(0.0, 0.0), surface.bounds.size); + let Some((patches, surface_deltas)) = + compositor_patches(ui, output, &surface.display_list, bounds) + else { + return false; + }; + let origin = surface.bounds.origin; + let to_global = Affine2D::translation(origin.x, origin.y); + let to_surface = Affine2D::translation(-origin.x, -origin.y); + deltas.extend( + surface_deltas + .into_iter() + .map(|(id, delta)| (id, to_global * delta * to_surface)), + ); + native_patches.push(patches); + } + output.display_list.apply_compositor_patches(&patches); + for (surface, patches) in output.native_surfaces.iter_mut().zip(native_patches) { + surface.display_list.apply_compositor_patches(&patches); + } + apply_geometry(output, &deltas); + true + } +} + +/// Resolves compositor patches and global presentation deltas for one surface. +fn compositor_patches( + ui: &UiTree, + output: &LayoutOutput, + display_list: &DisplayList, + surface_bounds: Rect, +) -> Option<(Vec, HashMap)> { + let mut patches = Vec::new(); + let mut deltas = HashMap::new(); + let mut stack = Vec::new(); + let mut boundaries = Vec::<(bool, Option)>::new(); + for command in display_list.commands() { + match command { + DisplayCommand::BeginLayer(style) => boundaries.push(( + false, + style.requires_offscreen().then(|| style.expanded_bounds()), + )), + DisplayCommand::BeginCompositor(layer) => { + let (current_local, opacity) = + if let Some(index) = output.composite_geometry.nodes.get(&layer.id) { + let node = output.nodes.get(*index)?; + let element = ui.element_at(node.index)?; + ( + ui.resolved_transform(node.node, element) + .affine(node.bounds, element.transform_origin), + element.layer.as_ref().map_or(1.0, |style| { + ui.resolved_layer(node.node, element, style).opacity + }), + ) + } else { + let region = output + .text_inputs + .get(*output.composite_geometry.carets.get(&layer.id)?)?; + let bounds = crate::input::visual_bounds( + region.caret?, + ®ion.caret_style.visual.primitives, + )?; + let frame = ui.resolved_caret_frame(region.node, ®ion.caret_style); + ( + frame + .transform + .affine(bounds, argui_core::TransformOrigin::CENTER), + frame.opacity, + ) + }; + let inverse = layer.base_transform.inverse()?; + let local_delta = layer.base_parent * current_local * inverse; + let inverse_delta = local_delta.inverse()?; + let parent_delta = stack.last().map_or(Affine2D::IDENTITY, |(delta, _)| *delta); + if local_delta != Affine2D::IDENTITY { + let retained_clip = boundaries + .iter() + .rev() + .find_map(|(_, bounds)| *bounds) + .map_or(Some(surface_bounds), |bounds| { + bounds.intersection(surface_bounds) + }); + if !retained_source_covers( + retained_clip, + layer.bounds, + local_delta, + inverse_delta, + ) { + return None; + } + } + let global_delta = parent_delta * local_delta; + patches.push(CompositorPatch::new(layer.id, local_delta, opacity)); + if global_delta != Affine2D::IDENTITY { + deltas.insert(layer.id, global_delta); + } + stack.push((global_delta, layer.bounds)); + boundaries.push((true, Some(layer.bounds))); + } + DisplayCommand::EndLayer => { + if boundaries.pop()?.0 { + return None; + } + } + DisplayCommand::EndCompositor => { + if !boundaries.pop()?.0 { + return None; + } + stack.pop()?; + } + _ => {} + } + } + (stack.is_empty() && boundaries.is_empty()).then_some((patches, deltas)) +} + +/// Returns whether the cached source contains every pixel exposed by `delta`. +fn retained_source_covers( + retained_clip: Option, + source_bounds: Rect, + delta: Affine2D, + inverse_delta: Affine2D, +) -> bool { + let Some(retained_clip) = retained_clip else { + return true; + }; + let Some(visible_output) = delta + .transform_rect(source_bounds) + .intersection(retained_clip) + else { + return true; + }; + let Some(available_source) = source_bounds.intersection(retained_clip) else { + return false; + }; + contains_rect( + available_source, + inverse_delta.transform_rect(visible_output), + ) +} + +/// Returns whether `outer` fully contains the axis-aligned `inner` rectangle. +fn contains_rect(outer: argui_core::Rect, inner: argui_core::Rect) -> bool { + const EPSILON: f32 = 0.01; + inner.origin.x >= outer.origin.x - EPSILON + && inner.origin.y >= outer.origin.y - EPSILON + && inner.origin.x + inner.size.width <= outer.origin.x + outer.size.width + EPSILON + && inner.origin.y + inner.size.height <= outer.origin.y + outer.size.height + EPSILON +} + +/// Restores baseline interaction geometry and applies the latest layer deltas. +fn apply_geometry(output: &mut LayoutOutput, deltas: &HashMap) { + let base = &output.composite_geometry; + let owners = &base.owners; + let mut clip_cache = HashMap::new(); + output.hit_regions.clone_from(&base.hit_regions); + for region in &mut output.hit_regions { + if let Some(delta) = delta_for(region.node, owners, deltas) { + region.transform = delta * region.transform; + } + region.clips = patched_clips(®ion.clips, deltas, &mut clip_cache); + } + output.semantic_bounds.clone_from(&base.semantic_bounds); + for (node, bounds) in &mut output.semantic_bounds { + if let Some(delta) = delta_for(*node, owners, deltas) { + *bounds = delta.transform_rect(*bounds); + } + } + output.text_regions.clone_from(&base.text_regions); + for region in &mut output.text_regions { + if let Some(delta) = delta_for(region.node, owners, deltas) { + region.transform = delta * region.transform; + } + region.clips = patched_clips(®ion.clips, deltas, &mut clip_cache); + } + output.scroll_regions.clone_from(&base.scroll_regions); + for region in &mut output.scroll_regions { + if let Some(delta) = delta_for(region.node, owners, deltas) { + region.transform = delta * region.transform; + } + region.clips = patched_clips(®ion.clips, deltas, &mut clip_cache); + } + output.desktop_backdrops.clone_from(&base.desktop_backdrops); + for region in &mut output.desktop_backdrops { + region.shape = patched_clips(®ion.shape, deltas, &mut clip_cache); + } +} + +/// Returns the current presentation delta inherited by `node`. +fn delta_for( + node: NodeId, + owners: &HashMap, + deltas: &HashMap, +) -> Option { + owners + .get(&node) + .and_then(|owner| deltas.get(owner)) + .copied() +} + +/// Returns a clip chain with retained-owner transforms patched once per shared chain. +fn patched_clips( + clips: &ClipChain, + deltas: &HashMap, + cache: &mut HashMap<(usize, usize), ClipChain>, +) -> ClipChain { + let key = (clips.regions().as_ptr() as usize, clips.regions().len()); + if let Some(patched) = cache.get(&key) { + return patched.clone(); + } + if !clips.regions().iter().any(|clip| { + clip.compositor + .and_then(|id| deltas.get(&id)) + .is_some_and(|delta| *delta != Affine2D::IDENTITY) + }) { + let unchanged = clips.clone(); + cache.insert(key, unchanged.clone()); + return unchanged; + } + let patched = ClipChain::from_regions( + clips + .regions() + .iter() + .copied() + .map(|mut clip| { + if let Some(delta) = clip.compositor.and_then(|id| deltas.get(&id)) { + clip.transform = *delta * clip.transform; + } + clip + }) + .collect::>(), + ); + cache.insert(key, patched.clone()); + patched +} diff --git a/crates/argui-layout/src/engine.rs b/crates/argui-layout/src/engine.rs index 0e2c3997..4d0010d4 100644 --- a/crates/argui-layout/src/engine.rs +++ b/crates/argui-layout/src/engine.rs @@ -9,6 +9,7 @@ use argui_text::{TextBlock, TextEngine, TextScene}; use argui_ui::{ Element, ElementKind, HitRegion, LayoutStyle, NodeId as UiNodeId, ScrollRegion, UiTree, }; +use std::collections::HashMap; use taffy::NodeId; mod compute; @@ -43,6 +44,8 @@ pub struct LayoutOutput { pub desktop_backdrops: Vec, pub paint_stats: PaintStats, pub virtualization_changed: bool, + pub(crate) compositor_owners: HashMap, + pub(crate) composite_geometry: crate::composite::CompositeGeometry, } #[derive(Clone, Debug, PartialEq)] @@ -303,13 +306,14 @@ fn collect_layout( ); let text_clip = crate::text::clip(node, element, placement.clip, bounds); let text_clip = text_clip.unwrap_or_default(); - let mut block = TextBlock::new(content, text_bounds); + let mut block = TextBlock::new(content.clone(), text_bounds); block.clip = text_clip; block.style = style.into_owned(); - if let Some((region, scroll)) = input::prepare( + if let Some((region, scroll, paint)) = input::prepare( ui, node.node, element, + &block.content, text_engine, input::InputPlacement { text: text_bounds, @@ -319,9 +323,7 @@ fn collect_layout( scroll_y: ui.scroll_offset(node.node).y, }, ) { - block.bounds.origin.x -= scroll.x; - block.bounds.origin.y -= scroll.y; - block.bounds.size.height = block.bounds.size.height.max(region.content_size.height); + input::position_input_block(&mut block, text_bounds, scroll, paint, &content, ®ion); text_scroll = Some((region.scroll_content_size(), scroll)); output.text_inputs.push(region); } diff --git a/crates/argui-layout/src/engine/output.rs b/crates/argui-layout/src/engine/output.rs index ee4f225a..9f0eb1c2 100644 --- a/crates/argui-layout/src/engine/output.rs +++ b/crates/argui-layout/src/engine/output.rs @@ -19,6 +19,45 @@ impl LayoutEngine { &mut self, ui: &UiTree, output: &mut LayoutOutput, + ) -> Result<(), LayoutError> { + self.apply_scroll_geometry(ui, output)?; + self.repaint(ui, output); + Ok(()) + } + + /// Recomputes scroll placement and refreshes virtualized editor text when required. + /// + /// * `ui` — current retained UI tree whose scroll offsets are authoritative. + /// * `text_engine` — shaping engine used to prepare newly visible editor lines. + /// * `output` — retained layout output updated in place. + /// + /// Returns whether editor text was reshaped. Callers can use this result to + /// rebuild prepared glyph data only when the visible text window changed. + /// + /// # Errors + /// + /// Returns [`LayoutError::MissingRoot`] if layout has not been computed yet, + /// or propagates errors from scroll and portal layout. + pub fn apply_scroll_with_text( + &mut self, + ui: &mut UiTree, + text_engine: &mut TextEngine, + output: &mut LayoutOutput, + ) -> Result { + let refresh_text = needs_scroll_refresh(ui, output); + self.apply_scroll_geometry(ui, output)?; + if refresh_text { + input::update(ui, text_engine, output); + } + self.repaint(ui, output); + Ok(refresh_text) + } + + /// Updates retained scroll and overlay geometry without repainting the output. + fn apply_scroll_geometry( + &mut self, + ui: &UiTree, + output: &mut LayoutOutput, ) -> Result<(), LayoutError> { let elements = flattened(ui.root()); let root = self.root.as_ref().ok_or(LayoutError::MissingRoot)?; @@ -37,7 +76,6 @@ impl LayoutEngine { )?; crate::overlay::resolve(&self.tree, root, &elements, ui, output)?; self.scroll_anchors = crate::anchor::capture(root, output); - self.repaint(ui, output); Ok(()) } @@ -64,3 +102,23 @@ impl LayoutEngine { self.repaint(ui, output); } } + +/// Reports whether a vertical scroll exposed a new virtualized editor text window. +fn needs_scroll_refresh(ui: &UiTree, output: &LayoutOutput) -> bool { + output.text_inputs.iter().any(|region| { + let offset = ui.scroll_offset(region.node); + if (offset.y - region.scroll_y).abs() <= f32::EPSILON + || region.content_size.height <= region.viewport.size.height + { + return false; + } + output + .nodes + .iter() + .find(|node| node.node == region.node) + .and_then(|node| node.text_index) + .is_some_and(|index| { + output.text.blocks()[index].style.wrap == argui_text::TextWrap::None + }) + }) +} diff --git a/crates/argui-layout/src/input.rs b/crates/argui-layout/src/input.rs index 11b5bd04..f6fd2173 100644 --- a/crates/argui-layout/src/input.rs +++ b/crates/argui-layout/src/input.rs @@ -1,7 +1,9 @@ use argui_core::{Affine2D, Color, Point, Rect, TextPosition}; -use argui_paint::{Border, ClipChain, DisplayList, Fill, Quad, QuadStyle}; +use argui_paint::{ + Border, ClipChain, CompositorId, CompositorLayer, DisplayList, Fill, Quad, QuadStyle, +}; use argui_text::{CaretScroll, CaretStop, TextEngine, TextInputScroll}; -use argui_ui::{CaretStyle, Element, ElementKind, NodeId, UiTree}; +use argui_ui::{CaretStyle, Element, ElementKind, NodeId, TextSelectionHighlight, UiTree}; mod navigation; @@ -31,7 +33,7 @@ pub struct TextInputRegion { pub stops: Vec, pub selection: Vec, pub caret: Option, - pub selection_color: Color, + pub selection_highlight: TextSelectionHighlight, pub caret_style: CaretStyle, pub content_size: argui_core::Size, pub scroll_x: f32, @@ -47,6 +49,12 @@ pub(crate) struct InputPlacement { pub scroll_y: f32, } +pub(crate) struct InputPaint { + content: argui_text::TextContent, + y: f32, + height: f32, +} + impl TextInputRegion { /// Returns the caret position under `point` when it lies within input bounds and clip. /// @@ -194,9 +202,10 @@ pub(crate) fn prepare( ui: &UiTree, node: NodeId, element: &Element, + content: &argui_text::TextContent, engine: &mut TextEngine, placement: InputPlacement, -) -> Option<(TextInputRegion, Point)> { +) -> Option<(TextInputRegion, Point, Option)> { let ElementKind::TextEditor { text, selection, @@ -206,10 +215,9 @@ pub(crate) fn prepare( else { return None; }; - let value = ui.text_input_display(node)?; let cursor = ui.text_input_position(node)?; - let layout = engine.input_layout( - &value, + let layout = engine.input_layout_content( + content, text, placement.text.size, cursor, @@ -243,6 +251,7 @@ pub(crate) fn prepare( .collect(); let mut caret_rect = layout.caret; caret_rect.origin = add(caret_rect.origin, origin); + let paint = visible_no_wrap_content(content, text, placement.text.size, layout.scroll_y); Some(( TextInputRegion { node, @@ -252,16 +261,48 @@ pub(crate) fn prepare( stops, selection: selection_rects, caret: (ui.focused_node() == Some(node)).then_some(caret_rect), - selection_color: *selection, + selection_highlight: element + .selection_highlight + .clone() + .unwrap_or_else(|| TextSelectionHighlight::solid(*selection)), caret_style: caret.clone(), content_size: layout.content_size, scroll_x: layout.scroll_x, scroll_y: layout.scroll_y, }, Point::new(layout.scroll_x, layout.scroll_y), + paint, )) } +fn visible_no_wrap_content( + content: &argui_text::TextContent, + style: &argui_text::TextStyle, + viewport: argui_core::Size, + scroll_y: f32, +) -> Option { + if style.wrap != argui_text::TextWrap::None || style.line_height <= 0.0 { + return None; + } + let offsets = source_line_offsets(content.as_str()); + let visible_lines = (viewport.height / style.line_height).ceil().max(1.0) as usize; + if offsets.len() <= visible_lines + 4 { + return None; + } + let first = ((scroll_y / style.line_height).floor() as usize).saturating_sub(2); + let end_line = (first + visible_lines + 5).min(offsets.len()); + let start = offsets[first]; + let end = offsets + .get(end_line) + .copied() + .unwrap_or_else(|| content.as_str().len()); + Some(InputPaint { + content: content.slice(start..end), + y: first as f32 * style.line_height, + height: (end_line - first) as f32 * style.line_height, + }) +} + pub(crate) fn update(ui: &mut UiTree, engine: &mut TextEngine, output: &mut crate::LayoutOutput) { let elements = crate::engine::flattened(ui.root()); let mut offsets = Vec::new(); @@ -280,21 +321,17 @@ pub(crate) fn update(ui: &mut UiTree, engine: &mut TextEngine, output: &mut crat else { continue; }; - let previous_scroll_x = output.text_inputs[region_index].scroll_x; - let previous_scroll_y = output.text_inputs[region_index].scroll_y; - let viewport_size = output.text_inputs[region_index].viewport.size; + let text_bounds = output.text_inputs[region_index].viewport; let block = &mut output.text.blocks_mut()[text_index]; - let text_bounds = Rect::new( - Point::new( - block.bounds.origin.x + previous_scroll_x, - block.bounds.origin.y + previous_scroll_y, - ), - viewport_size, - ); - if let Some((region, scroll)) = prepare( + let Some((content, _)) = crate::text::content(ui, node.node, element) else { + continue; + }; + block.content = content.clone(); + if let Some((region, scroll, paint)) = prepare( ui, node.node, element, + &content, engine, InputPlacement { text: text_bounds, @@ -304,9 +341,7 @@ pub(crate) fn update(ui: &mut UiTree, engine: &mut TextEngine, output: &mut crat scroll_y: ui.scroll_offset(node.node).y, }, ) { - block.bounds.origin.x = text_bounds.origin.x - scroll.x; - block.bounds.origin.y = text_bounds.origin.y - scroll.y; - block.bounds.size.height = text_bounds.size.height.max(region.content_size.height); + position_input_block(block, text_bounds, scroll, paint, &content, ®ion); if let Some(config) = output .scroll_regions .iter() @@ -343,6 +378,35 @@ pub(crate) fn update(ui: &mut UiTree, engine: &mut TextEngine, output: &mut crat ui.mark_text_input_layout_clean(); } +pub(crate) fn position_input_block( + block: &mut argui_text::TextBlock, + text_bounds: Rect, + scroll: Point, + paint: Option, + content: &argui_text::TextContent, + region: &TextInputRegion, +) { + block.bounds.origin.x = text_bounds.origin.x - scroll.x; + if let Some(paint) = paint { + block.content = paint.content; + block.bounds.origin.y = text_bounds.origin.y + paint.y - scroll.y; + block.bounds.size.height = paint.height; + } else { + block.content = content.clone(); + block.bounds.origin.y = text_bounds.origin.y - scroll.y; + block.bounds.size.height = text_bounds.size.height.max(region.content_size.height); + } +} + +fn source_line_offsets(text: &str) -> Vec { + let mut offsets = vec![0]; + offsets.extend( + text.char_indices() + .filter_map(|(index, character)| (character == '\n').then_some(index + 1)), + ); + offsets +} + pub(crate) fn paint_selection( region: &TextInputRegion, output: &mut DisplayList, @@ -350,7 +414,13 @@ pub(crate) fn paint_selection( clips: &ClipChain, ) { for bounds in ®ion.selection { - push_quad(output, *bounds, region.selection_color, transform, clips); + push_selection_quad( + output, + *bounds, + ®ion.selection_highlight, + transform, + clips, + ); } } @@ -365,31 +435,66 @@ pub(crate) fn paint_caret( return; }; let frame = ui.resolved_caret_frame(region.node, ®ion.caret_style); - if frame.opacity <= 0.0 { - return; - } let primitives = ®ion.caret_style.visual.primitives; let Some(bounds) = visual_bounds(line, primitives) else { return; }; - let transform = transform - * frame - .transform - .affine(bounds, argui_core::TransformOrigin::CENTER); + if clips.regions().is_empty() || bounds.size.width <= 0.0 || bounds.size.height <= 0.0 { + return; + } + let frame_transform = frame + .transform + .affine(bounds, argui_core::TransformOrigin::CENTER); + let composited = region + .caret_style + .animation + .as_ref() + .is_some_and(argui_ui::CaretAnimation::supports_composition) + && transform.inverse().is_some(); + if composited { + let inverse = transform + .inverse() + .expect("the composited caret transform was checked as invertible"); + let mut layer = CompositorLayer::new( + caret_compositor_id(region.node), + transform.transform_rect(bounds), + transform, + transform, + frame.opacity, + ); + layer.update(transform * frame_transform * inverse, frame.opacity); + output.begin_compositor(layer); + } else if frame.opacity <= 0.0 { + return; + } + let primitive_transform = if composited { + transform + } else { + transform * frame_transform + }; for primitive in primitives { push_caret_quad( output, primitive.bounds(line), &primitive.paint, frame.tint, - frame.opacity, - transform, + if composited { 1.0 } else { frame.opacity }, + primitive_transform, clips, ); } + if composited { + output.end_compositor(); + } +} + +/// Returns the stable compositor identity for one text input's caret. +pub(crate) const fn caret_compositor_id(node: NodeId) -> CompositorId { + CompositorId::subpart(node.get(), 1) } -fn visual_bounds(line: Rect, primitives: &[argui_ui::CaretPrimitive]) -> Option { +/// Returns the union of the authored caret primitives around `line`. +pub(crate) fn visual_bounds(line: Rect, primitives: &[argui_ui::CaretPrimitive]) -> Option { let first = primitives.first()?.bounds(line); let (mut left, mut top) = (first.origin.x, first.origin.y); let (mut right, mut bottom) = ( @@ -464,19 +569,19 @@ fn radius_for(bounds: Rect, radii: argui_paint::CornerRadii) -> argui_paint::Cor } } -fn push_quad( +fn push_selection_quad( output: &mut DisplayList, bounds: Rect, - color: Color, + highlight: &TextSelectionHighlight, transform: Affine2D, clips: &ClipChain, ) { if !clips.regions().is_empty() { output.push_quad(Quad { bounds, - background: Some(Fill::Solid(color)), + background: Some(highlight.background.clone()), border: Border::all(0.0, Color::TRANSPARENT), - radii: Default::default(), + radii: radius_for(bounds, highlight.radii), opacity: 1.0, transform, clips: clips.clone(), diff --git a/crates/argui-layout/src/lib.rs b/crates/argui-layout/src/lib.rs index 8d4d2a82..642ed05b 100644 --- a/crates/argui-layout/src/lib.rs +++ b/crates/argui-layout/src/lib.rs @@ -2,6 +2,7 @@ mod anchor; mod assets; +mod composite; mod custom; mod desktop_backdrop; pub use desktop_backdrop::DesktopBackdropRegion; @@ -23,5 +24,5 @@ pub use custom::CustomElementStats; pub use engine::{LayoutEngine, LayoutNode, LayoutOutput, LayoutStorage, PaintStats, PortalLayout}; pub use error::LayoutError; pub use input::TextInputRegion; -pub use selection::TextRegion; +pub use selection::{SelectionHandleGeometry, TextRegion}; pub use surface::NativeSurfacePaint; diff --git a/crates/argui-layout/src/paint.rs b/crates/argui-layout/src/paint.rs index f83aa398..ad002858 100644 --- a/crates/argui-layout/src/paint.rs +++ b/crates/argui-layout/src/paint.rs @@ -1,8 +1,5 @@ use argui_core::{Affine2D, Rect}; -use argui_paint::{ - Border, ClipChain, ClipRegion, Color, DisplayList, ImagePrimitive, Quad, QuadStyle, - VectorPrimitive, -}; +use argui_paint::{ClipChain, ClipRegion, CompositorId, CompositorLayer, DisplayList}; use argui_ui::{EffectScope, Element, ElementKind, NodeId, PointerEvents, UiTree}; use crate::{LayoutNode, LayoutOutput, engine::NodeMap, input, scroll}; @@ -14,8 +11,10 @@ mod effects; mod geometry; mod gpu_canvas; mod portal; +mod primitives; mod sync; use effects::{begin_layer, begin_scope, end_layers, scope_count}; +use primitives::{push_image, push_quad, push_vector}; #[derive(Clone, Debug, PartialEq)] pub(super) struct PaintContext { @@ -24,6 +23,7 @@ pub(super) struct PaintContext { clip_bounds: Rect, hit_allowed: bool, active_portal: Option, + compositor_owner: Option, } #[derive(Clone, Debug)] @@ -45,6 +45,7 @@ pub(crate) fn repaint( output.hit_regions.clear(); output.semantic_bounds.clear(); output.desktop_backdrops.clear(); + output.compositor_owners.clear(); cache.visited = 0; cache.reused = 0; cache.reused_commands = 0; @@ -60,6 +61,7 @@ pub(crate) fn repaint( clip_bounds: output.viewport, hit_allowed: true, active_portal: None, + compositor_owner: None, }; portal::paint( root, @@ -71,6 +73,12 @@ pub(crate) fn repaint( &mut scroll_updates, ); } + output.display_list.resolve_compositor_bounds(); + for surface in &mut output.native_surfaces { + surface.display_list.resolve_compositor_bounds(); + } + let composite_geometry = crate::composite::CompositeGeometry::capture(output); + output.composite_geometry = composite_geometry; output.paint_stats = crate::PaintStats { visited_subtrees: cache.visited, reused_subtrees: cache.reused, @@ -130,6 +138,9 @@ pub(super) fn paint_node( output .semantic_bounds .extend_from_slice(&fragment.semantic_bounds); + output + .compositor_owners + .extend(fragment.compositor_owners.iter().copied()); for update in &fragment.scroll_updates { apply_scroll_update(output, update); scroll_updates.push(update.clone()); @@ -152,6 +163,7 @@ pub(super) fn paint_node( clip_bounds: clip, hit_allowed: parent.hit_allowed, active_portal: parent.active_portal, + compositor_owner: None, }; &portal } else { @@ -160,12 +172,35 @@ pub(super) fn paint_node( let transform = parent.transform * ui.resolved_transform(node.node, element) .affine(node.bounds, element.transform_origin); + let composited = element.needs_compositor_layer(); + let compositor_id = CompositorId::new(node.node.get()); + let compositor_owner = if composited { + Some(compositor_id) + } else { + parent.compositor_owner + }; + if let Some(owner) = compositor_owner { + output.compositor_owners.insert(node.node, owner); + } + if composited { + let compositor_opacity = element.layer.as_ref().map_or(1.0, |layer| { + ui.resolved_layer(node.node, element, layer).opacity + }); + output.display_list.begin_compositor(CompositorLayer::new( + compositor_id, + transform.transform_rect(node.bounds), + parent.transform, + transform, + compositor_opacity, + )); + } let context = PaintContext { transform, clips: parent.clips.clone(), clip_bounds: parent.clip_bounds, hit_allowed: parent.hit_allowed, active_portal: parent.active_portal, + compositor_owner, }; geometry::record(node, &context, output); let scroll_layers = paint_enter( @@ -175,13 +210,15 @@ pub(super) fn paint_node( output, &context, map.style.overflow.x.clips() || map.style.overflow.y.clips(), + composited, ); let child_clips = if map.style.overflow.x.clips() || map.style.overflow.y.clips() { let radii = ui.resolved_quad(node.node, element).radii; - context - .clips - .appended(ClipRegion::rounded(node.bounds, transform, radii)) + context.clips.appended(owned_clip( + ClipRegion::rounded(node.bounds, transform, radii), + compositor_owner, + )) } else { context.clips.clone() }; @@ -202,6 +239,7 @@ pub(super) fn paint_node( PointerEvents::None | PointerEvents::BoxOnly ), active_portal: context.active_portal, + compositor_owner, }; crate::custom::paint( map, @@ -263,6 +301,9 @@ pub(super) fn paint_node( scroll::paint(region, &mut output.display_list); } paint_exit(element, &mut output.display_list); + if composited { + output.display_list.end_compositor(); + } if cacheable { remove_descendant_fragments(map, cache); cache.fragments.insert( @@ -274,6 +315,16 @@ pub(super) fn paint_node( commands: output.display_list.commands()[command_start..].to_vec(), hit_regions: output.hit_regions[hit_start..].to_vec(), semantic_bounds: output.semantic_bounds[semantic_start..].to_vec(), + compositor_owners: output.nodes[map.index..map.index + map.subtree_len] + .iter() + .filter_map(|node| { + output + .compositor_owners + .get(&node.node) + .copied() + .map(|owner| (node.node, owner)) + }) + .collect(), text_orders: output .text_regions .iter() @@ -328,6 +379,7 @@ fn paint_enter( output: &mut LayoutOutput, context: &PaintContext, clips_content: bool, + composited: bool, ) -> usize { let visual_bounds = context.transform.transform_rect(node.bounds); if element @@ -336,20 +388,23 @@ fn paint_enter( { output.desktop_backdrops.push(crate::DesktopBackdropRegion { node: node.node, - shape: context.clips.appended(ClipRegion::rounded( - node.bounds, - context.transform, - ui.resolved_quad(node.node, element).radii, + shape: context.clips.appended(owned_clip( + ClipRegion::rounded( + node.bounds, + context.transform, + ui.resolved_quad(node.node, element).radii, + ), + context.compositor_owner, )), }); } if let Some(layer) = &element.layer { - begin_layer( - &mut output.display_list, - ui.resolved_layer(node.node, element, layer), - visual_bounds, - node.node, - ); + let mut layer = ui.resolved_layer(node.node, element, layer); + if composited { + // Group opacity is applied by the retained compositor wrapper. + layer.opacity = 1.0; + } + begin_layer(&mut output.display_list, layer, visual_bounds, node.node); } begin_scope( ui, @@ -382,9 +437,10 @@ fn paint_enter( let scroll_layers = effects::begin_scroll(ui, element, node, output, context.transform); let content_clips = if clips_content { - context - .clips - .appended(ClipRegion::new(node.bounds, context.transform)) + context.clips.appended(owned_clip( + ClipRegion::new(node.bounds, context.transform), + context.compositor_owner, + )) } else { context.clips.clone() }; @@ -439,6 +495,12 @@ fn paint_enter( scroll_layers } +/// Associates a clip with the nearest retained compositor layer, when present. +fn owned_clip(mut clip: ClipRegion, owner: Option) -> ClipRegion { + clip.compositor = owner; + clip +} + fn paint_exit(element: &Element, display_list: &mut DisplayList) { end_layers(display_list, scope_count(element, EffectScope::Content)); end_layers( @@ -449,135 +511,3 @@ fn paint_exit(element: &Element, display_list: &mut DisplayList) { display_list.end_layer(); } } - -fn push_quad( - ui: &UiTree, - style: QuadStyle, - element: &Element, - node: LayoutNode, - output: &mut LayoutOutput, - context: &PaintContext, -) { - let split = scope_count(element, EffectScope::Background) != 0 - || scope_count(element, EffectScope::Border) != 0; - if !split { - if style.is_visible() { - output - .display_list - .push_quad(quad(style, node.bounds, context)); - } - return; - } - if style.background.is_some() { - push_scoped_quad( - ui, - QuadStyle { - border: None, - ..style - }, - element, - node, - EffectScope::Background, - output, - context, - ); - } - if style.border.is_some() { - push_scoped_quad( - ui, - QuadStyle { - background: None, - ..style - }, - element, - node, - EffectScope::Border, - output, - context, - ); - } -} - -fn push_scoped_quad( - ui: &UiTree, - style: QuadStyle, - element: &Element, - node: LayoutNode, - scope: EffectScope, - output: &mut LayoutOutput, - context: &PaintContext, -) { - let visual_bounds = context.transform.transform_rect(node.bounds); - let layers = begin_scope( - ui, - &mut output.display_list, - element, - scope, - visual_bounds, - node.node, - ); - output - .display_list - .push_quad(quad(style, node.bounds, context)); - end_layers(&mut output.display_list, layers); -} - -fn quad(style: QuadStyle, bounds: Rect, context: &PaintContext) -> Quad { - Quad { - bounds, - background: style.background, - border: style.border.unwrap_or(Border::all(0.0, Color::TRANSPARENT)), - radii: style.radii, - opacity: style.opacity, - transform: context.transform, - clips: context.clips.clone(), - } -} - -fn push_image( - ui: &UiTree, - element: &Element, - node: LayoutNode, - output: &mut LayoutOutput, - context: &PaintContext, -) { - let ElementKind::Image { - image, - fit, - sampling, - } = element.kind - else { - return; - }; - output.display_list.push_image(ImagePrimitive { - bounds: node.bounds, - image, - fit, - sampling, - opacity: ui.resolved_quad(node.node, element).opacity, - radii: ui.resolved_quad(node.node, element).radii, - transform: context.transform, - clips: context.clips.clone(), - }); -} - -fn push_vector( - ui: &UiTree, - element: &Element, - node: LayoutNode, - output: &mut LayoutOutput, - context: &PaintContext, -) { - let ElementKind::Vector { vector, fit, color } = element.kind else { - return; - }; - output.display_list.push_vector(VectorPrimitive { - vector, - bounds: node.bounds, - fit, - color: ui.resolved_vector_color(node.node, color), - opacity: ui.resolved_quad(node.node, element).opacity, - transform: context.transform, - clips: context.clips.clone(), - }); -} diff --git a/crates/argui-layout/src/paint/cache.rs b/crates/argui-layout/src/paint/cache.rs index 96cd5659..26b99ab2 100644 --- a/crates/argui-layout/src/paint/cache.rs +++ b/crates/argui-layout/src/paint/cache.rs @@ -13,6 +13,7 @@ pub(super) struct CachedFragment { pub(super) commands: Vec, pub(super) hit_regions: Vec, pub(super) semantic_bounds: Vec<(NodeId, Rect)>, + pub(super) compositor_owners: Vec<(NodeId, argui_paint::CompositorId)>, pub(super) desktop_backdrops: Vec, pub(super) backdrop_state: argui_ui::DesktopBackdropState, pub(super) text_orders: Vec<(NodeId, usize)>, diff --git a/crates/argui-layout/src/paint/effects.rs b/crates/argui-layout/src/paint/effects.rs index 8ace3f9e..0555f80b 100644 --- a/crates/argui-layout/src/paint/effects.rs +++ b/crates/argui-layout/src/paint/effects.rs @@ -1,7 +1,7 @@ use crate::{LayoutNode, LayoutOutput}; use argui_core::{Affine2D, Rect}; use argui_paint::{DisplayList, LayerStyle, ProfileDomain, RenderObjectId}; -use argui_ui::{EffectScope, Element, NodeId, ScrollAxes, ScrollMetrics, ScrollbarGutter, UiTree}; +use argui_ui::{EffectScope, Element, NodeId, ScrollAxes, ScrollMetrics, UiTree}; pub(super) fn begin_scope( ui: &UiTree, @@ -76,20 +76,7 @@ pub(super) fn begin_scroll( else { return 0; }; - let mut viewport = region.bounds; - let style = ui.resolved_layout_style(node.node, element); - if style.scrollbar_gutter == ScrollbarGutter::Stable { - let gutter = style.scrollbar_width.max(0.0); - if matches!(region.config.axes, ScrollAxes::Vertical | ScrollAxes::Both) { - viewport.size.width = (viewport.size.width - gutter).max(0.0); - } - if matches!( - region.config.axes, - ScrollAxes::Horizontal | ScrollAxes::Both - ) { - viewport.size.height = (viewport.size.height - gutter).max(0.0); - } - } + let viewport = region.bounds; let mut max_offset = region.max_offset; match region.config.axes { ScrollAxes::Vertical => max_offset.x = 0.0, diff --git a/crates/argui-layout/src/paint/primitives.rs b/crates/argui-layout/src/paint/primitives.rs new file mode 100644 index 00000000..9d99f6ff --- /dev/null +++ b/crates/argui-layout/src/paint/primitives.rs @@ -0,0 +1,144 @@ +use argui_core::Rect; +use argui_paint::{Border, Color, ImagePrimitive, Quad, QuadStyle, VectorPrimitive}; +use argui_ui::{EffectScope, Element, ElementKind, UiTree}; + +use crate::{LayoutNode, LayoutOutput}; + +use super::{PaintContext, effects}; + +/// Emits the element quad, splitting background and border effect scopes when needed. +pub(super) fn push_quad( + ui: &UiTree, + style: QuadStyle, + element: &Element, + node: LayoutNode, + output: &mut LayoutOutput, + context: &PaintContext, +) { + let split = effects::scope_count(element, EffectScope::Background) != 0 + || effects::scope_count(element, EffectScope::Border) != 0; + if !split { + if style.is_visible() { + output + .display_list + .push_quad(quad(style, node.bounds, context)); + } + return; + } + if style.background.is_some() { + push_scoped_quad( + ui, + QuadStyle { + border: None, + ..style + }, + element, + node, + EffectScope::Background, + output, + context, + ); + } + if style.border.is_some() { + push_scoped_quad( + ui, + QuadStyle { + background: None, + ..style + }, + element, + node, + EffectScope::Border, + output, + context, + ); + } +} + +/// Emits one quad component inside its authored effect scope. +fn push_scoped_quad( + ui: &UiTree, + style: QuadStyle, + element: &Element, + node: LayoutNode, + scope: EffectScope, + output: &mut LayoutOutput, + context: &PaintContext, +) { + let visual_bounds = context.transform.transform_rect(node.bounds); + let layers = effects::begin_scope( + ui, + &mut output.display_list, + element, + scope, + visual_bounds, + node.node, + ); + output + .display_list + .push_quad(quad(style, node.bounds, context)); + effects::end_layers(&mut output.display_list, layers); +} + +/// Builds a renderer-neutral quad from resolved style and paint context. +fn quad(style: QuadStyle, bounds: Rect, context: &PaintContext) -> Quad { + Quad { + bounds, + background: style.background, + border: style.border.unwrap_or(Border::all(0.0, Color::TRANSPARENT)), + radii: style.radii, + opacity: style.opacity, + transform: context.transform, + clips: context.clips.clone(), + } +} + +/// Emits an image primitive when `element` is an image. +pub(super) fn push_image( + ui: &UiTree, + element: &Element, + node: LayoutNode, + output: &mut LayoutOutput, + context: &PaintContext, +) { + let ElementKind::Image { + image, + fit, + sampling, + } = element.kind + else { + return; + }; + output.display_list.push_image(ImagePrimitive { + bounds: node.bounds, + image, + fit, + sampling, + opacity: ui.resolved_quad(node.node, element).opacity, + radii: ui.resolved_quad(node.node, element).radii, + transform: context.transform, + clips: context.clips.clone(), + }); +} + +/// Emits a vector primitive when `element` is a vector. +pub(super) fn push_vector( + ui: &UiTree, + element: &Element, + node: LayoutNode, + output: &mut LayoutOutput, + context: &PaintContext, +) { + let ElementKind::Vector { vector, fit, color } = element.kind else { + return; + }; + output.display_list.push_vector(VectorPrimitive { + vector, + bounds: node.bounds, + fit, + color: ui.resolved_vector_color(node.node, color), + opacity: ui.resolved_quad(node.node, element).opacity, + transform: context.transform, + clips: context.clips.clone(), + }); +} diff --git a/crates/argui-layout/src/selection.rs b/crates/argui-layout/src/selection.rs index a63fdf0d..14321d62 100644 --- a/crates/argui-layout/src/selection.rs +++ b/crates/argui-layout/src/selection.rs @@ -1,9 +1,12 @@ use std::sync::Arc; -use argui_core::{Affine2D, Point, Rect, TextPosition}; +use argui_core::{Affine2D, Point, Rect, Size, TextPosition}; use argui_paint::{Border, ClipChain, Color, CornerRadii, DisplayList, Fill, Quad}; use argui_text::TextLayout; -use argui_ui::{DocumentTextPoint, NodeId, TextSelectionHighlight, TextSelectionStyle, UiTree}; +use argui_ui::{ + DocumentSelectionEndpoint, DocumentTextPoint, DocumentTextSelection, NodeId, + TextSelectionHighlight, TextSelectionStyle, UiTree, +}; use crate::LayoutOutput; @@ -22,6 +25,21 @@ pub struct TextRegion { pub interaction_order: usize, } +/// Viewport-space hit and paint geometry for one touch selection endpoint. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SelectionHandleGeometry { + /// Retained text node containing this endpoint. + pub node: NodeId, + /// Directed endpoint that will move when this handle is dragged. + pub endpoint: DocumentSelectionEndpoint, + /// Center of the visible handle in viewport coordinates. + pub center: Point, + /// Visible handle bounds in viewport coordinates. + pub visual_bounds: Rect, + /// Comfortable touch target in viewport coordinates. + pub hit_bounds: Rect, +} + impl TextRegion { /// Returns the text position hit by `point`, respecting transforms and clips. /// @@ -158,51 +176,29 @@ pub(crate) fn paint(ui: &UiTree, region: &TextRegion, display_list: &mut Display } if ui.document_selection_handles_visible() && let Some(selection) = ui.document_selection() + && ui.has_document_selection() { - let forward = ui - .node_ids() - .iter() - .position(|node| *node == selection.anchor.node) - .zip( - ui.node_ids() - .iter() - .position(|node| *node == selection.focus.node), - ) - .is_none_or(|(anchor, focus)| { - (anchor, selection.anchor.position.index) <= (focus, selection.focus.position.index) - }); - if region.node == selection.anchor.node - && let Some(rect) = if forward { rects.first() } else { rects.last() } - { - paint_handle(display_list, region, *rect, forward); - } - if region.node == selection.focus.node - && let Some(rect) = if forward { rects.last() } else { rects.first() } - { - paint_handle(display_list, region, *rect, !forward); + for endpoint in [ + DocumentSelectionEndpoint::Anchor, + DocumentSelectionEndpoint::Focus, + ] { + let point = selection_endpoint(selection, endpoint); + if point.node == region.node + && let Some((_, bounds)) = handle_geometry(region, endpoint, point) + { + paint_handle(display_list, region, bounds); + } } } } -fn paint_handle( - display_list: &mut DisplayList, - region: &TextRegion, - selection: Rect, - leading: bool, -) { - let diameter = 10.0; - let x = if leading { - selection.origin.x - diameter * 0.5 - } else { - selection.origin.x + selection.size.width - diameter * 0.5 - }; - let bounds = Rect::new( - Point::new( - x, - selection.origin.y + selection.size.height - diameter * 0.5, - ), - argui_core::Size::new(diameter, diameter), - ); +/// Paints one round touch-selection handle inside its text region's transform and clips. +/// +/// * `display_list` — destination display list for the handle primitive. +/// * `region` — text region supplying the handle color, transform, and clipping chain. +/// * `bounds` — local-space visible bounds of the handle. +fn paint_handle(display_list: &mut DisplayList, region: &TextRegion, bounds: Rect) { + let diameter = bounds.size.width; paint_rect( display_list, region, @@ -231,6 +227,58 @@ fn paint_rect( } impl LayoutOutput { + /// Returns the visible touch selection handles and their hit targets. + /// + /// * `ui` — tree containing the current selection and its touch-handle visibility. + /// + /// Each endpoint receives a 44-logical-pixel square hit target centered on its painted + /// handle. Returns no handles when the selection is empty, hidden, clipped, or has no shaped + /// caret geometry. + #[must_use] + pub fn selection_handles(&self, ui: &UiTree) -> Vec { + if !ui.document_selection_handles_visible() || !ui.has_document_selection() { + return Vec::new(); + } + let Some(selection) = ui.document_selection() else { + return Vec::new(); + }; + [ + DocumentSelectionEndpoint::Anchor, + DocumentSelectionEndpoint::Focus, + ] + .into_iter() + .filter_map(|endpoint| { + let point = selection_endpoint(selection, endpoint); + let region = self + .text_regions + .iter() + .find(|region| region.node == point.node)?; + handle_geometry(region, endpoint, point).map(|(geometry, _)| geometry) + }) + .collect() + } + + /// Finds the closest visible touch selection handle whose target contains `point`. + /// + /// * `ui` — tree containing the current selection and its touch-handle visibility. + /// * `point` — pointer position in viewport coordinates. + /// + /// Returns the nearest matching handle, or `None` when no hit target contains the point. + #[must_use] + pub fn selection_handle_at( + &self, + ui: &UiTree, + point: Point, + ) -> Option { + self.selection_handles(ui) + .into_iter() + .filter(|handle| handle.hit_bounds.contains(point)) + .min_by(|left, right| { + distance_squared(left.center, point) + .total_cmp(&distance_squared(right.center, point)) + }) + } + /// Returns the viewport-space union of rectangles in the current document selection. /// /// * `ui` — tree providing the selected ranges for each text node. @@ -251,6 +299,88 @@ impl LayoutOutput { } } +/// Selects the requested directed endpoint from a document selection. +/// +/// * `selection` — anchor and focus positions to inspect. +/// * `endpoint` — endpoint identity to return. +/// +/// Returns the document text position stored at that endpoint. +fn selection_endpoint( + selection: DocumentTextSelection, + endpoint: DocumentSelectionEndpoint, +) -> DocumentTextPoint { + match endpoint { + DocumentSelectionEndpoint::Anchor => selection.anchor, + DocumentSelectionEndpoint::Focus => selection.focus, + } +} + +/// Builds viewport hit geometry and local paint bounds for a selection endpoint. +/// +/// * `region` — shaped text region containing the endpoint. +/// * `endpoint` — directed selection endpoint represented by the handle. +/// * `point` — document text position for that endpoint. +/// +/// Returns `None` when the endpoint has no caret stop, matching text line, or visible clip point. +fn handle_geometry( + region: &TextRegion, + endpoint: DocumentSelectionEndpoint, + point: DocumentTextPoint, +) -> Option<(SelectionHandleGeometry, Rect)> { + let stop = region + .layout + .stops + .iter() + .filter(|stop| stop.position.index == point.position.index) + .min_by_key(|stop| stop.position != point.position)?; + let line = region + .layout + .lines + .iter() + .find(|line| (line.bounds.origin.y - stop.point.y).abs() < 0.01)?; + let local_center = Point::new( + region.origin.x + stop.point.x, + region.origin.y + line.bounds.origin.y + line.bounds.size.height, + ); + let center = region.transform.transform_point(local_center); + if !region.clips.contains(center) { + return None; + } + let visual_diameter = 10.0; + let visual_local = centered_rect(local_center, visual_diameter); + let hit_bounds = centered_rect(center, 44.0); + Some(( + SelectionHandleGeometry { + node: region.node, + endpoint, + center, + visual_bounds: region.transform.transform_rect(visual_local), + hit_bounds, + }, + visual_local, + )) +} + +/// Creates a square centered at `center` with the supplied side length. +/// +/// * `center` — rectangle center in the caller's coordinate space. +/// * `diameter` — width and height of the returned square. +fn centered_rect(center: Point, diameter: f32) -> Rect { + Rect::new( + Point::new(center.x - diameter * 0.5, center.y - diameter * 0.5), + Size::new(diameter, diameter), + ) +} + +/// Computes squared Euclidean distance without taking a square root. +/// +/// * `left` — first viewport-space point. +/// * `right` — second viewport-space point. +fn distance_squared(left: Point, right: Point) -> f32 { + let delta = Point::new(left.x - right.x, left.y - right.y); + delta.x * delta.x + delta.y * delta.y +} + fn union(left: Rect, right: Rect) -> Rect { let x = left.origin.x.min(right.origin.x); let y = left.origin.y.min(right.origin.y); diff --git a/crates/argui-layout/src/surface.rs b/crates/argui-layout/src/surface.rs index c286e746..1740d800 100644 --- a/crates/argui-layout/src/surface.rs +++ b/crates/argui-layout/src/surface.rs @@ -66,7 +66,13 @@ impl NativeSurfacePaint { layer.bounds.origin.x += delta.x; layer.bounds.origin.y += delta.y; } - DisplayCommand::EndLayer => {} + DisplayCommand::BeginCompositor(layer) => { + layer.bounds.origin.x += delta.x; + layer.bounds.origin.y += delta.y; + translate(&mut layer.base_parent); + translate(&mut layer.base_transform); + } + DisplayCommand::EndLayer | DisplayCommand::EndCompositor => {} } command }) diff --git a/crates/argui-layout/src/text.rs b/crates/argui-layout/src/text.rs index 1e0b7d84..986b4ef4 100644 --- a/crates/argui-layout/src/text.rs +++ b/crates/argui-layout/src/text.rs @@ -18,6 +18,7 @@ pub(super) fn content<'a>( )), ElementKind::TextEditor { placeholder, + styled, text, placeholder_text, .. @@ -29,10 +30,12 @@ pub(super) fn content<'a>( Cow::Borrowed(placeholder_text), )) } else { - Some(( - TextContent::plain(value), - Cow::Owned(ui.resolved_text_style(node, text)), - )) + let content = styled + .as_deref() + .filter(|content| content.as_str() == value.as_ref()) + .cloned() + .unwrap_or_else(|| TextContent::plain(value.into_owned())); + Some((content, Cow::Owned(ui.resolved_text_style(node, text)))) } } ElementKind::Custom(_) diff --git a/crates/argui-layout/tests/composite.rs b/crates/argui-layout/tests/composite.rs new file mode 100644 index 00000000..23545406 --- /dev/null +++ b/crates/argui-layout/tests/composite.rs @@ -0,0 +1,170 @@ +use argui_animation::Time; +use argui_core::{Affine2D, Point, Size, Transform2D}; +use argui_layout::LayoutEngine; +use argui_paint::{CompositorId, DisplayCommand, LayerStyle, PaintStyle}; +use argui_text::{TextEngine, TextStyle}; +use argui_ui::{Color, Element, FocusRequest, Interaction, TreeUpdate, UiTree, length}; +use argui_widgets::{Input, InputStyle}; + +fn scene(transform: Transform2D, opacity: f32) -> Element { + Element::container([Element::container([]) + .keyed("target") + .width(length(20.0)) + .height(length(20.0)) + .interaction(Interaction::default())]) + .keyed("moving") + .width(length(80.0)) + .height(length(40.0)) + .background(Color::WHITE) + .transform(transform) + .layer(LayerStyle::new(Default::default()).opacity(opacity)) +} + +#[test] +fn composition_updates_retained_layers_and_descendant_geometry_without_repaint() { + let mut ui = UiTree::new(scene(Transform2D::IDENTITY, 0.8)); + let mut engine = LayoutEngine::new(); + let mut text = TextEngine::new(); + let mut output = engine + .compute(&mut ui, &mut text, Size::new(240.0, 120.0)) + .unwrap(); + let child = ui.node_id_at(1).expect("child node"); + let retained_quad = output + .display_list + .commands() + .iter() + .find_map(|command| match command { + DisplayCommand::Quad(quad) => Some(quad.clone()), + _ => None, + }) + .expect("retained quad"); + let retained_text = output.text.clone(); + + assert_eq!( + ui.update(scene(Transform2D::IDENTITY.translate(36.0, 8.0), 0.4,)), + TreeUpdate::Composite + ); + assert!(engine.composite(&ui, &mut output)); + + let compositor = output + .display_list + .commands() + .iter() + .find_map(|command| match command { + DisplayCommand::BeginCompositor(layer) => Some(layer), + _ => None, + }) + .expect("compositor layer"); + assert_eq!(compositor.transform, Affine2D::translation(36.0, 8.0)); + assert_eq!(compositor.opacity, 0.4); + assert_eq!(output.text, retained_text); + assert_eq!( + output + .display_list + .commands() + .iter() + .find_map(|command| match command { + DisplayCommand::Quad(quad) => Some(quad), + _ => None, + }), + Some(&retained_quad) + ); + + let hit = output + .hit_regions + .iter() + .find(|region| region.node == child) + .expect("child hit region"); + assert_eq!(hit.transform, Affine2D::translation(36.0, 8.0)); + assert!(hit.contains(Point::new(40.0, 12.0))); +} + +#[test] +fn singular_compositor_transforms_request_the_safe_paint_fallback() { + let mut ui = UiTree::new(scene(Transform2D::IDENTITY, 0.8)); + let mut engine = LayoutEngine::new(); + let mut output = engine + .compute(&mut ui, &mut TextEngine::new(), Size::new(240.0, 120.0)) + .unwrap(); + + assert_eq!( + ui.update(scene(Transform2D::IDENTITY.scale(0.0, 1.0), 0.8)), + TreeUpdate::Composite + ); + assert!(!engine.composite(&ui, &mut output)); +} + +#[test] +fn moving_a_partially_clipped_layer_requests_the_safe_paint_fallback() { + let mut ui = UiTree::new(scene(Transform2D::IDENTITY.translate(-20.0, 0.0), 0.8)); + let mut engine = LayoutEngine::new(); + let mut output = engine + .compute(&mut ui, &mut TextEngine::new(), Size::new(240.0, 120.0)) + .unwrap(); + + assert_eq!( + ui.update(scene(Transform2D::IDENTITY, 0.8)), + TreeUpdate::Composite + ); + assert!(!engine.composite(&ui, &mut output)); +} + +#[test] +fn moving_an_offscreen_layer_without_revealing_it_stays_composition_only() { + let mut ui = UiTree::new(scene(Transform2D::IDENTITY.translate(-100.0, 0.0), 0.8)); + let mut engine = LayoutEngine::new(); + let mut output = engine + .compute(&mut ui, &mut TextEngine::new(), Size::new(240.0, 120.0)) + .unwrap(); + + assert_eq!( + ui.update(scene(Transform2D::IDENTITY.translate(-90.0, 0.0), 0.8,)), + TreeUpdate::Composite + ); + assert!(engine.composite(&ui, &mut output)); +} + +#[test] +fn caret_blink_updates_its_retained_layer_without_repainting_text() { + let input = Input::new( + "editor", + "hello", + "", + InputStyle::new(PaintStyle::default(), TextStyle::default()), + ) + .build() + .width(length(180.0)) + .height(length(32.0)); + let mut ui = UiTree::new(input); + let node = ui.node_id_at(0).unwrap(); + let mut engine = LayoutEngine::new(); + let mut text = TextEngine::new(); + let mut output = engine + .compute(&mut ui, &mut text, Size::new(240.0, 80.0)) + .unwrap(); + ui.sync_focus( + &output.hit_regions, + Some(FocusRequest::Focus("editor".into())), + ); + engine.update_text_inputs(&mut ui, &mut text, &mut output); + let retained_text = output.text.clone(); + + assert_eq!( + ui.advance_animations(Time::from_nanos(1)), + TreeUpdate::Composite + ); + assert!(engine.composite(&ui, &mut output)); + assert_eq!( + ui.advance_animations(Time::from_nanos(500_000_001)), + TreeUpdate::Composite + ); + assert!(engine.composite(&ui, &mut output)); + + let caret = output + .display_list + .compositor_layers() + .find(|layer| layer.id == CompositorId::subpart(node.get(), 1)) + .expect("retained caret layer"); + assert_eq!(caret.opacity, 0.0); + assert_eq!(output.text, retained_text); +} diff --git a/crates/argui-layout/tests/input.rs b/crates/argui-layout/tests/input.rs index e52a7504..d7ff6d24 100644 --- a/crates/argui-layout/tests/input.rs +++ b/crates/argui-layout/tests/input.rs @@ -4,7 +4,7 @@ use argui_core::{ }; use argui_layout::{LayoutEngine, TextInputRegion}; use argui_paint::{PaintStyle, QuadStyle}; -use argui_text::{CaretStop, TextEngine, TextOverflow, TextStyle}; +use argui_text::{CaretStop, TextEngine, TextOverflow, TextStyle, TextWrap}; use argui_ui::{ CursorIcon, Element, EventHandlerId, EventListener, EventOwnerId, EventType, GestureCapture, GestureKind, GestureSet, Interaction, PanGesture, Position, ScrollbarPartStyle, ScrollbarStyle, @@ -74,6 +74,143 @@ fn placeholder_keeps_its_authored_color_on_the_first_frame() { assert_eq!(output.text.blocks()[0].style.color, Color::WHITE); } +#[test] +fn retained_text_edits_refresh_painted_content_without_a_tree_rebuild() { + let area = TextArea::new( + "code", + "before", + "Code", + InputStyle::new(PaintStyle::default(), TextStyle::default()), + ) + .build(); + let mut ui = UiTree::new(area); + let node = ui.node_id_at(0).unwrap(); + let mut layout = LayoutEngine::new(); + let mut text = text_engine(); + let mut output = layout + .compute(&mut ui, &mut text, Size::new(320.0, 100.0)) + .unwrap(); + + ui.replace_text_input(node, "after"); + layout.update_text_inputs(&mut ui, &mut text, &mut output); + + assert_eq!(output.text.blocks()[0].content.as_str(), "after"); +} + +#[test] +fn non_wrapping_text_area_scrolls_both_axes_without_leaking_past_its_viewport() { + let long_line = + "let extremely_long_identifier = build_a_value_that_exceeds_the_editor_width();"; + let value = std::iter::repeat_n(long_line, 24) + .collect::>() + .join("\n"); + let area = TextArea::new( + "code", + value, + "", + InputStyle::new(PaintStyle::default(), TextStyle::default()), + ) + .wrap(TextWrap::None) + .build() + .width(length(240.0)) + .height(length(96.0)); + let mut ui = UiTree::new(area); + let output = LayoutEngine::new() + .compute(&mut ui, &mut text_engine(), Size::new(240.0, 96.0)) + .unwrap(); + + assert!(output.scroll_regions[0].max_offset.x > 0.0); + assert!(output.scroll_regions[0].max_offset.y > 0.0); + assert_eq!(output.text.blocks()[0].clip, output.text_inputs[0].clip); +} + +#[test] +fn large_non_wrapping_text_areas_shape_only_the_scrolled_viewport() { + let value = (0..1_000) + .map(|line| format!("line {line:04}: let value = compute();")) + .collect::>() + .join("\n"); + let area = TextArea::new( + "code", + &value, + "", + InputStyle::new( + PaintStyle::default(), + TextStyle { + line_height: 20.0, + ..TextStyle::default() + }, + ), + ) + .wrap(TextWrap::None) + .build() + .width(length(320.0)) + .height(length(100.0)); + let mut ui = UiTree::new(area); + let node = ui.node_id_at(0).unwrap(); + let mut layout = LayoutEngine::new(); + let mut text = text_engine(); + let mut output = layout + .compute(&mut ui, &mut text, Size::new(320.0, 100.0)) + .unwrap(); + assert!(output.text_inputs[0].content_size.height >= 20_000.0); + assert!(output.text.blocks()[0].content.as_str().len() < value.len() / 10); + + ui.set_scroll_offset(node, Point::new(0.0, 10_000.0)); + layout.update_text_inputs(&mut ui, &mut text, &mut output); + + let visible = output.text.blocks()[0].content.as_str(); + assert!(visible.contains("line 0498") || visible.contains("line 0499")); + assert!(!visible.contains("line 0000")); + assert!(output.text_inputs[0].content_size.height >= 20_000.0); +} + +#[test] +fn repeated_virtual_editor_scrolls_keep_the_viewport_and_reach_the_last_line() { + let value = (0..1_000) + .map(|line| format!("line {line:04}: let value = compute();")) + .collect::>() + .join("\n"); + let mut style = InputStyle::new( + PaintStyle::default(), + TextStyle { + line_height: 20.0, + ..TextStyle::default() + }, + ); + style.layout.padding = argui_ui::sides(20.0, 16.0); + let area = TextArea::new("code", &value, "", style) + .wrap(TextWrap::None) + .build() + .width(length(640.0)) + .height(length(420.0)); + let mut ui = UiTree::new(area); + let node = ui.node_id_at(0).unwrap(); + let mut layout = LayoutEngine::new(); + let mut text = text_engine(); + let mut output = layout + .compute(&mut ui, &mut text, Size::new(640.0, 420.0)) + .unwrap(); + let viewport = output.text_inputs[0].viewport; + let maximum = output.scroll_regions[0].max_offset.y; + assert!(maximum > 19_000.0); + + for step in 1..=20 { + let offset = maximum * step as f32 / 20.0; + ui.set_scroll_offset(node, Point::new(0.0, offset)); + assert!( + layout + .apply_scroll_with_text(&mut ui, &mut text, &mut output) + .unwrap() + ); + assert_eq!(output.text_inputs[0].viewport, viewport); + assert_eq!(output.scroll_regions[0].max_offset.y, maximum); + } + + let visible = output.text.blocks()[0].content.as_str(); + assert!(visible.contains("line 0999"), "{visible}"); +} + fn stop(index: usize, x: f32, y: f32, word_boundary: bool) -> CaretStop { CaretStop { position: TextPosition::new(index, CaretAffinity::Before), @@ -92,7 +229,7 @@ fn region(stops: Vec) -> TextInputRegion { stops, selection: Vec::new(), caret: Some(Rect::new(Point::new(10.0, 0.0), Size::new(1.0, 16.0))), - selection_color: Color::TRANSPARENT, + selection_highlight: argui_ui::TextSelectionHighlight::solid(Color::TRANSPARENT), caret_style: argui_ui::CaretStyle::default(), content_size: Size::new(100.0, 60.0), scroll_x: 0.0, @@ -382,56 +519,6 @@ fn a_resize_handle_painted_over_a_scrollbar_keeps_pointer_priority() { })); } -#[test] -fn repeated_newlines_keep_the_multiline_caret_visible_and_scroll_monotonic() { - let editor = |value: &str| { - TextArea::new( - "notes", - value, - "notes", - InputStyle::new(PaintStyle::default(), TextStyle::default()), - ) - .build() - .height(length(96.0)) - }; - let mut ui = UiTree::new(editor("start")); - let node = ui.node_id_at(0).unwrap(); - let mut layout = LayoutEngine::new(); - let mut text = text_engine(); - let mut output = layout - .compute(&mut ui, &mut text, Size::new(260.0, 96.0)) - .unwrap(); - ui.pointer_moved(Point::new(20.0, 20.0), &output.hit_regions); - ui.primary_pressed(&output.hit_regions); - ui.place_text_cursor(node, "start".len(), false); - let mut previous_scroll = 0.0; - - for repeat in 0..12 { - ui.edit_text_input(&KeyInput { - key: Key::Enter, - state: KeyState::Pressed, - modifiers: Modifiers::default(), - repeat: repeat > 0, - text: None, - }); - let value = ui.text_input_value(node).unwrap().to_owned(); - ui.update(editor(&value)); - output = layout - .compute(&mut ui, &mut text, Size::new(260.0, 96.0)) - .unwrap(); - let region = &output.text_inputs[0]; - let scroll = ui.scroll_offset(node).y; - assert!(scroll >= previous_scroll); - assert!(region.caret.unwrap().origin.y >= region.viewport.origin.y); - assert!( - region.caret.unwrap().origin.y + region.caret.unwrap().size.height - <= region.viewport.origin.y + region.viewport.size.height - ); - previous_scroll = scroll; - } - assert!(previous_scroll > 0.0); -} - #[test] fn empty_or_degenerate_custom_carets_do_not_emit_invalid_quads() { use argui_ui::{CaretHeight, CaretPrimitive, CaretStyle, CaretVisual, FocusRequest}; @@ -481,84 +568,3 @@ fn empty_or_degenerate_custom_carets_do_not_emit_invalid_quads() { #[path = "input/navigation.rs"] mod navigation; - -#[test] -fn transformed_editor_hits_and_drags_resolve_the_same_text_positions() { - use argui_core::Affine2D; - let field = |key, value| { - Input::new( - key, - value, - "", - InputStyle::new(PaintStyle::default(), TextStyle::default()), - ) - .build() - }; - let mut ui = UiTree::new(Element::column([ - field("first", "other field"), - field("second", "alpha beta gamma"), - ])); - let node = ui.node_ids()[2]; - let mut output = LayoutEngine::new() - .compute(&mut ui, &mut text_engine(), Size::new(400.0, 160.0)) - .unwrap(); - let region = output - .text_inputs - .iter() - .find(|region| region.node == node) - .unwrap() - .clone(); - let local = region - .stops - .iter() - .find(|stop| stop.position.index == 7) - .unwrap() - .point; - let expected = region.closest_position(local); - for transform in [ - Affine2D::IDENTITY, - Affine2D { - matrix: [2.0, 0.0, 0.0, 1.5], - translation: Point::new(120.0, 45.0), - }, - ] { - output - .hit_regions - .iter_mut() - .find(|hit| hit.node == node) - .unwrap() - .transform = transform; - let point = transform.transform_point(local); - let mapped = output.local_point(node, point).unwrap(); - assert!((mapped.x - local.x).hypot(mapped.y - local.y) < 0.001); - assert_eq!(region.hit_position(mapped), Some(expected)); - ui.begin_text_selection( - node, - region.closest_position(mapped), - false, - argui_ui::SelectionGranularity::Word, - ); - assert_eq!(ui.text_input_selection(node), Some((6, 10))); - // Captured drags may leave the editor while still needing its inverse transform. - let outside = Point::new(region.bounds.origin.x - 50.0, local.y); - let mapped = output - .local_point(node, transform.transform_point(outside)) - .unwrap(); - assert!(region.hit_position(mapped).is_none()); - ui.drag_text_position(node, region.closest_position(mapped)); - assert_eq!(ui.text_input_selection(node), Some((0, 10))); - ui.release_text_cursor(); - } - output - .hit_regions - .iter_mut() - .find(|hit| hit.node == node) - .unwrap() - .transform = Affine2D { - matrix: [0.0; 4], - ..Affine2D::IDENTITY - }; - assert!(output.local_point(node, local).is_none()); - output.hit_regions.retain(|hit| hit.node != node); - assert!(output.local_point(node, local).is_none()); -} diff --git a/crates/argui-layout/tests/input/navigation.rs b/crates/argui-layout/tests/input/navigation.rs index 284dde83..ccdd57f9 100644 --- a/crates/argui-layout/tests/input/navigation.rs +++ b/crates/argui-layout/tests/input/navigation.rs @@ -1,5 +1,136 @@ use super::*; +#[test] +fn repeated_newlines_keep_the_multiline_caret_visible_and_scroll_monotonic() { + let editor = |value: &str| { + TextArea::new( + "notes", + value, + "notes", + InputStyle::new(PaintStyle::default(), TextStyle::default()), + ) + .build() + .height(length(96.0)) + }; + let mut ui = UiTree::new(editor("start")); + let node = ui.node_id_at(0).unwrap(); + let mut layout = LayoutEngine::new(); + let mut text = text_engine(); + let mut output = layout + .compute(&mut ui, &mut text, Size::new(260.0, 96.0)) + .unwrap(); + ui.pointer_moved(Point::new(20.0, 20.0), &output.hit_regions); + ui.primary_pressed(&output.hit_regions); + ui.place_text_cursor(node, "start".len(), false); + let mut previous_scroll = 0.0; + + for repeat in 0..12 { + ui.edit_text_input(&KeyInput { + key: Key::Enter, + state: KeyState::Pressed, + modifiers: Modifiers::default(), + repeat: repeat > 0, + text: None, + }); + let value = ui.text_input_value(node).unwrap().to_owned(); + ui.update(editor(&value)); + output = layout + .compute(&mut ui, &mut text, Size::new(260.0, 96.0)) + .unwrap(); + let region = &output.text_inputs[0]; + let scroll = ui.scroll_offset(node).y; + assert!(scroll >= previous_scroll); + assert!(region.caret.unwrap().origin.y >= region.viewport.origin.y); + assert!( + region.caret.unwrap().origin.y + region.caret.unwrap().size.height + <= region.viewport.origin.y + region.viewport.size.height + ); + previous_scroll = scroll; + } + assert!(previous_scroll > 0.0); +} + +#[test] +fn transformed_editor_hits_and_drags_resolve_the_same_text_positions() { + use argui_core::Affine2D; + let field = |key, value| { + Input::new( + key, + value, + "", + InputStyle::new(PaintStyle::default(), TextStyle::default()), + ) + .build() + }; + let mut ui = UiTree::new(Element::column([ + field("first", "other field"), + field("second", "alpha beta gamma"), + ])); + let node = ui.node_ids()[2]; + let mut output = LayoutEngine::new() + .compute(&mut ui, &mut text_engine(), Size::new(400.0, 160.0)) + .unwrap(); + let region = output + .text_inputs + .iter() + .find(|region| region.node == node) + .unwrap() + .clone(); + let local = region + .stops + .iter() + .find(|stop| stop.position.index == 7) + .unwrap() + .point; + let expected = region.closest_position(local); + for transform in [ + Affine2D::IDENTITY, + Affine2D { + matrix: [2.0, 0.0, 0.0, 1.5], + translation: Point::new(120.0, 45.0), + }, + ] { + output + .hit_regions + .iter_mut() + .find(|hit| hit.node == node) + .unwrap() + .transform = transform; + let point = transform.transform_point(local); + let mapped = output.local_point(node, point).unwrap(); + assert!((mapped.x - local.x).hypot(mapped.y - local.y) < 0.001); + assert_eq!(region.hit_position(mapped), Some(expected)); + ui.begin_text_selection( + node, + region.closest_position(mapped), + false, + argui_ui::SelectionGranularity::Word, + ); + assert_eq!(ui.text_input_selection(node), Some((6, 10))); + // Captured drags may leave the editor while still needing its inverse transform. + let outside = Point::new(region.bounds.origin.x - 50.0, local.y); + let mapped = output + .local_point(node, transform.transform_point(outside)) + .unwrap(); + assert!(region.hit_position(mapped).is_none()); + ui.drag_text_position(node, region.closest_position(mapped)); + assert_eq!(ui.text_input_selection(node), Some((0, 10))); + ui.release_text_cursor(); + } + output + .hit_regions + .iter_mut() + .find(|hit| hit.node == node) + .unwrap() + .transform = Affine2D { + matrix: [0.0; 4], + ..Affine2D::IDENTITY + }; + assert!(output.local_point(node, local).is_none()); + output.hit_regions.retain(|hit| hit.node != node); + assert!(output.local_point(node, local).is_none()); +} + struct Editor { ui: UiTree, layout: LayoutEngine, diff --git a/crates/argui-layout/tests/paint.rs b/crates/argui-layout/tests/paint.rs index 995def4b..58040604 100644 --- a/crates/argui-layout/tests/paint.rs +++ b/crates/argui-layout/tests/paint.rs @@ -49,9 +49,15 @@ fn gpu_canvas_lowers_with_retained_identity_and_normal_visual_geometry() { let output = LayoutEngine::new() .compute(&mut ui, &mut text_engine(), Size::new(120.0, 80.0)) .unwrap(); - let DisplayCommand::GpuCanvas(canvas) = &output.display_list.commands()[0] else { - panic!("GPU canvas element must lower to a canvas command"); - }; + let canvas = output + .display_list + .commands() + .iter() + .find_map(|command| match command { + DisplayCommand::GpuCanvas(canvas) => Some(canvas), + _ => None, + }) + .expect("GPU canvas element must lower to a canvas command"); assert_eq!(canvas.canvas, id); assert_eq!(canvas.object.value, retained.get()); assert_eq!(canvas.slot, 0); @@ -350,12 +356,14 @@ fn transformed_images_share_exact_clips_layers_and_hit_geometry() { assert!(matches!( output.display_list.commands(), [ + DisplayCommand::BeginCompositor(_), DisplayCommand::BeginLayer(_), DisplayCommand::Image(_), - DisplayCommand::EndLayer + DisplayCommand::EndLayer, + DisplayCommand::EndCompositor ] )); - let DisplayCommand::Image(image) = &output.display_list.commands()[1] else { + let DisplayCommand::Image(image) = &output.display_list.commands()[2] else { unreachable!(); }; assert_eq!(image.image, ImageId(42)); diff --git a/crates/argui-layout/tests/paint/effects.rs b/crates/argui-layout/tests/paint/effects.rs index 4a3084c3..b89cb33b 100644 --- a/crates/argui-layout/tests/paint/effects.rs +++ b/crates/argui-layout/tests/paint/effects.rs @@ -98,7 +98,7 @@ fn scroll_layers_exclude_background_and_scrollbars_and_preserve_effect_order() { )); let initial = layers(&output); assert_eq!(initial.len(), 2); - assert_eq!(initial[0].bounds.size.width, 90.0); + assert_eq!(initial[0].bounds.size.width, 100.0); let Filter::Effect(filter) = &initial[0].filters[0] else { panic!() }; @@ -125,7 +125,7 @@ fn scroll_layers_exclude_background_and_scrollbars_and_preserve_effect_order() { } #[test] -fn fitting_content_skips_effects_and_both_axes_reserve_their_gutters() { +fn fitting_content_skips_effects_and_active_effects_cover_the_full_viewport() { let mut text = TextEngine::from_embedded_fonts( [include_bytes!("../../../argui-web-demo/assets/fonts/NotoSans-Regular.ttf").as_slice()], "Noto Sans", @@ -144,7 +144,7 @@ fn fitting_content_skips_effects_and_both_axes_reserve_their_gutters() { let layers = layers(&output); assert_eq!(layers.len(), expected); if both { - assert_eq!(layers[0].bounds.size, Size::new(90.0, 70.0)); + assert_eq!(layers[0].bounds.size, Size::new(100.0, 80.0)); } } } @@ -163,7 +163,7 @@ fn horizontal_axes_survive_repaint_and_resizing_removes_obsolete_effects() { output.scroll_regions[0].config.axes, argui_ui::ScrollAxes::Horizontal ); - assert_eq!(layers(&output)[0].bounds.size, Size::new(100.0, 70.0)); + assert_eq!(layers(&output)[0].bounds.size, Size::new(100.0, 80.0)); let node = output.scroll_regions[0].node; ui.set_scroll_offset(node, Point::new(6.0, 50.0)); engine.apply_scroll(&ui, &mut output).unwrap(); diff --git a/crates/argui-layout/tests/scroll.rs b/crates/argui-layout/tests/scroll.rs index b3070050..f298afd5 100644 --- a/crates/argui-layout/tests/scroll.rs +++ b/crates/argui-layout/tests/scroll.rs @@ -64,10 +64,12 @@ fn scrollbar_is_regular_paint_with_geometry_from_the_scroll_state() { assert!(matches!( output.display_list.commands(), [ + DisplayCommand::BeginCompositor(_), DisplayCommand::BeginLayer(_), DisplayCommand::Quad(_), DisplayCommand::Quad(_), - DisplayCommand::EndLayer + DisplayCommand::EndLayer, + DisplayCommand::EndCompositor ] )); diff --git a/crates/argui-layout/tests/selection.rs b/crates/argui-layout/tests/selection.rs index ac04d2e5..a1f687ef 100644 --- a/crates/argui-layout/tests/selection.rs +++ b/crates/argui-layout/tests/selection.rs @@ -2,12 +2,14 @@ use argui_core::{Affine2D, CaretAffinity, Point, Rect, Size, TextPosition}; use argui_layout::LayoutEngine; use argui_paint::{ ClipChain, ClipRegion, CornerRadii, DisplayCommand, Fill, GradientStop, LinearGradient, + PaintStyle, }; -use argui_text::TextEngine; +use argui_text::{TextEngine, TextStyle}; use argui_ui::{ - DocumentTextPoint, Element, SelectionGranularity, TextSelectionHighlight, TextSelectionStyle, - UiTree, UserSelect, percent, + DocumentSelectionEndpoint, DocumentTextPoint, Element, SelectionGranularity, TextSelection, + TextSelectionHighlight, TextSelectionRequest, TextSelectionStyle, UiTree, UserSelect, percent, }; +use argui_widgets::{InputStyle, TextArea}; const NOTO_SANS: &[u8] = include_bytes!("../../argui-web-demo/assets/fonts/NotoSans-Regular.ttf"); @@ -15,6 +17,38 @@ fn text_engine() -> TextEngine { TextEngine::from_embedded_fonts([NOTO_SANS], "Noto Sans", "Noto Sans", "Noto Sans") } +#[test] +fn text_editor_selection_highlight_keeps_authored_corner_radii() { + let color = argui_core::Color::srgba(0.2, 0.5, 0.9, 0.4); + let area = TextArea::new( + "rounded-selection", + "select me", + "", + InputStyle::new(PaintStyle::default(), TextStyle::default()), + ) + .build() + .selection_highlight(TextSelectionHighlight::solid(color).radius(5.0)); + let mut ui = UiTree::new(area); + let mut layout = LayoutEngine::new(); + let mut text = text_engine(); + layout + .compute(&mut ui, &mut text, Size::new(240.0, 80.0)) + .unwrap(); + ui.select_text(TextSelectionRequest::new( + "rounded-selection", + TextSelection::All, + )); + let output = layout + .compute(&mut ui, &mut text, Size::new(240.0, 80.0)) + .unwrap(); + + assert!(output.display_list.commands().iter().any(|command| { + matches!(command, DisplayCommand::Quad(quad) + if quad.background == Some(Fill::Solid(color)) + && quad.radii.top_left == 5.0) + })); +} + #[test] fn transparent_overlays_block_text_hits_including_cached_portals() { use argui_ui::{CursorIcon, HitShape, HitTestStyle, Interaction, Sides, WindowLayer, length}; @@ -290,6 +324,62 @@ fn touch_selection_paints_both_handles_in_forward_and_reverse_order() { assert!(output.document_selection_bounds(&ui).is_some()); } +#[test] +fn touch_selection_handles_have_transformed_comfortable_hit_targets() { + let mut ui = UiTree::new(Element::text("alpha beta gamma").width(percent(1.0))); + let mut engine = LayoutEngine::new(); + let mut text = text_engine(); + let mut output = engine + .compute(&mut ui, &mut text, Size::new(300.0, 80.0)) + .unwrap(); + let node = output.text_regions[0].node; + ui.begin_touch_document_selection( + DocumentTextPoint::new(node, TextPosition::new(7, CaretAffinity::After)), + SelectionGranularity::Word, + ); + ui.release_document_selection(); + engine.repaint(&ui, &mut output); + + let handles = output.selection_handles(&ui); + assert_eq!(handles.len(), 2); + for handle in &handles { + assert_eq!(handle.hit_bounds.size, Size::new(44.0, 44.0)); + assert_eq!(handle.visual_bounds.size, Size::new(10.0, 10.0)); + assert_eq!( + output + .selection_handle_at(&ui, handle.center) + .map(|hit| hit.endpoint), + Some(handle.endpoint) + ); + assert!( + handle + .hit_bounds + .contains(Point::new(handle.center.x, handle.center.y + 21.0)) + ); + } + + let anchor = handles + .iter() + .find(|handle| handle.endpoint == DocumentSelectionEndpoint::Anchor) + .unwrap(); + output.text_regions[0].transform = Affine2D::translation(2.0, 3.0); + let moved_anchor = output + .selection_handles(&ui) + .into_iter() + .find(|handle| handle.endpoint == DocumentSelectionEndpoint::Anchor) + .unwrap(); + assert_eq!( + moved_anchor.center, + Point::new(anchor.center.x + 2.0, anchor.center.y + 3.0) + ); + assert_eq!( + output + .selection_handle_at(&ui, moved_anchor.center) + .map(|hit| hit.endpoint), + Some(DocumentSelectionEndpoint::Anchor) + ); +} + #[test] fn touch_handles_attach_to_opposite_ends_across_text_nodes() { let background = argui_core::Color::srgba(0.2, 0.5, 0.9, 0.4); diff --git a/crates/argui-paint/src/compositor.rs b/crates/argui-paint/src/compositor.rs new file mode 100644 index 00000000..1dca4825 --- /dev/null +++ b/crates/argui-paint/src/compositor.rs @@ -0,0 +1,148 @@ +use argui_core::{Affine2D, Rect}; + +use crate::{LayerStyle, ProfileDomain, RenderObjectId}; + +/// Stable identity for one retained compositor layer. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct CompositorId { + owner: u64, + subpart: u32, +} + +impl CompositorId { + /// Creates an identity from a retained engine node value. + /// + /// * `value` — stable value that identifies the layer between frames. + #[must_use] + pub const fn new(value: u64) -> Self { + Self { + owner: value, + subpart: 0, + } + } + + /// Creates an identity for a retained subpart owned by an engine node. + /// + /// * `owner` — stable owner value shared with the surrounding element. + /// * `subpart` — non-zero namespace local to that owner. + #[must_use] + pub const fn subpart(owner: u64, subpart: u32) -> Self { + Self { owner, subpart } + } + + /// Returns the opaque numeric layer identity. + #[must_use] + pub const fn get(self) -> u64 { + if self.subpart == 0 { + self.owner + } else { + self.owner + .wrapping_mul(0x9e37_79b9_7f4a_7c15) + .wrapping_add(self.subpart as u64) + } + } +} + +/// Retained group whose content can be transformed and faded without repainting it. +#[derive(Clone, Debug, PartialEq)] +pub struct CompositorLayer { + /// Stable identity used by scene updates and the renderer cache. + pub id: CompositorId, + /// Bounds of the already-painted content in surface coordinates. + pub bounds: Rect, + /// Parent transform captured when the content was painted. + pub base_parent: Affine2D, + /// Full transform captured when the content was painted. + pub base_transform: Affine2D, + /// Delta applied to the retained content at composition time. + pub transform: Affine2D, + /// Group opacity applied at composition time. + pub opacity: f32, +} + +impl CompositorLayer { + /// Creates an identity compositor layer around already-painted content. + /// + /// * `id` — stable retained identity. + /// * `bounds` — initial painted bounds in surface coordinates. + /// * `base_parent` — parent surface transform used by the paint snapshot. + /// * `base_transform` — full surface transform used by the paint snapshot. + /// * `opacity` — initial group opacity. + #[must_use] + pub fn new( + id: CompositorId, + bounds: Rect, + base_parent: Affine2D, + base_transform: Affine2D, + opacity: f32, + ) -> Self { + Self { + id, + bounds, + base_parent, + base_transform, + transform: Affine2D::IDENTITY, + opacity: normalized_opacity(opacity), + } + } + + /// Replaces the composition-only properties of this retained layer. + /// + /// * `transform` — delta from the painted snapshot to the current presentation. + /// * `opacity` — current group opacity. + /// + /// Returns whether either property changed. + pub fn update(&mut self, transform: Affine2D, opacity: f32) -> bool { + let opacity = normalized_opacity(opacity); + let changed = self.transform != transform || self.opacity != opacity; + self.transform = transform; + self.opacity = opacity; + changed + } + + /// Converts this compositor entry into the renderer's generic layer representation. + #[must_use] + pub fn style(&self) -> LayerStyle { + LayerStyle::new(self.bounds) + .retained(true) + .opacity(self.opacity) + .transform(self.transform) + .profile(RenderObjectId::new(ProfileDomain::Engine, self.id.get())) + } +} + +/// One lightweight update to an existing retained compositor layer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CompositorPatch { + /// Layer receiving the update. + pub id: CompositorId, + /// Delta from the layer's painted snapshot. + pub transform: Affine2D, + /// Current group opacity. + pub opacity: f32, +} + +impl CompositorPatch { + /// Creates a patch for an existing compositor layer. + /// + /// * `id` — target retained layer. + /// * `transform` — composition-time transform delta. + /// * `opacity` — composition-time group opacity. + #[must_use] + pub const fn new(id: CompositorId, transform: Affine2D, opacity: f32) -> Self { + Self { + id, + transform, + opacity, + } + } +} + +/// Clamps finite opacity and replaces invalid floating-point values with identity. +fn normalized_opacity(opacity: f32) -> f32 { + if opacity.is_finite() { + opacity.clamp(0.0, 1.0) + } else { + 1.0 + } +} diff --git a/crates/argui-paint/src/display_list.rs b/crates/argui-paint/src/display_list.rs index 99cef84a..a6262002 100644 --- a/crates/argui-paint/src/display_list.rs +++ b/crates/argui-paint/src/display_list.rs @@ -2,7 +2,10 @@ use core::fmt; use argui_core::Affine2D; -use crate::{ClipChain, GpuCanvasPrimitive, ImagePrimitive, LayerStyle, Quad, VectorPrimitive}; +use crate::{ + ClipChain, CompositorId, CompositorLayer, CompositorPatch, GpuCanvasPrimitive, ImagePrimitive, + LayerStyle, Quad, VectorPrimitive, +}; #[derive(Clone, Debug, PartialEq)] /// Ordered drawing commands submitted to a renderer. @@ -18,6 +21,8 @@ pub enum DisplayCommand { }, BeginLayer(LayerStyle), EndLayer, + BeginCompositor(CompositorLayer), + EndCompositor, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -25,6 +30,7 @@ pub enum DisplayCommand { pub enum DisplayListError { UnexpectedLayerEnd { command: usize }, UnclosedLayers { count: usize }, + MismatchedLayerEnd { command: usize }, } impl fmt::Display for DisplayListError { @@ -39,6 +45,12 @@ impl fmt::Display for DisplayListError { Self::UnclosedLayers { count } => { write!(formatter, "display list has {count} unclosed layers") } + Self::MismatchedLayerEnd { command } => { + write!( + formatter, + "display list closes a different layer at command {command}" + ) + } } } } @@ -116,6 +128,110 @@ impl DisplayList { self.commands.push(DisplayCommand::EndLayer); } + /// Begins a retained compositor group; pair it with [`Self::end_compositor`]. + /// + /// * `layer` — stable compositor metadata for the enclosed commands. + pub fn begin_compositor(&mut self, layer: CompositorLayer) { + self.commands.push(DisplayCommand::BeginCompositor(layer)); + } + + /// Ends the most recently begun compositor group. + pub fn end_compositor(&mut self) { + self.commands.push(DisplayCommand::EndCompositor); + } + + /// Applies composition-only patches without modifying retained paint commands. + /// + /// * `patches` — latest transform and opacity for retained layer identities. + /// + /// Returns the number of compositor entries whose presentation changed. + pub fn apply_compositor_patches(&mut self, patches: &[CompositorPatch]) -> usize { + let patches = patches + .iter() + .map(|patch| (patch.id, (patch.transform, patch.opacity))) + .collect::>(); + let mut changed = 0; + for command in &mut self.commands { + let DisplayCommand::BeginCompositor(layer) = command else { + continue; + }; + let Some((transform, opacity)) = patches.get(&layer.id) else { + continue; + }; + changed += usize::from(layer.update(*transform, *opacity)); + } + changed + } + + /// Returns retained compositor layers in display order. + pub fn compositor_layers(&self) -> impl Iterator { + self.commands.iter().filter_map(|command| match command { + DisplayCommand::BeginCompositor(layer) => Some(layer), + _ => None, + }) + } + + /// Updates the source bounds for one retained compositor layer. + /// + /// * `id` — layer whose painted bounds changed. + /// * `bounds` — new surface-space source bounds. + /// + /// Returns whether the layer was found. + pub fn set_compositor_bounds(&mut self, id: CompositorId, bounds: argui_core::Rect) -> bool { + self.commands.iter_mut().any(|command| { + let DisplayCommand::BeginCompositor(layer) = command else { + return false; + }; + if layer.id != id { + return false; + } + layer.bounds = bounds; + true + }) + } + + /// Expands every compositor source to include its retained descendant primitives. + /// + /// Text is covered by the compositor's initial element bounds. Quad, image, + /// vector, effect, and nested compositor bounds expand that conservative base. + pub fn resolve_compositor_bounds(&mut self) { + let mut stack = Vec::<(CompositorId, argui_core::Rect)>::new(); + let mut resolved = Vec::new(); + for command in &self.commands { + match command { + DisplayCommand::BeginCompositor(layer) => stack.push((layer.id, layer.bounds)), + DisplayCommand::EndCompositor => { + let Some((id, bounds)) = stack.pop() else { + continue; + }; + resolved.push((id, bounds)); + if let Some((_, parent)) = stack.last_mut() { + *parent = union(*parent, bounds); + } + } + DisplayCommand::Quad(quad) => { + expand_current(&mut stack, quad.transform.transform_rect(quad.bounds)); + } + DisplayCommand::Image(image) => { + expand_current(&mut stack, image.transform.transform_rect(image.bounds)); + } + DisplayCommand::GpuCanvas(canvas) => { + expand_current(&mut stack, canvas.transform.transform_rect(canvas.bounds)); + } + DisplayCommand::Vector(vector) => { + expand_current(&mut stack, vector.transform.transform_rect(vector.bounds)); + } + DisplayCommand::BeginLayer(layer) => { + expand_current(&mut stack, layer.transformed_bounds()); + } + DisplayCommand::Text { .. } | DisplayCommand::EndLayer => {} + } + } + for (id, bounds) in resolved { + self.set_compositor_bounds(id, bounds); + } + } + /// Removes all commands and resets the cached quad count. pub fn clear(&mut self) { self.commands.clear(); @@ -170,16 +286,32 @@ impl DisplayList { /// /// # Errors /// Returns [`DisplayListError::UnexpectedLayerEnd`] for an unmatched end or + /// [`DisplayListError::MismatchedLayerEnd`] for the wrong end kind, or /// [`DisplayListError::UnclosedLayers`] when the list ends inside a layer. pub fn validate(&self) -> Result<(), DisplayListError> { - let mut depth = 0_usize; + #[derive(Clone, Copy, Eq, PartialEq)] + enum LayerKind { + Effect, + Compositor, + } + let mut stack = Vec::new(); for (command, item) in self.commands.iter().enumerate() { match item { - DisplayCommand::BeginLayer(_) => depth += 1, - DisplayCommand::EndLayer if depth == 0 => { + DisplayCommand::BeginLayer(_) => stack.push(LayerKind::Effect), + DisplayCommand::BeginCompositor(_) => stack.push(LayerKind::Compositor), + DisplayCommand::EndLayer | DisplayCommand::EndCompositor if stack.is_empty() => { return Err(DisplayListError::UnexpectedLayerEnd { command }); } - DisplayCommand::EndLayer => depth -= 1, + DisplayCommand::EndLayer => { + if stack.pop() != Some(LayerKind::Effect) { + return Err(DisplayListError::MismatchedLayerEnd { command }); + } + } + DisplayCommand::EndCompositor => { + if stack.pop() != Some(LayerKind::Compositor) { + return Err(DisplayListError::MismatchedLayerEnd { command }); + } + } DisplayCommand::Quad(_) | DisplayCommand::Image(_) | DisplayCommand::GpuCanvas(_) @@ -187,10 +319,29 @@ impl DisplayList { | DisplayCommand::Text { .. } => {} } } - if depth == 0 { + if stack.is_empty() { Ok(()) } else { - Err(DisplayListError::UnclosedLayers { count: depth }) + Err(DisplayListError::UnclosedLayers { count: stack.len() }) } } } + +/// Expands the innermost open compositor layer to include `bounds`. +fn expand_current(stack: &mut [(CompositorId, argui_core::Rect)], bounds: argui_core::Rect) { + if let Some((_, current)) = stack.last_mut() { + *current = union(*current, bounds); + } +} + +/// Returns the conservative axis-aligned union of two rectangles. +fn union(left: argui_core::Rect, right: argui_core::Rect) -> argui_core::Rect { + let x = left.origin.x.min(right.origin.x); + let y = left.origin.y.min(right.origin.y); + let right_edge = (left.origin.x + left.size.width).max(right.origin.x + right.size.width); + let bottom = (left.origin.y + left.size.height).max(right.origin.y + right.size.height); + argui_core::Rect::new( + argui_core::Point::new(x, y), + argui_core::Size::new((right_edge - x).max(0.0), (bottom - y).max(0.0)), + ) +} diff --git a/crates/argui-paint/src/effect.rs b/crates/argui-paint/src/effect.rs index 1096a70b..f119a5f4 100644 --- a/crates/argui-paint/src/effect.rs +++ b/crates/argui-paint/src/effect.rs @@ -1,4 +1,4 @@ -use argui_core::{Color, Point, Rect, Size}; +use argui_core::{Affine2D, Color, Point, Rect, Size}; use crate::CornerRadii; @@ -295,6 +295,10 @@ pub enum LayerMask { #[derive(Clone, Debug, PartialEq)] pub struct LayerStyle { pub bounds: Rect, + /// Composition-time transform applied after the layer content is rendered. + pub transform: Affine2D, + /// Whether the renderer should retain this layer even at identity presentation. + pub retained: bool, pub opacity: f32, pub blend_mode: BlendMode, pub filters: Vec, @@ -310,6 +314,8 @@ impl LayerStyle { pub const fn new(bounds: Rect) -> Self { Self { bounds, + transform: Affine2D::IDENTITY, + retained: false, opacity: 1.0, blend_mode: BlendMode::Normal, filters: Vec::new(), @@ -348,6 +354,26 @@ impl LayerStyle { self } + /// Sets the composition-time transform applied to the completed layer. + /// + /// * `transform` — affine transform in logical surface coordinates. + #[must_use] + pub const fn transform(mut self, transform: Affine2D) -> Self { + self.transform = transform; + self + } + + /// Marks this layer as retained compositor content. + /// + /// Retained layers receive an offscreen surface even while their current + /// transform and opacity are at identity, avoiding a promotion hitch when + /// an animation begins. + #[must_use] + pub const fn retained(mut self, retained: bool) -> Self { + self.retained = retained; + self + } + /// Sets the blend mode used when compositing the layer. /// * `blend_mode` — compositing blend mode. #[must_use] @@ -374,7 +400,9 @@ impl LayerStyle { /// Returns whether the style requires an offscreen compositing pass. #[must_use] pub fn requires_offscreen(&self) -> bool { - self.opacity != 1.0 + self.retained + || self.transform != Affine2D::IDENTITY + || self.opacity != 1.0 || self.blend_mode != BlendMode::Normal || !self.filters.is_empty() || !self.backdrop_filters.is_empty() @@ -405,6 +433,12 @@ impl LayerStyle { ) } + /// Returns expanded layer bounds after applying the composition transform. + #[must_use] + pub fn transformed_bounds(&self) -> Rect { + self.transform.transform_rect(self.expanded_bounds()) + } + /// Returns bounds expanded for foreground filters only. #[must_use] pub fn foreground_expansion(&self) -> f32 { @@ -428,6 +462,7 @@ impl LayerStyle { self.bounds.size.height * factor, ), ); + scaled.transform = self.transform.scaled(factor); scaled.filters = self .filters .iter() diff --git a/crates/argui-paint/src/lib.rs b/crates/argui-paint/src/lib.rs index d1839761..e7c9544d 100644 --- a/crates/argui-paint/src/lib.rs +++ b/crates/argui-paint/src/lib.rs @@ -1,5 +1,6 @@ //! Renderer-independent painting primitives and ordered display lists. +mod compositor; mod display_list; mod effect; mod gpu_canvas; @@ -8,6 +9,7 @@ mod vector; mod visual; pub use argui_core::{Color, ColorInterpolation}; +pub use compositor::{CompositorId, CompositorLayer, CompositorPatch}; pub use display_list::{DisplayCommand, DisplayList, DisplayListError}; pub use effect::{ BlendMode, EffectArgument, EffectId, EffectInstance, EffectValue, Filter, LayerMask, diff --git a/crates/argui-paint/src/visual.rs b/crates/argui-paint/src/visual.rs index 6013cb31..0c567eb9 100644 --- a/crates/argui-paint/src/visual.rs +++ b/crates/argui-paint/src/visual.rs @@ -314,6 +314,8 @@ pub struct ClipRegion { pub bounds: Rect, pub transform: Affine2D, pub radii: crate::CornerRadii, + /// Retained compositor layer that owns this transform, when known. + pub compositor: Option, } impl ClipRegion { @@ -325,6 +327,7 @@ impl ClipRegion { bounds, transform, radii: crate::CornerRadii::all(0.0), + compositor: None, } } @@ -336,9 +339,19 @@ impl ClipRegion { bounds, transform, radii, + compositor: None, } } + /// Associates this clip with the compositor layer that moves it. + /// + /// * `compositor` — retained layer whose transform applies to the clip. + #[must_use] + pub const fn compositor(mut self, compositor: crate::CompositorId) -> Self { + self.compositor = Some(compositor); + self + } + /// Returns whether `point` lies inside the transformed rounded region. #[must_use] pub fn contains(self, point: Point) -> bool { diff --git a/crates/argui-paint/tests/compositor.rs b/crates/argui-paint/tests/compositor.rs new file mode 100644 index 00000000..65f08b8f --- /dev/null +++ b/crates/argui-paint/tests/compositor.rs @@ -0,0 +1,68 @@ +use argui_core::{Affine2D, Point, Rect, Size}; +use argui_paint::{CompositorId, CompositorLayer, CompositorPatch, DisplayCommand, DisplayList}; + +fn bounds(x: f32, y: f32, width: f32, height: f32) -> Rect { + Rect::new(Point::new(x, y), Size::new(width, height)) +} + +#[test] +fn compositor_patches_change_only_presentation_properties() { + let id = CompositorId::new(42); + let layer = CompositorLayer::new( + id, + bounds(10.0, 20.0, 80.0, 40.0), + Affine2D::IDENTITY, + Affine2D::IDENTITY, + f32::NAN, + ); + let mut list = DisplayList::new(); + list.begin_compositor(layer.clone()); + list.end_compositor(); + + assert_eq!(layer.opacity, 1.0); + assert_eq!( + list.apply_compositor_patches(&[CompositorPatch::new( + id, + Affine2D::translation(12.0, -4.0), + 1.5, + )]), + 1 + ); + let DisplayCommand::BeginCompositor(patched) = &list.commands()[0] else { + panic!("missing compositor layer"); + }; + assert_eq!(patched.bounds, layer.bounds); + assert_eq!(patched.base_transform, layer.base_transform); + assert_eq!(patched.transform, Affine2D::translation(12.0, -4.0)); + assert_eq!(patched.opacity, 1.0); + assert!(patched.style().retained); +} + +#[test] +fn compositor_bounds_include_retained_descendant_layers() { + let outer = CompositorId::new(1); + let inner = CompositorId::new(2); + let mut list = DisplayList::new(); + list.begin_compositor(CompositorLayer::new( + outer, + bounds(10.0, 10.0, 20.0, 20.0), + Affine2D::IDENTITY, + Affine2D::IDENTITY, + 1.0, + )); + list.begin_compositor(CompositorLayer::new( + inner, + bounds(50.0, 60.0, 30.0, 40.0), + Affine2D::IDENTITY, + Affine2D::IDENTITY, + 1.0, + )); + list.end_compositor(); + list.end_compositor(); + + list.resolve_compositor_bounds(); + + let layers = list.compositor_layers().collect::>(); + assert_eq!(layers[0].bounds, bounds(10.0, 10.0, 70.0, 90.0)); + assert_eq!(layers[1].bounds, bounds(50.0, 60.0, 30.0, 40.0)); +} diff --git a/crates/argui-paint/tests/effect.rs b/crates/argui-paint/tests/effect.rs index 54fe630a..67f029e3 100644 --- a/crates/argui-paint/tests/effect.rs +++ b/crates/argui-paint/tests/effect.rs @@ -54,6 +54,13 @@ fn effect_layers_are_explicit_and_compute_conservative_bounds() { fn every_layer_feature_independently_requests_offscreen_rendering() { let plain = LayerStyle::new(bounds()); assert!(!plain.requires_offscreen()); + assert!(plain.clone().retained(true).requires_offscreen()); + assert!( + plain + .clone() + .transform(argui_core::Affine2D::translation(3.0, 4.0)) + .requires_offscreen() + ); assert!(plain.clone().opacity(0.5).requires_offscreen()); assert!( plain @@ -139,3 +146,30 @@ fn balanced_nested_layers_validate_without_gpu_state() { .contains("unclosed") ); } + +#[test] +fn compositor_and_effect_layers_must_close_in_stack_order() { + use argui_paint::{CompositorId, CompositorLayer}; + + let compositor = CompositorLayer::new( + CompositorId::new(7), + bounds(), + argui_core::Affine2D::IDENTITY, + argui_core::Affine2D::IDENTITY, + 1.0, + ); + let mut list = DisplayList::new(); + list.begin_compositor(compositor.clone()); + list.begin_layer(LayerStyle::new(bounds())); + list.end_layer(); + list.end_compositor(); + assert_eq!(list.validate(), Ok(())); + + let mut mismatched = DisplayList::new(); + mismatched.begin_compositor(compositor); + mismatched.end_layer(); + assert_eq!( + mismatched.validate(), + Err(DisplayListError::MismatchedLayerEnd { command: 1 }) + ); +} diff --git a/crates/argui-platform/Cargo.toml b/crates/argui-platform/Cargo.toml index 5d3de447..43a3ea9d 100644 --- a/crates/argui-platform/Cargo.toml +++ b/crates/argui-platform/Cargo.toml @@ -44,6 +44,16 @@ wayland-protocols-plasma = { version = "=0.3.12", features = ["client"], optiona wayland-backend = { version = "=0.3.17", features = ["client_system"], optional = true } raw-window-handle = { version = "=0.6.2", optional = true } +[target.'cfg(target_os = "android")'.dependencies] +android-activity = { version = "=0.6.1", features = ["native-activity"] } +jni = "=0.22.4" + +[target.'cfg(target_os = "ios")'.dependencies] +block2 = "=0.6.2" +dispatch2 = { version = "=0.3.1", default-features = false, features = ["std", "block2", "objc2"] } +objc2 = { version = "=0.6.4", default-features = false, features = ["std"] } +objc2-ui-kit = { version = "=0.3.2", default-features = false, features = ["UIApplication", "UIResponder", "block2"] } + [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true, features = ["UI_ViewManagement"] } diff --git a/crates/argui-platform/src/lib.rs b/crates/argui-platform/src/lib.rs index 19433df2..b35c11d2 100644 --- a/crates/argui-platform/src/lib.rs +++ b/crates/argui-platform/src/lib.rs @@ -12,6 +12,7 @@ pub mod file_picker; #[cfg(all(feature = "gtk-host", target_os = "linux"))] pub mod gtk_host; mod identity; +pub mod mobile; #[cfg(all(feature = "tray", not(target_arch = "wasm32")))] mod native_tray; #[cfg(all(feature = "native-popups", not(target_arch = "wasm32")))] diff --git a/crates/argui-platform/src/mobile.rs b/crates/argui-platform/src/mobile.rs new file mode 100644 index 00000000..88767a4a --- /dev/null +++ b/crates/argui-platform/src/mobile.rs @@ -0,0 +1,274 @@ +//! Native mobile background-activity and system-inset support. + +use std::sync::{Arc, Mutex}; + +use crate::Insets; +#[cfg(target_os = "android")] +use argui_core::ColorScheme; + +#[cfg(target_os = "android")] +mod android; +#[cfg(target_os = "ios")] +mod ios; + +/// Physical-pixel distances from each edge of a mobile window. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PhysicalInsets { + /// Distance from the top edge in physical pixels. + pub top: u32, + /// Distance from the right edge in physical pixels. + pub right: u32, + /// Distance from the bottom edge in physical pixels. + pub bottom: u32, + /// Distance from the left edge in physical pixels. + pub left: u32, +} + +impl PhysicalInsets { + /// Converts physical edge distances to Argui logical pixels. + /// + /// # Arguments + /// * `scale_factor` — physical pixels per logical pixel; invalid values fall back to `1.0`. + /// + /// # Returns + /// Insets in logical-pixel top, right, bottom, left order. + #[must_use] + pub fn to_logical(self, scale_factor: f32) -> Insets { + let scale_factor = if scale_factor.is_finite() && scale_factor > 0.0 { + scale_factor + } else { + 1.0 + }; + Insets::new( + self.top as f32 / scale_factor, + self.right as f32 / scale_factor, + self.bottom as f32 / scale_factor, + self.left as f32 / scale_factor, + ) + } +} + +/// The strongest background-activity behavior supported by the current platform. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MobileActivityCapability { + /// Android can display an ongoing foreground-service notification. + OngoingNotification, + /// iOS may show a Live Activity or use its bounded background-task fallback. + LiveActivityOrTimeLimited, + /// The current target has no mobile background-activity integration. + ForegroundOnly, +} + +/// Failure to start, update, or finish a native mobile activity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MobileActivityError { + /// This platform does not expose the requested mobile activity behavior. + Unsupported, + /// Android is asking for notification permission before starting the foreground service. + NotificationPermissionRequired, + /// A native operating-system call failed with the supplied diagnostic. + Native(String), +} + +impl std::fmt::Display for MobileActivityError { + /// Formats a short user-readable mobile activity error. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unsupported => formatter.write_str("mobile background activity is unsupported"), + Self::NotificationPermissionRequired => { + formatter.write_str("notification permission is required to start this activity") + } + Self::Native(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for MobileActivityError {} + +/// Owns one native mobile background activity and finishes it when dropped. +#[derive(Debug)] +pub struct MobileActivity { + active: Arc>, + #[cfg(target_os = "ios")] + native: ios::IosActivity, +} + +impl MobileActivity { + /// Returns the background behavior available on the current target. + #[must_use] + pub const fn capability() -> MobileActivityCapability { + #[cfg(target_os = "android")] + { + return MobileActivityCapability::OngoingNotification; + } + #[cfg(target_os = "ios")] + { + return MobileActivityCapability::LiveActivityOrTimeLimited; + } + #[allow(unreachable_code)] + MobileActivityCapability::ForegroundOnly + } + + /// Starts a native activity with a title and explanatory message. + /// + /// # Arguments + /// * `title` — short title shown by Android's ongoing notification or iOS Live Activity. + /// * `message` — initial progress text shown where the platform exposes task status. + /// + /// # Returns + /// An owner that stops the native activity when explicitly finished or dropped. + /// + /// # Errors + /// Returns [`MobileActivityError::Unsupported`] on non-mobile targets, + /// [`MobileActivityError::NotificationPermissionRequired`] when Android asks + /// the user to grant notification permission, or [`MobileActivityError::Native`] + /// when the operating system rejects the request. + pub fn begin(title: &str, message: &str) -> Result { + #[cfg(target_os = "android")] + { + android::start(title, message)?; + Ok(Self { + active: Arc::new(Mutex::new(true)), + }) + } + #[cfg(target_os = "ios")] + { + let native = ios::start(title, message)?; + Ok(Self { + active: Arc::new(Mutex::new(true)), + native, + }) + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let _ = (title, message); + Err(MobileActivityError::Unsupported) + } + } + + /// Creates a sendable progress reporter for work running on another thread. + #[must_use] + pub fn progress(&self) -> MobileActivityProgress { + MobileActivityProgress { + active: Arc::clone(&self.active), + #[cfg(target_os = "ios")] + native_id: self.native.progress_id(), + } + } + + /// Ends the native activity and removes its ongoing notification where available. + /// + /// # Errors + /// Returns [`MobileActivityError::Native`] if the operating system rejects the stop request. + pub fn finish(&mut self) -> Result<(), MobileActivityError> { + let mut active = self + .active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !*active { + return Ok(()); + } + *active = false; + #[cfg(target_os = "android")] + android::finish()?; + #[cfg(target_os = "ios")] + self.native.finish()?; + Ok(()) + } +} + +impl Drop for MobileActivity { + /// Best-effort cleanup when the owner leaves scope. + fn drop(&mut self) { + let _ = self.finish(); + } +} + +/// A cloneable handle for updating native progress from background work. +#[derive(Clone, Debug)] +pub struct MobileActivityProgress { + active: Arc>, + #[cfg(target_os = "ios")] + native_id: Option, +} + +impl MobileActivityProgress { + /// Updates the platform activity with a percentage and message. + /// + /// # Arguments + /// * `percent` — progress from 0 through 100; values above 100 are clamped. + /// * `message` — short status text shown by Android's notification or iOS Live Activity. + /// + /// # Errors + /// Returns [`MobileActivityError::Native`] if the native progress surface rejects the update. + pub fn update(&self, percent: u8, message: &str) -> Result<(), MobileActivityError> { + let active = self + .active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !*active { + return Ok(()); + } + #[cfg(target_os = "android")] + android::update(percent, message)?; + #[cfg(target_os = "ios")] + if let Some(native_id) = self.native_id { + ios::update(native_id, percent, message)?; + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + let _ = (percent, message); + Ok(()) + } +} + +/// Initializes the shared Android activity/JVM bridge used by safe areas and notifications. +/// +/// # Arguments +/// * `android_app` — active NativeActivity handle supplied by `android-activity`. +/// +/// # Errors +/// Returns a native diagnostic if JNI cannot retain the activity/JVM context. +#[cfg(target_os = "android")] +pub fn initialize_android( + android_app: &android_activity::AndroidApp, +) -> Result<(), MobileActivityError> { + android::initialize(android_app).map_err(MobileActivityError::Native) +} + +/// Reads Android's current system-bar and display-cutout insets in logical pixels. +/// +/// # Arguments +/// * `scale_factor` — physical pixels per logical pixel for the current window. +/// +/// # Returns +/// Current insets, or `None` until Android has attached window insets to its decor view. +#[cfg(target_os = "android")] +#[must_use] +pub fn android_safe_area_insets(scale_factor: f32) -> Option { + Some(android::safe_area_insets()?.to_logical(scale_factor)) +} + +/// Updates Android's status- and navigation-bar icon contrast for the active theme. +/// +/// Calls before [`initialize_android`] are ignored. Repeating the current scheme is a no-op. +/// +/// # Arguments +/// * `scheme` — resolved application color scheme behind the transparent system bars. +#[cfg(target_os = "android")] +pub fn set_android_system_bar_color_scheme(scheme: ColorScheme) { + android::set_system_bar_color_scheme(scheme); +} + +/// Requests the Android soft keyboard through the retained NativeActivity handle. +/// +/// This explicit request supplements Winit's `set_ime_allowed` policy; showing +/// input uses Android's non-implicit request so a focused editor reliably opens +/// the keyboard. Hiding input uses Android's soft-input hide request. +/// Calls before [`initialize_android`] are ignored. +/// +/// # Arguments +/// * `visible` — whether to show the soft keyboard; `false` hides it. +#[cfg(target_os = "android")] +pub fn set_android_soft_input_visible(visible: bool) { + android::set_soft_input_visible(visible); +} diff --git a/crates/argui-platform/src/mobile/android.rs b/crates/argui-platform/src/mobile/android.rs new file mode 100644 index 00000000..5454679b --- /dev/null +++ b/crates/argui-platform/src/mobile/android.rs @@ -0,0 +1,570 @@ +use std::sync::{ + Arc, Mutex, MutexGuard, OnceLock, + atomic::{AtomicBool, AtomicU8, Ordering}, +}; + +use android_activity::AndroidApp; +use argui_core::ColorScheme; +use jni::{ + Env, JValue, JavaVM, jni_sig, jni_str, + objects::{Global, JClass, JObject}, +}; + +use super::{MobileActivityError, PhysicalInsets}; + +struct AndroidContext { + app: AndroidApp, + vm: JavaVM, + activity: Global>, + helper: Mutex>>>, + soft_input_visible: AtomicBool, + system_bar_scheme: AtomicU8, +} + +static CONTEXT: OnceLock>>> = OnceLock::new(); + +/// Returns the process-wide lock protecting the retained Android JNI context. +fn context_lock() -> &'static Mutex>> { + CONTEXT.get_or_init(|| Mutex::new(None)) +} + +/// Locks the Android context and recovers it if another caller panicked while holding the lock. +fn lock_context() -> MutexGuard<'static, Option>> { + context_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Initializes JNI state and retains the active NativeActivity without requiring app Java code. +#[allow(unsafe_code)] +pub(super) fn initialize(android_app: &AndroidApp) -> Result<(), String> { + // SAFETY: android-activity returns the process JVM pointer and guarantees it remains valid + // for the NativeActivity lifetime; this context only uses it while the app process is alive. + let vm = unsafe { JavaVM::from_raw(android_app.vm_as_ptr().cast()) }; + let app = android_app.clone(); + let created = vm + .attach_current_thread_for_scope(|env| -> jni::errors::Result { + let raw_activity = android_app.activity_as_ptr().cast(); + // SAFETY: android-activity documents this as an unowned global reference valid while + // `android_app` is alive. `new_global_ref` below creates our own retained reference. + let borrowed_activity = + unsafe { env.as_cast_raw::>>(&raw_activity)? }; + let activity = env.new_global_ref(borrowed_activity)?; + Ok(AndroidContext { + app, + vm: vm.clone(), + activity, + helper: Mutex::new(None), + soft_input_visible: AtomicBool::new(false), + system_bar_scheme: AtomicU8::new(0), + }) + }) + .map_err(|error| error.to_string())?; + + *lock_context() = Some(Arc::new(created)); + configure_edge_to_edge() +} + +/// Configures the retained NativeActivity for transparent edge-to-edge system bars. +fn configure_edge_to_edge() -> Result<(), String> { + with_context(|env, context| { + let window = env + .call_method( + context.activity.as_ref(), + jni_str!("getWindow"), + jni_sig!("()Landroid/view/Window;"), + &[], + )? + .l()?; + env.call_method( + &window, + jni_str!("setStatusBarColor"), + jni_sig!("(I)V"), + &[JValue::Int(0)], + )?; + env.call_method( + &window, + jni_str!("setNavigationBarColor"), + jni_sig!("(I)V"), + &[JValue::Int(0)], + )?; + if android_sdk_version(env)? >= 30 { + env.call_method( + &window, + jni_str!("setDecorFitsSystemWindows"), + jni_sig!("(Z)V"), + &[JValue::Bool(false)], + )?; + } else { + let decor = env + .call_method( + &window, + jni_str!("getDecorView"), + jni_sig!("()Landroid/view/View;"), + &[], + )? + .l()?; + let current = env + .call_method( + &decor, + jni_str!("getSystemUiVisibility"), + jni_sig!("()I"), + &[], + )? + .i()?; + let layout = android_view_flag(env, "SYSTEM_UI_FLAG_LAYOUT_STABLE")? + | android_view_flag(env, "SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN")? + | android_view_flag(env, "SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION")?; + env.call_method( + &decor, + jni_str!("setSystemUiVisibility"), + jni_sig!("(I)V"), + &[JValue::Int(current | layout)], + )?; + } + Ok(()) + }) +} + +/// Updates Android's transparent system-bar icon contrast when the resolved theme changes. +pub(super) fn set_system_bar_color_scheme(scheme: ColorScheme) { + let context = { + let guard = lock_context(); + guard.as_ref().cloned() + }; + let Some(context) = context else { + return; + }; + let encoded = match scheme { + ColorScheme::Light => 1, + ColorScheme::Dark => 2, + }; + if context.system_bar_scheme.swap(encoded, Ordering::AcqRel) == encoded { + return; + } + let _ = with_context(|env, context| { + let window = env + .call_method( + context.activity.as_ref(), + jni_str!("getWindow"), + jni_sig!("()Landroid/view/Window;"), + &[], + )? + .l()?; + let light_background = scheme == ColorScheme::Light; + let sdk_version = android_sdk_version(env)?; + if sdk_version >= 30 { + let controller = env + .call_method( + &window, + jni_str!("getInsetsController"), + jni_sig!("()Landroid/view/WindowInsetsController;"), + &[], + )? + .l()?; + if controller.is_null() { + return Ok(()); + } + let mask = env + .get_static_field( + jni_str!("android/view/WindowInsetsController"), + jni_str!("APPEARANCE_LIGHT_STATUS_BARS"), + jni_sig!("I"), + )? + .i()? + | env + .get_static_field( + jni_str!("android/view/WindowInsetsController"), + jni_str!("APPEARANCE_LIGHT_NAVIGATION_BARS"), + jni_sig!("I"), + )? + .i()?; + env.call_method( + &controller, + jni_str!("setSystemBarsAppearance"), + jni_sig!("(II)V"), + &[ + JValue::Int(if light_background { mask } else { 0 }), + JValue::Int(mask), + ], + )?; + return Ok(()); + } + let decor = env + .call_method( + &window, + jni_str!("getDecorView"), + jni_sig!("()Landroid/view/View;"), + &[], + )? + .l()?; + let current = env + .call_method( + &decor, + jni_str!("getSystemUiVisibility"), + jni_sig!("()I"), + &[], + )? + .i()?; + let mut mask = 0; + if sdk_version >= 23 { + mask |= android_view_flag(env, "SYSTEM_UI_FLAG_LIGHT_STATUS_BAR")?; + } + if sdk_version >= 26 { + mask |= android_view_flag(env, "SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR")?; + } + env.call_method( + &decor, + jni_str!("setSystemUiVisibility"), + jni_sig!("(I)V"), + &[JValue::Int(if light_background { + current | mask + } else { + current & !mask + })], + )?; + Ok(()) + }); +} + +/// Reads the device API level used to select compatible window APIs. +fn android_sdk_version(env: &mut Env<'_>) -> jni::errors::Result { + env.get_static_field( + jni_str!("android/os/Build$VERSION"), + jni_str!("SDK_INT"), + jni_sig!("I"), + )? + .i() +} + +/// Reads one public `android.view.View` system-UI flag by field name. +fn android_view_flag(env: &mut Env<'_>, name: &'static str) -> jni::errors::Result { + let field = match name { + "SYSTEM_UI_FLAG_LAYOUT_STABLE" => jni_str!("SYSTEM_UI_FLAG_LAYOUT_STABLE"), + "SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN" => jni_str!("SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN"), + "SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION" => { + jni_str!("SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION") + } + "SYSTEM_UI_FLAG_LIGHT_STATUS_BAR" => jni_str!("SYSTEM_UI_FLAG_LIGHT_STATUS_BAR"), + _ => jni_str!("SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR"), + }; + env.get_static_field(jni_str!("android/view/View"), field, jni_sig!("I"))? + .i() +} + +/// Runs one JNI operation with an attached thread and retained app references. +fn with_context( + operation: impl FnOnce(&mut Env<'_>, &AndroidContext) -> jni::errors::Result, +) -> Result { + let context = lock_context() + .as_ref() + .cloned() + .ok_or_else(|| "Android mobile context is not initialized".to_owned())?; + context + .vm + .attach_current_thread_for_scope(|env| operation(env, &context)) + .map_err(|error| error.to_string()) +} + +/// Runs one mobile-activity JNI operation with its lazily loaded Java helper class. +fn with_helper( + env: &mut Env<'_>, + context: &AndroidContext, + operation: impl FnOnce(&mut Env<'_>, &JClass<'_>) -> jni::errors::Result, +) -> jni::errors::Result { + let mut helper = context + .helper + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if helper.is_none() { + let class_loader = env + .call_method( + context.activity.as_ref(), + jni_str!("getClassLoader"), + jni_sig!("()Ljava/lang/ClassLoader;"), + &[], + )? + .l()?; + let helper_name = env.new_string("dev.argui.android.MobileActivityHost")?; + let helper_object = env + .call_method( + &class_loader, + jni_str!("loadClass"), + jni_sig!("(Ljava/lang/String;)Ljava/lang/Class;"), + &[JValue::Object(&helper_name)], + )? + .l()?; + *helper = Some(env.new_cast_global_ref::>(helper_object)?); + } + let helper = helper + .as_ref() + .ok_or(jni::errors::Error::NullPtr("MobileActivityHost class"))?; + operation(env, helper) +} + +/// Requests Android notification permission and begins the foreground service when allowed. +pub(super) fn start(title: &str, message: &str) -> Result<(), MobileActivityError> { + let permission_granted = with_context(|env, context| { + with_helper(env, context, |env, helper| { + env.call_static_method( + helper, + jni_str!("prepareNotificationPermission"), + jni_sig!("(Landroid/app/Activity;)Z"), + &[JValue::Object(context.activity.as_ref())], + )? + .z() + }) + }) + .map_err(MobileActivityError::Native)?; + if !permission_granted { + return Err(MobileActivityError::NotificationPermissionRequired); + } + + with_context(|env, context| { + let title = env.new_string(title)?; + let message = env.new_string(message)?; + with_helper(env, context, |env, helper| { + env.call_static_method( + helper, + jni_str!("start"), + jni_sig!("(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)V"), + &[ + JValue::Object(context.activity.as_ref()), + JValue::Object(title.as_ref()), + JValue::Object(message.as_ref()), + ], + )?; + Ok(()) + }) + }) + .map_err(MobileActivityError::Native) +} + +/// Updates the foreground notification using the supplied percentage and message. +pub(super) fn update(percent: u8, message: &str) -> Result<(), MobileActivityError> { + with_context(|env, context| { + let message = env.new_string(message)?; + with_helper(env, context, |env, helper| { + env.call_static_method( + helper, + jni_str!("update"), + jni_sig!("(Landroid/app/Activity;ILjava/lang/String;)V"), + &[ + JValue::Object(context.activity.as_ref()), + JValue::Int(i32::from(percent)), + JValue::Object(message.as_ref()), + ], + )?; + Ok(()) + }) + }) + .map_err(MobileActivityError::Native) +} + +/// Stops the foreground service and removes its notification. +pub(super) fn finish() -> Result<(), MobileActivityError> { + with_context(|env, context| { + with_helper(env, context, |env, helper| { + env.call_static_method( + helper, + jni_str!("finish"), + jni_sig!("(Landroid/app/Activity;)V"), + &[JValue::Object(context.activity.as_ref())], + )?; + Ok(()) + }) + }) + .map_err(MobileActivityError::Native) +} + +/// Reads the current root-window insets from Android's decor view. +pub(super) fn safe_area_insets() -> Option { + with_context(read_window_insets).ok().flatten() +} + +/// Explicitly shows or hides Android's soft keyboard through the retained activity. +pub(super) fn set_soft_input_visible(visible: bool) { + let context = { + let guard = lock_context(); + guard.as_ref().cloned() + }; + if let Some(context) = context { + if context.soft_input_visible.swap(visible, Ordering::AcqRel) == visible { + return; + } + if visible { + context.app.show_soft_input(false); + } else { + context.app.hide_soft_input(true); + } + } +} + +/// Reads system bars and display cutouts using the Android API available on this device. +fn read_window_insets( + env: &mut Env<'_>, + context: &AndroidContext, +) -> jni::errors::Result> { + let window = env + .call_method( + context.activity.as_ref(), + jni_str!("getWindow"), + jni_sig!("()Landroid/view/Window;"), + &[], + )? + .l()?; + let decor = env + .call_method( + &window, + jni_str!("getDecorView"), + jni_sig!("()Landroid/view/View;"), + &[], + )? + .l()?; + let root = env + .call_method( + &decor, + jni_str!("getRootWindowInsets"), + jni_sig!("()Landroid/view/WindowInsets;"), + &[], + )? + .l()?; + if root.is_null() { + return Ok(None); + } + + let sdk_version = env + .get_static_field( + jni_str!("android/os/Build$VERSION"), + jni_str!("SDK_INT"), + jni_sig!("I"), + )? + .i()?; + let insets = if sdk_version >= 30 { + read_modern_insets(env, &root)? + } else { + read_pre_android_11_insets(env, &root, sdk_version)? + }; + Ok(Some(insets)) +} + +/// Reads Android 11+ system-bar and cutout values through the typed inset API. +fn read_modern_insets( + env: &mut Env<'_>, + root: &JObject<'_>, +) -> jni::errors::Result { + let inset_types = env.find_class(jni_str!("android/view/WindowInsets$Type"))?; + let system_bars = env + .call_static_method(&inset_types, jni_str!("systemBars"), jni_sig!("()I"), &[])? + .i()?; + let display_cutout = env + .call_static_method( + &inset_types, + jni_str!("displayCutout"), + jni_sig!("()I"), + &[], + )? + .i()?; + let values = env + .call_method( + root, + jni_str!("getInsets"), + jni_sig!("(I)Landroid/graphics/Insets;"), + &[JValue::Int(system_bars | display_cutout)], + )? + .l()?; + read_insets_fields(env, &values) +} + +/// Reads Android 10-and-earlier system-window values and includes available display cutouts. +fn read_pre_android_11_insets( + env: &mut Env<'_>, + root: &JObject<'_>, + sdk_version: i32, +) -> jni::errors::Result { + let mut result = PhysicalInsets { + top: non_negative(read_int_method(env, root, "getSystemWindowInsetTop")?), + right: non_negative(read_int_method(env, root, "getSystemWindowInsetRight")?), + bottom: non_negative(read_int_method(env, root, "getSystemWindowInsetBottom")?), + left: non_negative(read_int_method(env, root, "getSystemWindowInsetLeft")?), + }; + if sdk_version >= 28 { + let cutout = env + .call_method( + root, + jni_str!("getDisplayCutout"), + jni_sig!("()Landroid/view/DisplayCutout;"), + &[], + )? + .l()?; + if !cutout.is_null() { + result.top = result.top.max(non_negative(read_int_method( + env, + &cutout, + "getSafeInsetTop", + )?)); + result.right = result.right.max(non_negative(read_int_method( + env, + &cutout, + "getSafeInsetRight", + )?)); + result.bottom = result.bottom.max(non_negative(read_int_method( + env, + &cutout, + "getSafeInsetBottom", + )?)); + result.left = result.left.max(non_negative(read_int_method( + env, + &cutout, + "getSafeInsetLeft", + )?)); + } + } + Ok(result) +} + +/// Reads one Android 10-and-earlier inset method from a Java object. +fn read_int_method( + env: &mut Env<'_>, + object: &JObject<'_>, + name: &'static str, +) -> jni::errors::Result { + let method = match name { + "getSystemWindowInsetTop" => jni_str!("getSystemWindowInsetTop"), + "getSystemWindowInsetRight" => jni_str!("getSystemWindowInsetRight"), + "getSystemWindowInsetBottom" => jni_str!("getSystemWindowInsetBottom"), + "getSystemWindowInsetLeft" => jni_str!("getSystemWindowInsetLeft"), + "getSafeInsetTop" => jni_str!("getSafeInsetTop"), + "getSafeInsetRight" => jni_str!("getSafeInsetRight"), + "getSafeInsetBottom" => jni_str!("getSafeInsetBottom"), + _ => jni_str!("getSafeInsetLeft"), + }; + env.call_method(object, method, jni_sig!("()I"), &[])?.i() +} + +/// Reads the four public fields returned by `WindowInsets.getInsets`. +fn read_insets_fields( + env: &mut Env<'_>, + values: &JObject<'_>, +) -> jni::errors::Result { + Ok(PhysicalInsets { + top: non_negative(env.get_field(values, jni_str!("top"), jni_sig!("I"))?.i()?), + right: non_negative( + env.get_field(values, jni_str!("right"), jni_sig!("I"))? + .i()?, + ), + bottom: non_negative( + env.get_field(values, jni_str!("bottom"), jni_sig!("I"))? + .i()?, + ), + left: non_negative( + env.get_field(values, jni_str!("left"), jni_sig!("I"))? + .i()?, + ), + }) +} + +/// Converts a signed Android inset to a non-negative physical-pixel distance. +const fn non_negative(value: i32) -> u32 { + if value > 0 { value as u32 } else { 0 } +} diff --git a/crates/argui-platform/src/mobile/ios.rs b/crates/argui-platform/src/mobile/ios.rs new file mode 100644 index 00000000..1b4a4567 --- /dev/null +++ b/crates/argui-platform/src/mobile/ios.rs @@ -0,0 +1,303 @@ +//! iOS Live Activity integration with a finite UIKit background-task fallback. + +use std::{ + ffi::{CString, c_char, c_void}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, +}; + +use block2::RcBlock; +use dispatch2::DispatchQueue; +use objc2::MainThreadMarker; +use objc2_ui_kit::{UIApplication, UIBackgroundTaskInvalid}; + +use super::MobileActivityError; + +/// Starts a Live Activity when the shell's Swift bridge is present, with a UIKit fallback. +/// +/// # Arguments +/// * `title` — static title shown by the Live Activity. +/// * `message` — initial progress text shown by the Live Activity. +/// +/// # Returns +/// An owner that ends the started native activity. +/// +/// # Errors +/// Returns [`MobileActivityError::Native`] if UIKit cannot create a background task or an input +/// contains an interior NUL byte. +pub(super) fn start(title: &str, message: &str) -> Result { + IosActivity::start(title, message) +} + +/// Sends progress to a Live Activity started by the supplied opaque handle. +/// +/// # Arguments +/// * `identifier` — handle returned by the ActivityKit bridge. +/// * `percent` — current progress from 0 through 100. +/// * `message` — short status text displayed by the Live Activity. +/// +/// # Errors +/// Returns [`MobileActivityError::Native`] if ActivityKit rejects the update or if the message +/// contains an interior NUL byte. The function is a no-op when a shell omitted its Swift bridge. +pub(super) fn update( + identifier: u64, + percent: u8, + message: &str, +) -> Result<(), MobileActivityError> { + IosActivity::update(identifier, percent, message) +} + +#[derive(Debug)] +enum ActivityTarget { + /// ActivityKit owns a visible Live Activity through the optional Swift bridge. + LiveActivity(u64), + /// UIKit granted a finite background-execution assertion. + BackgroundTask(Arc), + /// The activity was already finished. + Finished, +} + +/// Owns one iOS Live Activity or finite UIKit background-task assertion. +#[derive(Debug)] +pub(super) struct IosActivity { + target: ActivityTarget, +} + +impl IosActivity { + /// Starts ActivityKit when its Swift bridge is linked, otherwise requests UIKit background time. + /// + /// # Arguments + /// * `title` — static title displayed by the Live Activity. + /// * `message` — initial status text displayed by the Live Activity. + /// + /// # Returns + /// An owner that ends the Live Activity or releases its UIKit task identifier. + /// + /// # Errors + /// Returns [`MobileActivityError::Native`] when UIKit cannot create a background task or + /// when a title or message contains an interior NUL byte. + #[allow(unsafe_code)] + pub(super) fn start(title: &str, message: &str) -> Result { + let title = CString::new(title).map_err(|error| native_error(error.to_string()))?; + let message = CString::new(message).map_err(|error| native_error(error.to_string()))?; + + if let Some(bridge) = activity_bridge() { + // SAFETY: Swift exports this C ABI and accepts borrowed, NUL-terminated strings for + // the duration of the call. The bridge copies both strings before returning. + let identifier = unsafe { (bridge.start)(title.as_ptr(), message.as_ptr()) }; + if identifier != 0 { + return Ok(Self { + target: ActivityTarget::LiveActivity(identifier), + }); + } + } + + let task = begin_background_task()?; + Ok(Self { + target: ActivityTarget::BackgroundTask(task), + }) + } + + /// Returns the ActivityKit identifier used by a cloneable progress reporter, if available. + /// + /// # Returns + /// `Some` for an ActivityKit Live Activity, or `None` for a UIKit fallback or finished task. + pub(super) const fn progress_id(&self) -> Option { + match &self.target { + ActivityTarget::LiveActivity(identifier) => Some(*identifier), + ActivityTarget::BackgroundTask(_) | ActivityTarget::Finished => None, + } + } + + /// Updates this ActivityKit Live Activity with its latest progress text and percentage. + /// + /// # Arguments + /// * `identifier` — handle returned when the Live Activity started. + /// * `percent` — progress from 0 through 100; values above 100 are clamped. + /// * `message` — short status text displayed by the widget extension. + /// + /// # Errors + /// Returns [`MobileActivityError::Native`] if ActivityKit rejects the update or if the + /// message contains an interior NUL byte. A missing bridge is treated as a no-op so an app + /// shell without the optional widget extension remains usable. + #[allow(unsafe_code)] + pub(super) fn update( + identifier: u64, + percent: u8, + message: &str, + ) -> Result<(), MobileActivityError> { + let Some(bridge) = activity_bridge() else { + return Ok(()); + }; + let message = CString::new(message).map_err(|error| native_error(error.to_string()))?; + // SAFETY: The bridge accepts a scalar handle, percentage, and borrowed C string, and + // copies the string before returning. + let result = unsafe { (bridge.update)(identifier, percent.min(100), message.as_ptr()) }; + if result == 0 { + return Err(native_error("ActivityKit rejected the progress update")); + } + Ok(()) + } + + /// Ends the owned Live Activity or releases the finite UIKit background-task assertion. + /// + /// # Errors + /// Returns [`MobileActivityError::Native`] if the native bridge cannot finish an ActivityKit + /// activity or UIKit cannot release its task identifier. + #[allow(unsafe_code)] + pub(super) fn finish(&mut self) -> Result<(), MobileActivityError> { + let target = std::mem::replace(&mut self.target, ActivityTarget::Finished); + match target { + ActivityTarget::LiveActivity(identifier) => { + let Some(bridge) = activity_bridge() else { + return Err(native_error("ActivityKit bridge is no longer available")); + }; + // SAFETY: The scalar identifier was issued by the paired Swift bridge. + let result = unsafe { (bridge.finish)(identifier) }; + if result == 0 { + return Err(native_error("ActivityKit rejected the finish request")); + } + } + ActivityTarget::BackgroundTask(task) => end_background_task(task)?, + ActivityTarget::Finished => {} + } + Ok(()) + } +} + +#[derive(Clone, Copy)] +struct ActivityBridge { + start: unsafe extern "C" fn(*const c_char, *const c_char) -> u64, + update: unsafe extern "C" fn(u64, u8, *const c_char) -> i32, + finish: unsafe extern "C" fn(u64) -> i32, +} + +/// Looks up the Swift bridge without making a shell that omits it fail to link. +#[allow(unsafe_code)] +fn activity_bridge() -> Option { + let start = dynamic_symbol(b"argui_ios_activity_start\0")?; + let update = dynamic_symbol(b"argui_ios_activity_update\0")?; + let finish = dynamic_symbol(b"argui_ios_activity_finish\0")?; + // SAFETY: Swift exports these exact C signatures. Symbols are called only after all functions + // are found in the current process image. + Some(unsafe { + ActivityBridge { + start: std::mem::transmute::< + *mut c_void, + unsafe extern "C" fn(*const c_char, *const c_char) -> u64, + >(start), + update: std::mem::transmute::< + *mut c_void, + unsafe extern "C" fn(u64, u8, *const c_char) -> i32, + >(update), + finish: std::mem::transmute::<*mut c_void, unsafe extern "C" fn(u64) -> i32>(finish), + } + }) +} + +/// Resolves a symbol from the main executable or its linked frameworks. +#[allow(unsafe_code)] +fn dynamic_symbol(name: &'static [u8]) -> Option<*mut c_void> { + // `RTLD_DEFAULT` is the Darwin dlsym pseudo-handle defined by `` as `(void *)-2`. + const RTLD_DEFAULT: *mut c_void = (-2_isize) as *mut c_void; + // SAFETY: `name` is a static NUL-terminated symbol name and RTLD_DEFAULT is the documented + // Darwin pseudo-handle used to search the current process and its dependencies. + let symbol = unsafe { dlsym(RTLD_DEFAULT, name.as_ptr().cast()) }; + (!symbol.is_null()).then_some(symbol) +} + +/// Requests UIKit's finite background execution time and returns its shared active-task token. +fn begin_background_task() -> Result, MobileActivityError> { + on_main(begin_background_task_on_main) +} + +/// Starts a UIKit assertion and ends it automatically if the system's finite time expires. +fn begin_background_task_on_main( + main_thread: MainThreadMarker, +) -> Result, MobileActivityError> { + let application = UIApplication::sharedApplication(main_thread); + let task_identifier = Arc::new(AtomicUsize::new(invalid_task_identifier())); + let expiration_identifier = Arc::clone(&task_identifier); + let expiration_handler = RcBlock::new(move || { + let identifier = expiration_identifier.swap(invalid_task_identifier(), Ordering::AcqRel); + if identifier != invalid_task_identifier() { + end_expired_task(identifier); + } + }); + let identifier = + application.beginBackgroundTaskWithExpirationHandler(Some(&expiration_handler)); + if identifier == invalid_task_identifier() { + return Err(native_error( + "UIApplication could not begin a background task", + )); + } + task_identifier.store(identifier, Ordering::Release); + Ok(task_identifier) +} + +/// Ends an active UIKit assertion from its owner or expiration callback. +fn end_background_task(task_identifier: Arc) -> Result<(), MobileActivityError> { + let identifier = task_identifier.swap(invalid_task_identifier(), Ordering::AcqRel); + if identifier == invalid_task_identifier() { + return Ok(()); + } + on_main(move |main_thread| { + UIApplication::sharedApplication(main_thread).endBackgroundTask(identifier); + Ok(()) + }) +} + +/// Ends an expiring UIKit assertion immediately or queues its release on the main thread. +fn end_expired_task(identifier: usize) { + if let Some(main_thread) = MainThreadMarker::new() { + UIApplication::sharedApplication(main_thread).endBackgroundTask(identifier); + } else { + DispatchQueue::main().exec_async(move || { + if let Some(main_thread) = MainThreadMarker::new() { + UIApplication::sharedApplication(main_thread).endBackgroundTask(identifier); + } + }); + } +} + +/// Runs UIKit work synchronously on the main thread and returns its result. +fn on_main( + operation: impl FnOnce(MainThreadMarker) -> Result + Send + 'static, +) -> Result { + if let Some(main_thread) = MainThreadMarker::new() { + return operation(main_thread); + } + + let (sender, receiver) = mpsc::sync_channel(1); + DispatchQueue::main().exec_sync(move || { + let result = MainThreadMarker::new().map_or_else( + || Err(native_error("UIKit work did not run on the main thread")), + operation, + ); + let _ = sender.send(result); + }); + receiver + .recv() + .map_err(|_| native_error("the UIKit main-thread operation was interrupted"))? +} + +/// Returns UIKit's platform-defined invalid background-task identifier. +#[allow(unsafe_code)] +fn invalid_task_identifier() -> usize { + // SAFETY: UIKit exports UIBackgroundTaskInvalid as a process-wide constant of NSUInteger type. + unsafe { UIBackgroundTaskInvalid } +} + +/// Converts a message into a native mobile-activity diagnostic. +fn native_error(message: impl Into) -> MobileActivityError { + MobileActivityError::Native(message.into()) +} + +#[link(name = "System")] +#[allow(unsafe_code)] +unsafe extern "C" { + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; +} diff --git a/crates/argui-platform/tests/mobile.rs b/crates/argui-platform/tests/mobile.rs new file mode 100644 index 00000000..8c4e6a47 --- /dev/null +++ b/crates/argui-platform/tests/mobile.rs @@ -0,0 +1,72 @@ +use argui_core::Insets; +use argui_platform::mobile::{ + MobileActivity, MobileActivityCapability, MobileActivityError, PhysicalInsets, +}; + +/// Converts each physical edge with the supplied Android-style density scale. +#[test] +fn physical_insets_convert_each_edge_to_logical_pixels() { + let actual = PhysicalInsets { + top: 48, + right: 24, + bottom: 96, + left: 12, + } + .to_logical(2.0); + + assert_eq!(actual, Insets::new(24.0, 12.0, 48.0, 6.0)); +} + +/// Uses a neutral scale if a host reports a non-finite or non-positive factor. +#[test] +fn physical_insets_use_unit_scale_when_factor_is_invalid() { + let physical = PhysicalInsets { + top: 48, + right: 24, + bottom: 96, + left: 12, + }; + + assert_eq!( + physical.to_logical(0.0), + Insets::new(48.0, 24.0, 96.0, 12.0) + ); + assert_eq!( + physical.to_logical(f32::NAN), + Insets::new(48.0, 24.0, 96.0, 12.0) + ); +} + +/// Reports the desktop fallback without trying to create native mobile state. +#[test] +fn desktop_reports_foreground_only_mobile_activity_capability() { + assert_eq!( + MobileActivity::capability(), + MobileActivityCapability::ForegroundOnly + ); +} + +/// Rejects native activity creation on a non-mobile target with a useful diagnostic. +#[test] +fn desktop_rejects_native_mobile_activity_creation() { + let error = MobileActivity::begin("Build", "Starting").unwrap_err(); + + assert_eq!(error, MobileActivityError::Unsupported); + assert_eq!( + error.to_string(), + "mobile background activity is unsupported" + ); +} + +/// Formats the platform-specific permission and native error variants for application UI. +#[test] +fn mobile_activity_errors_have_readable_messages() { + assert_eq!( + MobileActivityError::NotificationPermissionRequired.to_string(), + "notification permission is required to start this activity" + ); + assert_eq!( + MobileActivityError::Native("native failure".into()).to_string(), + "native failure" + ); +} diff --git a/crates/argui-render/src/batch.rs b/crates/argui-render/src/batch.rs index 7c1f3830..847cefcc 100644 --- a/crates/argui-render/src/batch.rs +++ b/crates/argui-render/src/batch.rs @@ -54,7 +54,10 @@ pub(crate) fn build_batches( vector += 1; Some((DrawKind::Vector, instances)) } - DisplayCommand::BeginLayer(_) | DisplayCommand::EndLayer => None, + DisplayCommand::BeginLayer(_) + | DisplayCommand::EndLayer + | DisplayCommand::BeginCompositor(_) + | DisplayCommand::EndCompositor => None, }; let Some((kind, instances)) = draw else { continue; diff --git a/crates/argui-render/src/effect/pipeline.rs b/crates/argui-render/src/effect/pipeline.rs index 87c2e459..196a372f 100644 --- a/crates/argui-render/src/effect/pipeline.rs +++ b/crates/argui-render/src/effect/pipeline.rs @@ -57,6 +57,26 @@ impl Default for EffectUniform { } } +impl EffectUniform { + /// Stores an inverse surface transform used by the built-in compositor pass. + /// + /// * `transform` — inverse transform mapping output pixels to retained source pixels. + pub(crate) fn set_inverse_transform(&mut self, transform: argui_core::Affine2D) { + self.matrix[0] = [ + transform.matrix[0], + transform.matrix[2], + transform.translation.x, + 0.0, + ]; + self.matrix[1] = [ + transform.matrix[1], + transform.matrix[3], + transform.translation.y, + 0.0, + ]; + } +} + pub(crate) struct EffectGpu { pipeline: wgpu::RenderPipeline, custom: HashMap<(EffectId, usize), wgpu::RenderPipeline>, diff --git a/crates/argui-render/src/effect_graph.rs b/crates/argui-render/src/effect_graph.rs index 580d10ec..8d53a866 100644 --- a/crates/argui-render/src/effect_graph.rs +++ b/crates/argui-render/src/effect_graph.rs @@ -144,11 +144,25 @@ impl EffectGraph { content_revision, }); } - DisplayCommand::EndLayer => { + DisplayCommand::BeginCompositor(layer) => { + let style = layer.style().scaled(scale_factor); + let viewport = PixelRegion::viewport(viewport[0] as u32, viewport[1] as u32); + let region = match stack.last() { + Some(parent) => parent.region.and_then(|parent| { + PixelRegion::from_rect(style.expanded_bounds(), parent) + }), + None => PixelRegion::from_rect(style.expanded_bounds(), viewport), + }; + stack.push(EffectLayer { + region, + style, + children: Vec::new(), + content_revision, + }); + } + DisplayCommand::EndLayer | DisplayCommand::EndCompositor => { let layer = stack.pop().expect("validated layer stack"); - if layer.style.opacity > 0.0 { - push_node(&mut roots, &mut stack, EffectNode::Layer(layer)); - } + push_node(&mut roots, &mut stack, EffectNode::Layer(layer)); } } } @@ -161,6 +175,7 @@ impl EffectGraph { for node in nodes { match node { EffectNode::Draw(_) => stats.draw_batches += 1, + EffectNode::Layer(layer) if layer.style.opacity <= 0.0 => {} EffectNode::Layer(layer) => { stats.layers += 1; stats.offscreen_layers += usize::from(layer.style.requires_offscreen()); @@ -185,13 +200,24 @@ impl EffectGraph { #[must_use] pub fn needs_offscreen_root(&self) -> bool { - self.stats().offscreen_layers != 0 + /// Returns whether this node slice contains an offscreen layer. + fn visit(nodes: &[EffectNode]) -> bool { + nodes.iter().any(|node| match node { + EffectNode::Draw(_) => false, + EffectNode::Layer(layer) => { + layer.style.requires_offscreen() || visit(&layer.children) + } + }) + } + visit(&self.roots) } pub fn effects(&self) -> Vec<&EffectInstance> { fn visit<'a>(nodes: &'a [EffectNode], effects: &mut Vec<&'a EffectInstance>) { for node in nodes { - if let EffectNode::Layer(layer) = node { + if let EffectNode::Layer(layer) = node + && layer.style.opacity > 0.0 + { for filter in layer .style .filters diff --git a/crates/argui-render/src/shaders/effects/compositor.wgsl b/crates/argui-render/src/shaders/effects/compositor.wgsl index ee1847d2..11442f3c 100644 --- a/crates/argui-render/src/shaders/effects/compositor.wgsl +++ b/crates/argui-render/src/shaders/effects/compositor.wgsl @@ -79,6 +79,14 @@ fn sample_backdrop(pixel: vec2) -> vec4 { return textureSampleLevel(backdrop_texture, linear_sampler, allocated_uv(pixel, params.backdrop, params.backdrop_uv), 0.0); } +fn retained_pixel(pixel: vec2) -> vec2 { + let point = vec3(pixel, 1.0); + return vec2( + dot(point, params.matrix[0].xyz), + dot(point, params.matrix[1].xyz), + ); +} + fn mask_coverage(pixel: vec2) -> f32 { let center = params.bounds.xy + params.bounds.zw * 0.5; let local = pixel - center; @@ -124,10 +132,12 @@ fn sample_blur(pixel: vec2, axis: vec2) -> vec4 { @fragment fn fs_main(input: VertexOut) -> @location(0) vec4 { let pixel = global_pixel(input.uv); - let original = sample_source(pixel); + var source_pixel = pixel; + if params.mode == 0u { source_pixel = retained_pixel(pixel); } + let original = sample_source(source_pixel); var source = original; let backdrop = sample_backdrop(pixel); - if params.mode == 0u { return composite(source, backdrop, pixel); } + if params.mode == 0u { return composite(source, backdrop, source_pixel); } if params.mode == 1u { source = sample_blur(pixel, vec2(1.0, 0.0)); } if params.mode == 2u { source = sample_blur(pixel, vec2(0.0, 1.0)); } if params.mode == 8u { diff --git a/crates/argui-render/src/surface.rs b/crates/argui-render/src/surface.rs index 3f319f9e..610873bb 100644 --- a/crates/argui-render/src/surface.rs +++ b/crates/argui-render/src/surface.rs @@ -48,6 +48,10 @@ enum FrameContent<'a> { display_list: &'a DisplayList, scale_factor: f32, }, + Composite { + display_list: &'a DisplayList, + scale_factor: f32, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -93,6 +97,7 @@ pub struct SurfaceRenderer { target_format: TextureFormat, renderer_config: RendererConfig, batches: Vec, + text_ranges: Vec>, quad: QuadGpu, text: TextGpu, image: ImageGpu, @@ -205,6 +210,7 @@ impl SurfaceRenderer { target_format, renderer_config, batches: Vec::new(), + text_ranges: Vec::new(), quad, text, image, @@ -300,10 +306,30 @@ impl SurfaceRenderer { { self.content_revision = self.content_revision.wrapping_add(1); } - build_batches(display_list, draw.ranges(), &mut self.batches); + self.text_ranges = draw.ranges().to_vec(); + build_batches(display_list, &self.text_ranges, &mut self.batches); + let graph = EffectGraph::build( + display_list, + &self.text_ranges, + viewport, + scale_factor, + self.content_revision, + ) + .map_err(|error| RendererError::InvalidDisplayList(error.to_string()))?; + let additional_effect_passes = self.validate_custom_effects(&graph)?; + graph_stats = graph.stats(); + graph_stats.filter_passes += additional_effect_passes; + effect_graph = Some(graph); + } + FrameContent::Composite { + display_list, + scale_factor, + } => { + self.vector.clear_frame_stats(); + build_batches(display_list, &self.text_ranges, &mut self.batches); let graph = EffectGraph::build( display_list, - draw.ranges(), + &self.text_ranges, viewport, scale_factor, self.content_revision, diff --git a/crates/argui-render/src/surface/api.rs b/crates/argui-render/src/surface/api.rs index 149654f6..6def9abc 100644 --- a/crates/argui-render/src/surface/api.rs +++ b/crates/argui-render/src/surface/api.rs @@ -318,6 +318,59 @@ impl SurfaceRenderer { ) } + /// Presents composition-only changes while reusing prepared primitive and text buffers. + /// + /// This method is valid after at least one successful [`Self::render_ui`] or + /// [`Self::render_ui_notified`] call for the same retained display-list content. + /// + /// # Errors + /// Returns a renderer error if the display list is invalid, a required effect + /// is unavailable, or frame acquisition or rendering fails. + /// + /// * `display_list` — retained commands with updated compositor layers. + /// * `scale_factor` — logical-to-physical scale used by the prepared content. + #[cfg_attr(coverage_nightly, coverage(off))] + pub fn render_composite( + &mut self, + display_list: &DisplayList, + scale_factor: f32, + ) -> Result { + self.render_frame( + FrameContent::Composite { + display_list, + scale_factor, + }, + || {}, + ) + } + + /// Presents composition-only changes and invokes `notify` after submission. + /// + /// This method is valid after at least one successful [`Self::render_ui`] or + /// [`Self::render_ui_notified`] call for the same retained display-list content. + /// + /// # Errors + /// Returns a renderer error if the display list is invalid, a required effect + /// is unavailable, or frame acquisition or rendering fails. + /// + /// * `display_list` — retained commands with updated compositor layers. + /// * `scale_factor` — logical-to-physical scale used by the prepared content. + /// * `notify` — callback invoked after the frame is submitted. + pub fn render_composite_notified( + &mut self, + display_list: &DisplayList, + scale_factor: f32, + notify: impl FnOnce(), + ) -> Result { + self.render_frame( + FrameContent::Composite { + display_list, + scale_factor, + }, + notify, + ) + } + /// Returns current statistics for the offscreen texture pool. #[must_use] pub fn texture_pool_stats(&self) -> TexturePoolStats { diff --git a/crates/argui-render/src/surface/composite.rs b/crates/argui-render/src/surface/composite.rs index 8d2125c7..2d8d910a 100644 --- a/crates/argui-render/src/surface/composite.rs +++ b/crates/argui-render/src/surface/composite.rs @@ -18,10 +18,10 @@ impl SurfaceRenderer { foreground: TextureTarget, style: &LayerStyle, viewport: [f32; 2], - region: PixelRegion, + output_region: PixelRegion, profiler: Option<&GpuFrameCapture>, ) { - let snapshot = self.snapshot(encoder, target, region); + let snapshot = self.snapshot(encoder, target, output_region); let filtered = self.apply_filters( encoder, snapshot, @@ -35,16 +35,16 @@ impl SurfaceRenderer { let mut backdrop = if style.backdrop_filters.is_empty() { snapshot } else { - let merged = self.acquire_target(region, region.size); + let merged = self.acquire_target(output_region, output_region.size); self.clear_target(encoder, merged, wgpu::Color::TRANSPARENT); - let mut params = uniform(viewport, region, filtered, snapshot, style.bounds); + let mut params = uniform(viewport, output_region, filtered, snapshot, style.bounds); params.mode = 12; params.data[0] = style.opacity.clamp(0.0, 1.0); params.radii = layer_radii(style.mask); self.draw_effect( encoder, merged, - region, + output_region, EffectSources { source: filtered, backdrop: snapshot, @@ -68,9 +68,9 @@ impl SurfaceRenderer { profiler, style.profile, ); - let shadowed = self.acquire_target(region, region.size); + let shadowed = self.acquire_target(output_region, output_region.size); self.clear_target(encoder, shadowed, wgpu::Color::TRANSPARENT); - let mut params = uniform(viewport, region, blurred, backdrop, style.bounds); + let mut params = uniform(viewport, output_region, blurred, backdrop, style.bounds); params.mode = if shadow.inset { 11 } else { 10 }; params.color = shadow.color.to_linear_rgba(); params.color[3] *= style.opacity.clamp(0.0, 1.0); @@ -79,7 +79,7 @@ impl SurfaceRenderer { self.draw_effect( encoder, shadowed, - region, + output_region, EffectSources { source: blurred, backdrop, @@ -96,18 +96,19 @@ impl SurfaceRenderer { let expansion = style.foreground_expansion(); let mut params = uniform( viewport, - region, + output_region, foreground, backdrop, style.foreground_bounds(), ); params.blend = blend_mode(style.blend_mode); params.data[0] = style.opacity; + params.set_inverse_transform(style.transform.inverse().unwrap_or_default()); params.radii = expanded_radii(style.mask, expansion); self.draw_effect( encoder, target, - region, + output_region, EffectSources { source: foreground, backdrop, diff --git a/crates/argui-render/src/surface/effects.rs b/crates/argui-render/src/surface/effects.rs index b61b8883..7c633532 100644 --- a/crates/argui-render/src/surface/effects.rs +++ b/crates/argui-render/src/surface/effects.rs @@ -165,7 +165,11 @@ impl SurfaceRenderer { } self.draw_offscreen(encoder, target, &nodes[start..index], profiler, owner); } - EffectNode::Layer(layer) if layer.style.opacity <= 0.0 => {} + EffectNode::Layer(layer) if layer.style.opacity <= 0.0 => { + if let Some(profile) = layer.style.profile { + cache_stats.used.insert(profile); + } + } EffectNode::Layer(layer) if !layer.style.requires_offscreen() => { self.render_effect_nodes( encoder, @@ -185,7 +189,7 @@ impl SurfaceRenderer { cache_stats.used.insert(profile); self.layer_cache .get(&profile) - .filter(|cached| cached.layer == *layer) + .filter(|cached| same_layer_content(&cached.layer, layer)) .map(|cached| cached.target) }); let foreground = if let Some(cached) = cached { @@ -226,13 +230,18 @@ impl SurfaceRenderer { } foreground }; + let Some(output_region) = + PixelRegion::from_rect(layer.style.transformed_bounds(), target.region) + else { + continue; + }; self.composite_layer( encoder, target, foreground, &layer.style, viewport, - region, + output_region, profiler, ); } @@ -531,6 +540,19 @@ impl SurfaceRenderer { } } +/// Returns whether a cached foreground remains valid across composition-only changes. +fn same_layer_content( + cached: &crate::effect_graph::EffectLayer, + current: &crate::effect_graph::EffectLayer, +) -> bool { + cached.region == current.region + && cached.content_revision == current.content_revision + && cached.children == current.children + && cached.style.bounds == current.style.bounds + && cached.style.filters == current.style.filters + && cached.style.mask == current.style.mask +} + fn clear_view(encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView, color: wgpu::Color) { let attachment = Some(RenderPassColorAttachment { view, diff --git a/crates/argui-render/tests/effect_graph.rs b/crates/argui-render/tests/effect_graph.rs index 894602bf..b00803ea 100644 --- a/crates/argui-render/tests/effect_graph.rs +++ b/crates/argui-render/tests/effect_graph.rs @@ -1,8 +1,9 @@ use argui_core::{Affine2D, Color, Point, Rect, Size}; use argui_paint::{ - Border, ClipChain, CornerRadii, DisplayList, EffectId, EffectInstance, Fill, Filter, - GpuCanvasId, GpuCanvasPrimitive, ImageFit, ImageId, ImagePrimitive, ImageSampling, LayerMask, - LayerStyle, ProfileDomain, Quad, Refraction, RenderObjectId, Shadow, VectorId, VectorPrimitive, + Border, ClipChain, CompositorId, CompositorLayer, CornerRadii, DisplayList, EffectId, + EffectInstance, Fill, Filter, GpuCanvasId, GpuCanvasPrimitive, ImageFit, ImageId, + ImagePrimitive, ImageSampling, LayerMask, LayerStyle, ProfileDomain, Quad, Refraction, + RenderObjectId, Shadow, VectorId, VectorPrimitive, }; use argui_render::analyze_display_list; @@ -158,3 +159,22 @@ fn canvas_draws_keep_display_order_and_participate_in_effect_layers() { assert_eq!(analysis.stats.offscreen_layers, 1); assert_eq!(analysis.stats.filter_passes, 1); } + +#[test] +fn identity_compositor_layers_are_pre_promoted_for_future_animation_frames() { + let mut list = DisplayList::new(); + list.begin_compositor(CompositorLayer::new( + CompositorId::new(9), + bounds(), + Affine2D::IDENTITY, + Affine2D::IDENTITY, + 1.0, + )); + list.push_quad(quad()); + list.end_compositor(); + + let analysis = analyze_display_list(&list, &[], [320.0, 240.0], 1.0, 4).unwrap(); + assert_eq!(analysis.stats.layers, 1); + assert_eq!(analysis.stats.offscreen_layers, 1); + assert_eq!(analysis.stats.draw_batches, 1); +} diff --git a/crates/argui-runtime/Cargo.toml b/crates/argui-runtime/Cargo.toml index 9a95e8e7..7f852f69 100644 --- a/crates/argui-runtime/Cargo.toml +++ b/crates/argui-runtime/Cargo.toml @@ -45,7 +45,7 @@ accesskit.workspace = true accesskit_winit.workspace = true dioxus-devtools = { workspace = true, optional = true } pollster.workspace = true -tokio = { version = "=1.53.1", features = ["rt-multi-thread", "time", "net"], optional = true } +tokio = { version = "=1.53.1", features = ["rt-multi-thread", "time", "net", "test-util"], optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures.workspace = true diff --git a/crates/argui-runtime/src/animation.rs b/crates/argui-runtime/src/animation.rs index e4c0dff6..c8aeb29e 100644 --- a/crates/argui-runtime/src/animation.rs +++ b/crates/argui-runtime/src/animation.rs @@ -7,6 +7,7 @@ pub(super) struct RuntimeAnimations { clock: MonotonicClock, scheduler: Scheduler, model_animation: Option, + wake_at: Option { + /// Returns an operation handle for a unique application `key`. + #[must_use] + pub fn get_by_key(&mut self, key: impl Into) -> TestNode<'_, A> { + TestNode::new(self, Selector::key(key)) + } + + /// Returns an operation handle for a unique accessible `role` and exact `name`. + #[must_use] + pub fn get_by_role(&mut self, role: Role, name: impl Into) -> TestNode<'_, A> { + TestNode::new(self, Selector::role(role, name)) + } + + /// Returns an operation handle for unique visible `text`. + #[must_use] + pub fn get_by_text(&mut self, text: impl Into) -> TestNode<'_, A> { + TestNode::new(self, Selector::text(text)) + } + + /// Returns an operation handle for a unique accessible `label`. + #[must_use] + pub fn get_by_label(&mut self, label: impl Into) -> TestNode<'_, A> { + TestNode::new(self, Selector::label(label)) + } + + /// Returns an operation handle for a unique accessible semantic `state`. + #[must_use] + pub fn get_by_state(&mut self, state: SemanticMatcher) -> TestNode<'_, A> { + TestNode::new(self, Selector::state(state)) + } + + /// Returns an operation handle for the currently focused element. + #[must_use] + pub fn focused(&mut self) -> TestNode<'_, A> { + TestNode::new(self, Selector::Focused) + } + + /// Clicks the unique element with application `key` using real hit testing. + /// + /// # Errors + /// + /// Returns a selector, bounds, layout, or stabilization error. + pub fn click(&mut self, key: &str) -> Result<(), TestError> { + self.click_selector(&Selector::key(key)) + } + + pub(crate) fn click_selector(&mut self, selector: &Selector) -> Result<(), TestError> { + let point = self.center(selector)?; + let regions = self.output().hit_regions.clone(); + let moved = self.ui_mut().pointer_moved(point, ®ions); + self.dispatch_interaction(moved)?; + for phase in [PointerPhase::Pressed, PointerPhase::Released] { + let mut event = PointerEvent::mouse(phase, point); + event.timestamp = std::time::Duration::from_nanos(self.now.as_nanos()); + let update = self.ui_mut().pointer_event(event, ®ions); + let deliveries = update.events.clone(); + self.dispatch_interaction(update)?; + if phase == PointerPhase::Pressed + && deliveries.iter().all(|event| !event.default_prevented()) + { + let focus = self + .ui_mut() + .focus_pointer_default(PointerId::MOUSE, ®ions); + self.dispatch_interaction(focus)?; + } + } + self.settle() + } + + /// Taps `selector` with a primary touch contact through gesture and hit testing. + /// + /// # Errors + /// + /// Returns a selector, bounds, layout, or stabilization error. + pub fn tap(&mut self, selector: impl Into) -> Result<(), TestError> { + let selector = selector.into(); + let point = self.center(&selector)?; + let regions = self.output().hit_regions.clone(); + for phase in [PointerPhase::Pressed, PointerPhase::Released] { + let event = PointerEvent { + id: PointerId::new(1), + kind: PointerKind::Touch, + phase, + position: point, + button: None, + buttons: 0, + pressure: Some(if phase == PointerPhase::Pressed { + 1.0 + } else { + 0.0 + }), + primary: true, + modifiers: Modifiers::default(), + timestamp: std::time::Duration::from_nanos(self.now.as_nanos()), + }; + let update = self.ui_mut().pointer_event(event, ®ions); + let deliveries = update.events.clone(); + self.dispatch_interaction(update)?; + if phase == PointerPhase::Pressed + && deliveries.iter().all(|event| !event.default_prevented()) + { + let focus = self + .ui_mut() + .focus_pointer_default(PointerId::new(1), ®ions); + self.dispatch_interaction(focus)?; + } + } + self.settle() + } + + /// Moves the mouse pointer to `point` and delivers enter, leave, and move events. + /// + /// # Errors + /// + /// Returns a stabilization error. Points outside the viewport are rejected as + /// [`TestError::MissingBounds`]. + pub fn pointer_move(&mut self, point: Point) -> Result<(), TestError> { + if !self.point_in_viewport(point) { + return Err(TestError::MissingBounds { + selector: Selector::Focused, + tree: self.compact_dump(), + }); + } + let regions = self.output().hit_regions.clone(); + let update = self.ui_mut().pointer_moved(point, ®ions); + self.dispatch_interaction(update)?; + self.settle() + } + + /// Drags one primary pointer from `start` to `end` through `steps` move samples. + /// + /// # Errors + /// + /// Returns a stabilization error or rejects coordinates outside the viewport. + pub fn drag(&mut self, start: Point, end: Point, steps: usize) -> Result<(), TestError> { + if !self.point_in_viewport(start) || !self.point_in_viewport(end) { + return Err(TestError::MissingBounds { + selector: Selector::Focused, + tree: self.compact_dump(), + }); + } + let regions = self.output().hit_regions.clone(); + let pressed = PointerEvent::mouse(PointerPhase::Pressed, start); + let update = self.ui_mut().pointer_event(pressed, ®ions); + self.dispatch_interaction(update)?; + for index in 1..=steps.max(1) { + let ratio = index as f32 / steps.max(1) as f32; + let point = Point::new( + start.x + (end.x - start.x) * ratio, + start.y + (end.y - start.y) * ratio, + ); + let update = self + .ui_mut() + .pointer_event(PointerEvent::mouse(PointerPhase::Moved, point), ®ions); + self.dispatch_interaction(update)?; + } + let update = self + .ui_mut() + .pointer_event(PointerEvent::mouse(PointerPhase::Released, end), ®ions); + self.dispatch_interaction(update)?; + self.settle() + } + + /// Focuses the unique element selected by `selector`. + /// + /// # Errors + /// + /// Returns a selector or stabilization error. + pub fn focus(&mut self, selector: impl Into) -> Result<(), TestError> { + let selector = selector.into(); + let node = self.resolve_unique(&selector)?.node; + let regions = self.output().hit_regions.clone(); + let update = self + .ui_mut() + .sync_focus(®ions, Some(FocusRequest::Focus(node.into()))); + self.dispatch_interaction(update)?; + self.settle() + } + + /// Clears focus and delivers the resulting blur event. + /// + /// # Errors + /// + /// Returns a stabilization error. + pub fn blur(&mut self) -> Result<(), TestError> { + let regions = self.output().hit_regions.clone(); + let update = self + .ui_mut() + .sync_focus(®ions, Some(FocusRequest::Clear)); + self.dispatch_interaction(update)?; + self.settle() + } + + /// Sends a complete key press and release to the focused element. + /// + /// `key` is normalized platform-independent input; `modifiers` remain active + /// for both transitions. + /// + /// # Errors + /// + /// Returns a stabilization error. + pub fn key(&mut self, key: Key, modifiers: Modifiers) -> Result<(), TestError> { + self.key_transition(key.clone(), KeyState::Pressed, modifiers, None)?; + self.key_transition(key, KeyState::Released, modifiers, None) + } + + /// Sends a key shortcut using the supplied `key` and `modifiers`. + /// + /// # Errors + /// + /// Returns a stabilization error. + pub fn shortcut(&mut self, key: Key, modifiers: Modifiers) -> Result<(), TestError> { + self.key(key, modifiers) + } + + /// Types Unicode text into the unique text input selected by `selector`. + /// + /// # Errors + /// + /// Returns a selector error, [`TestError::NotTextInput`], or a stabilization error. + pub fn type_text( + &mut self, + selector: impl Into, + text: &str, + ) -> Result<(), TestError> { + let selector = selector.into(); + let resolved = self.resolve_unique(&selector)?; + self.require_text_input(&selector, &resolved)?; + self.focus(selector.clone())?; + for character in text.chars() { + let text = character.to_string(); + self.key_transition( + Key::Character(text.clone()), + KeyState::Pressed, + Modifiers::default(), + Some(text), + )?; + self.key_transition( + Key::Character(character.to_string()), + KeyState::Released, + Modifiers::default(), + None, + )?; + } + Ok(()) + } + + /// Replaces the entire controlled text value selected by `selector`. + /// + /// # Errors + /// + /// Returns a selector, input-kind, or stabilization error. + pub fn replace_text( + &mut self, + selector: impl Into, + value: &str, + ) -> Result<(), TestError> { + let selector = selector.into(); + let resolved = self.resolve_unique(&selector)?; + self.require_text_input(&selector, &resolved)?; + let update = self.ui_mut().replace_text_input(resolved.node, value); + self.dispatch_interaction(update)?; + self.settle() + } + + /// Pastes `text` into the unique selected text input and records it as clipboard data. + /// + /// # Errors + /// + /// Returns a selector, input-kind, or stabilization error. + pub fn paste(&mut self, selector: impl Into, text: &str) -> Result<(), TestError> { + let selector = selector.into(); + let resolved = self.resolve_unique(&selector)?; + self.require_text_input(&selector, &resolved)?; + self.clipboard = Some(text.to_owned()); + self.focus(selector)?; + let update = self.ui_mut().paste_text(Some(resolved.node), text); + self.dispatch_interaction(update)?; + self.settle() + } + + /// Submits the unique selected text input with Enter. + /// + /// # Errors + /// + /// Returns a selector, input-kind, or stabilization error. + pub fn submit(&mut self, selector: impl Into) -> Result<(), TestError> { + let selector = selector.into(); + let resolved = self.resolve_unique(&selector)?; + self.require_text_input(&selector, &resolved)?; + self.focus(selector)?; + self.key(Key::Enter, Modifiers::default()) + } + + /// Sends wheel input at `point` through real scroll routing. + /// + /// # Errors + /// + /// Returns a stabilization error. + pub fn wheel(&mut self, point: Point, delta: ScrollDelta) -> Result<(), TestError> { + let regions = self.output().scroll_regions.clone(); + let wheel = self.ui_mut().wheel_event(point, delta, ®ions); + let wheel_event = wheel.events.first().cloned(); + self.dispatch_interaction(wheel)?; + if wheel_event.is_some_and(|event| event.default_prevented()) { + return self.settle(); + } + let scroll = self.ui_mut().scroll(point, delta, ®ions); + if scroll.scroll_changed { + self.layout = None; + self.pending = true; + } + self.dispatch_interaction(scroll)?; + self.settle() + } + + /// Invokes an accessibility `action` on the unique selected node. + /// + /// `value` supplies data for actions such as `SetValue`. + /// + /// # Errors + /// + /// Returns a selector, unsupported-action, or stabilization error. + pub fn accessibility_action( + &mut self, + selector: impl Into, + action: SemanticAction, + value: Option, + ) -> Result<(), TestError> { + let selector = selector.into(); + let resolved = self.resolve_unique(&selector)?; + if action == SemanticAction::Focus { + return self.focus(selector); + } + if action == SemanticAction::Blur { + return self.blur(); + } + if action == SemanticAction::SetValue + && let Some(SemanticValue::Text(value)) = &value + && matches!( + resolved.role, + Role::TextInput | Role::TextArea | Role::SearchInput | Role::ComboBox + ) + { + return self.replace_text(selector, value); + } + let kind = if action == SemanticAction::Click { + UiEventKind::Click(argui_ui::ClickEvent::accessibility()) + } else { + UiEventKind::SemanticAction { action, value } + }; + let events = self.ui_mut().event_deliveries(resolved.node, kind); + if events.is_empty() && !resolved.state.disabled { + return Err(TestError::UnsupportedAction { + selector, + action, + tree: self.compact_dump(), + }); + } + self.dispatch_interaction(InteractionUpdate { + events, + ..InteractionUpdate::default() + })?; + self.settle() + } + + /// Changes the logical viewport and settles responsive and virtualized content. + /// + /// # Errors + /// + /// Returns a layout or stabilization error. + pub fn resize(&mut self, viewport: Size) -> Result<(), TestError> { + self.viewport = Size::new(viewport.width.max(1.0), viewport.height.max(1.0)); + self.layout = None; + self.pending = true; + self.settle() + } + + /// Replaces the platform environment and settles environment-dependent views. + /// + /// # Errors + /// + /// Returns a layout or stabilization error. + pub fn set_environment(&mut self, environment: WindowEnvironment) -> Result<(), TestError> { + self.environment = environment; + self.pending = true; + self.settle() + } + + /// Delivers one application lifecycle event for the main test window. + /// + /// # Errors + /// + /// Returns a stabilization error. + pub fn lifecycle(&mut self, event: PlatformEvent) -> Result<(), TestError> { + let update = self.model.update(&AppEvent::Window { + window: self.window.clone(), + event, + }); + self.record_app_update(update); + self.settle() + } + + /// Advances the injected animation clock without sleeping. + /// + /// # Errors + /// + /// Returns a layout or stabilization error. + pub fn advance(&mut self, duration: std::time::Duration) -> Result<(), TestError> { + #[cfg(all(feature = "tasks", not(target_arch = "wasm32")))] + { + self.tasks + .advance_time(duration) + .map_err(|error| TestError::Task { + message: error.to_string(), + })?; + let update = self.model.tasks_ready(&self.window); + self.record_app_update(update); + } + self.frame(duration) + } + + /// Drains completed tasks and requested animation frames until no work remains. + /// + /// # Errors + /// + /// Returns [`TestError::DidNotSettle`] if work remains after the settle limit. + pub fn run_until_idle(&mut self) -> Result<(), TestError> { + for _ in 0..self.settle_limit() { + #[cfg(all(feature = "tasks", not(target_arch = "wasm32")))] + self.tasks + .advance_time(std::time::Duration::ZERO) + .map_err(|error| TestError::Task { + message: error.to_string(), + })?; + let update = self.model.tasks_ready(&self.window); + self.record_app_update(update); + if self.model.wants_animation_frame(&self.window) + || self.ui().wants_animation_frame() + || self.ui().wants_scroll_frame() + { + self.frame(std::time::Duration::from_millis(16))?; + continue; + } + self.settle()?; + if !self.pending { + return Ok(()); + } + } + Err(TestError::DidNotSettle { + limit: self.settle_limit(), + tree: self.compact_dump(), + }) + } + + /// Returns the number of asynchronous operations awaiting completion or cancellation. + #[must_use] + pub fn pending_tasks(&self) -> usize { + #[cfg(feature = "tasks")] + { + self.tasks.pending() + } + #[cfg(not(feature = "tasks"))] + { + 0 + } + } + + /// Removes and returns application commands emitted since the last drain. + pub fn take_commands(&mut self) -> Vec { + std::mem::take(&mut self.commands) + } + + /// Returns application commands emitted since the last drain. + #[must_use] + pub fn commands(&self) -> &[argui_runtime::AppCommand] { + &self.commands + } + + /// Returns scroll requests emitted by application callbacks. + #[must_use] + pub fn scroll_requests(&self) -> &[argui_ui::ScrollRequest] { + &self.scroll_requests + } + + fn center(&self, selector: &Selector) -> Result { + let resolved = self.resolve_unique(selector)?; + let bounds = resolved + .bounds + .filter(|bounds| bounds.size.width > 0.0 && bounds.size.height > 0.0) + .ok_or_else(|| TestError::MissingBounds { + selector: selector.clone(), + tree: self.compact_dump(), + })?; + Ok(Point::new( + bounds.origin.x + bounds.size.width * 0.5, + bounds.origin.y + bounds.size.height * 0.5, + )) + } + + fn key_transition( + &mut self, + key: Key, + state: KeyState, + modifiers: Modifiers, + text: Option, + ) -> Result<(), TestError> { + let regions = self.output().hit_regions.clone(); + let update = self.ui_mut().key_input( + &KeyInput { + key, + state, + modifiers, + repeat: false, + text, + }, + ®ions, + ); + self.dispatch_interaction(update)?; + self.settle() + } + + fn require_text_input( + &self, + selector: &Selector, + resolved: &crate::inspect::ResolvedNode, + ) -> Result<(), TestError> { + if matches!( + resolved.role, + Role::TextInput | Role::TextArea | Role::SearchInput | Role::ComboBox + ) && self.ui().text_input_value(resolved.node).is_some() + { + return Ok(()); + } + Err(TestError::NotTextInput { + selector: selector.clone(), + role: resolved.role, + tree: self.compact_dump(), + }) + } + + const fn settle_limit(&self) -> usize { + self.settle_limit + } +} diff --git a/crates/argui-testing/src/app.rs b/crates/argui-testing/src/app.rs new file mode 100644 index 00000000..a23416e6 --- /dev/null +++ b/crates/argui-testing/src/app.rs @@ -0,0 +1,482 @@ +use argui_animation::{Duration as AnimationDuration, Frame, Time}; +use argui_core::{Point, Rect, Size}; +use argui_layout::{LayoutEngine, LayoutOutput}; +use argui_platform::WindowKey; +use argui_runtime::{ + AppCommand, AppEvent, AppModel, AppUpdate, Entity, LayoutBounds, LayoutSnapshot, Render, + SingleWindowModel, ViewUpdate, WindowEnvironment, +}; +use argui_text::TextEngine; +use argui_ui::{ + ClipboardRequest, InteractionUpdate, ScrollAlignment, ScrollRequest, ScrollTarget, TreeUpdate, + UiTree, +}; + +use crate::TestError; + +const DEFAULT_SETTLE_LIMIT: usize = 32; + +/// Headless retained application with deterministic layout and input delivery. +pub struct TestApp { + pub(crate) entity: Entity, + pub(crate) model: SingleWindowModel, + pub(crate) window: WindowKey, + pub(crate) environment: WindowEnvironment, + pub(crate) viewport: Size, + pub(crate) ui: Option, + pub(crate) layout_engine: LayoutEngine, + pub(crate) text_engine: TextEngine, + pub(crate) layout: Option, + pub(crate) clipboard: Option, + pub(crate) commands: Vec, + pub(crate) scroll_requests: Vec, + pub(crate) now: Time, + pub(crate) pending: bool, + pub(crate) settle_limit: usize, + #[cfg(feature = "tasks")] + pub(crate) tasks: argui_runtime::tasks::TaskRuntime, +} + +impl TestApp { + /// Creates and settles a headless application at a 1024 by 768 logical viewport. + /// + /// # Panics + /// + /// Panics when initial layout or stabilization fails. Use [`TestApp::try_new`] + /// to handle the failure explicitly. + #[must_use] + pub fn new(app: A) -> Self { + Self::try_new(app).unwrap_or_else(|error| panic!("{error}")) + } + + /// Creates and settles a headless application, returning initialization failures. + /// + /// # Errors + /// + /// Returns a layout or non-settling diagnostic. + pub fn try_new(app: A) -> Result { + let entity = Entity::new(app); + Self::from_entity_in_window(entity, WindowKey::main()) + } + + /// Creates an independent headless window presentation for an existing entity. + /// + /// `entity` supplies shared application state and `window` identifies this + /// presentation. Each call owns independent focus, layout, handler slots, and + /// presentation-scoped resources. + /// + /// # Errors + /// Returns a layout, closed-scope, or non-settling diagnostic. + pub fn from_entity_in_window(entity: Entity, window: WindowKey) -> Result { + let model = SingleWindowModel::from_entity(entity.clone()) + .map_err(|_| TestError::ClosedPresentation)? + .window_key(window.clone()); + #[cfg(feature = "tasks")] + let tasks = { + #[cfg(not(target_arch = "wasm32"))] + let tasks = argui_runtime::tasks::TaskRuntime::new_paused(|| {}); + #[cfg(target_arch = "wasm32")] + let tasks = argui_runtime::tasks::TaskRuntime::new(|| {}); + entity.set_task_runtime(tasks.clone()); + tasks + }; + let mut test = Self { + entity, + model, + window, + environment: WindowEnvironment::default(), + viewport: Size::new(1024.0, 768.0), + ui: None, + layout_engine: LayoutEngine::new(), + text_engine: TextEngine::from_embedded_fonts( + [ + epaint_default_fonts::UBUNTU_LIGHT, + epaint_default_fonts::HACK_REGULAR, + epaint_default_fonts::NOTO_EMOJI_REGULAR, + ], + "Ubuntu", + "Ubuntu", + "Hack", + ), + layout: None, + clipboard: None, + commands: Vec::new(), + scroll_requests: Vec::new(), + now: Time::ZERO, + pending: true, + settle_limit: DEFAULT_SETTLE_LIMIT, + #[cfg(feature = "tasks")] + tasks, + }; + test.settle()?; + Ok(test) + } + + /// Returns the retained application entity for explicit state inspection. + #[must_use] + pub const fn entity(&self) -> &Entity { + &self.entity + } + + /// Returns the application-window identity used for model events. + #[must_use] + pub const fn window_key(&self) -> &WindowKey { + &self.window + } + + /// Returns the logical platform and theme environment used by the current window. + #[must_use] + pub const fn environment(&self) -> &WindowEnvironment { + &self.environment + } + + /// Changes the maximum render/effect iterations allowed during stabilization. + pub fn set_settle_limit(&mut self, limit: usize) { + self.settle_limit = limit.max(1); + } + + /// Renders, reconciles, lays out, delivers layout effects, and repeats to quiescence. + /// + /// # Errors + /// + /// Returns a layout error or a bounded non-settling diagnostic. + pub fn settle(&mut self) -> Result<(), TestError> { + for _ in 0..self.settle_limit { + #[cfg(all(feature = "tasks", not(target_arch = "wasm32")))] + { + self.tasks + .advance_time(std::time::Duration::ZERO) + .map_err(|error| TestError::Task { + message: error.to_string(), + })?; + let update = self.model.tasks_ready(&self.window); + self.record_app_update(update); + } + self.pending = false; + let root = self + .model + .view(&self.window, self.environment.clone()) + .expect("single-window test model must provide its main view"); + let tree_update = match &mut self.ui { + Some(ui) => ui.update(root), + None => { + self.ui = Some(UiTree::new(root)); + TreeUpdate::Layout + } + }; + let needs_layout = self.layout.is_none() + || tree_update == TreeUpdate::Layout + || self.ui().layout_dirty(); + if needs_layout { + self.compute_layout()?; + let update = self + .model + .layout_changed(&self.window, &self.layout_snapshot()); + self.record_app_update(update); + } + let effects = self.apply_model_effects()?; + if !self.pending && !effects { + return Ok(()); + } + } + Err(TestError::DidNotSettle { + limit: self.settle_limit, + tree: self.compact_dump(), + }) + } + + pub(crate) fn ui(&self) -> &UiTree { + self.ui.as_ref().expect("test application is settled") + } + + pub(crate) fn ui_mut(&mut self) -> &mut UiTree { + self.ui.as_mut().expect("test application is settled") + } + + pub(crate) fn output(&self) -> &LayoutOutput { + self.layout.as_ref().expect("test application is settled") + } + + fn compute_layout(&mut self) -> Result<(), TestError> { + let output = self + .layout_engine + .compute( + self.ui.as_mut().expect("UI created before layout"), + &mut self.text_engine, + self.viewport, + ) + .map_err(|error| TestError::Layout { + message: error.to_string(), + })?; + self.ui_mut().mark_layout_clean(); + self.layout = Some(output); + Ok(()) + } + + fn layout_snapshot(&self) -> LayoutSnapshot { + LayoutSnapshot { + viewport: self.output().viewport, + nodes: self + .output() + .nodes + .iter() + .map(|layout| LayoutBounds { + node: layout.node, + key: self.ui().key(layout.node).map(str::to_owned), + bounds: layout.bounds, + }) + .collect(), + } + } + + pub(crate) fn dispatch_interaction( + &mut self, + update: InteractionUpdate, + ) -> Result<(), TestError> { + if let Some(request) = update.clipboard { + self.apply_clipboard_request(request)?; + } + self.pending |= update.layout_changed || update.text_input_changed; + for event in update.events { + if !event.should_dispatch() { + continue; + } + let update = self.model.update(&AppEvent::Ui { + window: self.window.clone(), + event, + }); + self.record_app_update(update); + } + self.apply_model_effects()?; + Ok(()) + } + + pub(crate) fn record_app_update(&mut self, update: AppUpdate) { + self.pending |= update + .windows + .iter() + .any(|window| window.window == self.window && window.update != ViewUpdate::None); + self.commands.extend(update.commands); + } + + fn apply_model_effects(&mut self) -> Result { + let mut changed = false; + loop { + let commands = self.model.take_ui_commands(&self.window); + let focus = self.model.take_focus_request(&self.window); + let selection = self.model.take_text_selection_request(&self.window); + let clipboard = self.model.take_clipboard_request(&self.window); + let scroll = self.model.take_scroll_request(&self.window); + let theme = self.model.take_theme_request(&self.window); + if commands.is_empty() + && focus.is_none() + && selection.is_none() + && clipboard.is_none() + && scroll.is_none() + && theme.is_none() + { + break; + } + changed = true; + let mut interaction = InteractionUpdate::default(); + let hit_regions = self.output().hit_regions.clone(); + if let Some(request) = focus { + interaction.merge(self.ui_mut().sync_focus(&hit_regions, Some(request))); + } + if let Some(request) = selection { + interaction.merge(self.ui_mut().select_text(request)); + } + for command in commands { + interaction.merge(self.ui_mut().apply_command(command)); + } + if let Some(request) = clipboard { + self.apply_clipboard_request(request)?; + } + if let Some(request) = scroll { + self.pending |= self.apply_scroll_request(&request); + self.scroll_requests.push(request); + } + if let Some(request) = theme { + if let Some(color_scheme) = request.color_scheme { + self.environment.color_scheme = color_scheme; + } + if let Some(primary) = request.primary { + self.environment.primary = primary; + } + self.pending = true; + } + if !interaction.events.is_empty() + || interaction.layout_changed + || interaction.text_input_changed + { + self.dispatch_interaction(interaction)?; + } + } + Ok(changed) + } + + fn apply_clipboard_request(&mut self, request: ClipboardRequest) -> Result<(), TestError> { + match request { + ClipboardRequest::Write(value) => self.clipboard = Some(value), + ClipboardRequest::Read { target } => { + if let Some(value) = self.clipboard.clone() { + let update = self.ui_mut().paste_text(target, &value); + self.dispatch_interaction(update)?; + } + } + } + Ok(()) + } + + fn apply_scroll_request(&mut self, request: &ScrollRequest) -> bool { + let tracks = match &request.target { + ScrollTarget::Offset { container, offset } => self + .ui() + .resolve_node(container) + .and_then(|node| { + self.output() + .scroll_regions + .iter() + .find(|region| region.node == node) + .map(|region| (node, clamp_point(*offset, region.max_offset))) + }) + .into_iter() + .collect(), + ScrollTarget::Rect { container, rect } => self + .ui() + .resolve_node(container) + .and_then(|node| { + self.output() + .scroll_regions + .iter() + .find(|region| region.node == node) + .map(|region| { + ( + node, + reveal_offset( + self.ui().scroll_offset(node), + region, + *rect, + request, + ), + ) + }) + }) + .into_iter() + .collect(), + ScrollTarget::Element(target) => { + let Some(node) = self.ui().resolve_node(target) else { + return false; + }; + let Some(mut rect) = self + .output() + .nodes + .iter() + .find(|candidate| candidate.node == node) + .map(|candidate| candidate.bounds) + else { + return false; + }; + let mut tracks = Vec::new(); + let mut cursor = Some(node); + while let Some(current) = cursor { + if let Some(region) = self + .output() + .scroll_regions + .iter() + .find(|region| region.node == current) + { + let from = self.ui().scroll_offset(current); + let to = reveal_offset(from, region, rect, request); + rect.origin.x -= to.x - from.x; + rect.origin.y -= to.y - from.y; + tracks.push((current, to)); + } + cursor = self.ui().parent_of(current); + } + tracks + } + }; + let mut changed = false; + for (node, offset) in tracks { + changed |= self.ui_mut().set_scroll_offset(node, offset); + } + changed + } + + pub(crate) fn frame(&mut self, elapsed: std::time::Duration) -> Result<(), TestError> { + let elapsed = AnimationDuration::from(elapsed); + self.now = self.now + elapsed; + let update = self.model.animation_frame( + &self.window, + Frame { + now: self.now, + elapsed, + }, + ); + self.record_app_update(update); + let now = self.now; + self.pending |= self.ui_mut().advance_animations(now) != TreeUpdate::None; + self.settle() + } + + pub(crate) fn point_in_viewport(&self, point: Point) -> bool { + Rect::new(Point::default(), self.viewport).contains(point) + } +} + +fn reveal_offset( + current: Point, + region: &argui_ui::ScrollRegion, + target: Rect, + request: &ScrollRequest, +) -> Point { + clamp_point( + Point::new( + align_axis( + current.x, + region.bounds.origin.x, + region.bounds.origin.x + region.bounds.size.width, + target.origin.x - request.margin.left, + target.origin.x + target.size.width + request.margin.right, + request.x, + ), + align_axis( + current.y, + region.bounds.origin.y, + region.bounds.origin.y + region.bounds.size.height, + target.origin.y - request.margin.top, + target.origin.y + target.size.height + request.margin.bottom, + request.y, + ), + ), + region.max_offset, + ) +} + +fn align_axis( + current: f32, + viewport_start: f32, + viewport_end: f32, + target_start: f32, + target_end: f32, + alignment: ScrollAlignment, +) -> f32 { + current + + match alignment { + ScrollAlignment::Start => target_start - viewport_start, + ScrollAlignment::Center => { + (target_start + target_end - viewport_start - viewport_end) * 0.5 + } + ScrollAlignment::End => target_end - viewport_end, + ScrollAlignment::Nearest if target_start < viewport_start => { + target_start - viewport_start + } + ScrollAlignment::Nearest if target_end > viewport_end => target_end - viewport_end, + ScrollAlignment::Nearest => 0.0, + } +} + +fn clamp_point(point: Point, maximum: Point) -> Point { + Point::new(point.x.clamp(0.0, maximum.x), point.y.clamp(0.0, maximum.y)) +} diff --git a/crates/argui-testing/src/error.rs b/crates/argui-testing/src/error.rs new file mode 100644 index 00000000..782d4b18 --- /dev/null +++ b/crates/argui-testing/src/error.rs @@ -0,0 +1,47 @@ +use crate::Selector; + +/// Observed cardinality for a selector that must resolve uniquely. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SelectorCount { + None, + Multiple(usize), +} + +/// Structured failure returned by a headless application operation. +#[derive(Clone, Debug, thiserror::Error)] +pub enum TestError { + #[error("the requested application presentation is closed")] + ClosedPresentation, + #[error("task runtime failed: {message}")] + Task { message: String }, + #[error("application window {window:?} is already open")] + DuplicateWindow { window: argui_platform::WindowKey }, + #[error("application window {window:?} is not open")] + MissingWindow { window: argui_platform::WindowKey }, + #[error("layout failed: {message}")] + Layout { message: String }, + #[error("selector {selector} matched {count:?}; focused={focused:?}\n{candidates}\n{tree}")] + Selector { + selector: Selector, + count: SelectorCount, + focused: Option, + candidates: String, + tree: String, + }, + #[error("application did not settle within {limit} iterations\n{tree}")] + DidNotSettle { limit: usize, tree: String }, + #[error("node selected by {selector} has no usable layout bounds\n{tree}")] + MissingBounds { selector: Selector, tree: String }, + #[error("operation requires a text input, but {selector} selected {role:?}\n{tree}")] + NotTextInput { + selector: Selector, + role: argui_accessibility::Role, + tree: String, + }, + #[error("semantic action {action:?} is unavailable for {selector}\n{tree}")] + UnsupportedAction { + selector: Selector, + action: argui_accessibility::SemanticAction, + tree: String, + }, +} diff --git a/crates/argui-testing/src/inspect.rs b/crates/argui-testing/src/inspect.rs new file mode 100644 index 00000000..7e88cd08 --- /dev/null +++ b/crates/argui-testing/src/inspect.rs @@ -0,0 +1,373 @@ +use argui_accessibility::{Role, SemanticState, SemanticTree, SemanticValue}; +use argui_core::Rect; +use argui_runtime::{AppModel, Render}; +use argui_ui::{Display, NodeId}; + +use crate::{Selector, SelectorCount, SemanticMatcher, TestApp, TestError}; + +#[derive(Clone, Debug)] +pub(crate) struct ResolvedNode { + pub node: NodeId, + pub role: Role, + pub label: Option, + pub state: SemanticState, + pub bounds: Option, +} + +impl TestApp { + /// Returns the current renderer-independent accessibility snapshot. + #[must_use] + pub fn semantics(&self) -> SemanticTree { + self.ui().semantic_tree(&self.output().semantic_bounds, 1.0) + } + + /// Returns a compact current tree and semantic dump for diagnostics. + #[must_use] + pub fn dump(&self) -> String { + self.compact_dump() + } + + /// Panics unless visible text containing `text` exists. + pub fn assert_text(&self, text: &str) { + assert!( + self.texts() + .iter() + .any(|candidate| candidate.contains(text)), + "expected visible text containing {text:?}\n{}", + self.compact_dump() + ); + } + + /// Panics if visible text containing `text` exists. + pub fn assert_no_text(&self, text: &str) { + assert!( + self.texts() + .iter() + .all(|candidate| !candidate.contains(text)), + "expected no visible text containing {text:?}\n{}", + self.compact_dump() + ); + } + + /// Panics unless the unique element with `key` currently owns focus. + pub fn assert_focused(&self, key: &str) { + let resolved = self + .resolve_unique(&Selector::key(key)) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + self.ui().focused_node(), + Some(resolved.node), + "expected key {key:?} to be focused\n{}", + self.compact_dump() + ); + } + + /// Panics unless `selector` resolves to exactly one visible element. + pub fn assert_exists(&self, selector: impl Into) { + self.resolve_unique(&selector.into()) + .unwrap_or_else(|error| panic!("{error}")); + } + + /// Panics unless the unique `selector` is visible with non-empty bounds. + pub fn assert_visible(&self, selector: impl Into) { + let selector = selector.into(); + let resolved = self + .resolve_unique(&selector) + .unwrap_or_else(|error| panic!("{error}")); + assert!( + resolved + .bounds + .is_some_and(|bounds| bounds.size.width > 0.0 && bounds.size.height > 0.0), + "expected {selector} to have visible bounds\n{}", + self.compact_dump() + ); + } + + /// Panics unless `selector` has the requested accessible `state`. + pub fn assert_state(&self, selector: impl Into, state: SemanticMatcher) { + let selector = selector.into(); + let resolved = self + .resolve_unique(&selector) + .unwrap_or_else(|error| panic!("{error}")); + assert!( + state.matches(&resolved.state), + "expected {selector} to match {state:?}, got {:?}\n{}", + resolved.state, + self.compact_dump() + ); + } + + /// Panics unless the unique text input selected by `selector` has `expected` value. + pub fn assert_input_value(&self, selector: impl Into, expected: &str) { + let selector = selector.into(); + let resolved = self + .resolve_unique(&selector) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + self.ui().text_input_value(resolved.node), + Some(expected), + "unexpected value for {selector}\n{}", + self.compact_dump() + ); + } + + /// Panics unless `selector` has exactly `expected` logical layout bounds. + pub fn assert_bounds(&self, selector: impl Into, expected: Rect) { + let selector = selector.into(); + let resolved = self + .resolve_unique(&selector) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + resolved.bounds, + Some(expected), + "unexpected bounds for {selector}" + ); + } + + /// Returns the logical layout bounds of the unique `selector`. + /// + /// # Errors + /// Returns a selector diagnostic or [`TestError::MissingBounds`]. + pub fn bounds(&self, selector: impl Into) -> Result { + let selector = selector.into(); + self.resolve_unique(&selector)? + .bounds + .ok_or_else(|| TestError::MissingBounds { + selector, + tree: self.compact_dump(), + }) + } + + /// Returns the retained scroll offset for the unique `selector`. + /// + /// # Errors + /// Returns a selector diagnostic when `selector` is missing or ambiguous. + pub fn scroll_offset( + &self, + selector: impl Into, + ) -> Result { + let selector = selector.into(); + let node = self.resolve_unique(&selector)?.node; + Ok(self.ui().scroll_offset(node)) + } + + /// Panics unless the current in-memory clipboard equals `expected`. + pub fn assert_clipboard(&self, expected: &str) { + assert_eq!(self.clipboard.as_deref(), Some(expected)); + } + + /// Panics unless the recorded application commands equal `expected` in order. + pub fn assert_commands(&self, expected: &[argui_runtime::AppCommand]) { + assert_eq!(self.commands(), expected, "unexpected application commands"); + } + + /// Panics unless no render, model, animation, or scroll work is pending. + pub fn assert_quiescent(&self) { + assert!( + !self.pending + && !self.model.wants_animation_frame(&self.window) + && !self.ui().wants_animation_frame() + && !self.ui().wants_scroll_frame(), + "expected application to be quiescent\n{}", + self.compact_dump() + ); + } + + /// Panics unless no asynchronous operation remains registered. + pub fn assert_no_pending_tasks(&self) { + assert_eq!( + self.pending_tasks(), + 0, + "expected no pending tasks\n{}", + self.compact_dump() + ); + } + + pub(crate) fn resolve_unique(&self, selector: &Selector) -> Result { + let matches = self.resolve_all(selector); + if matches.len() == 1 { + return Ok(matches.into_iter().next().expect("one match")); + } + Err(TestError::Selector { + selector: selector.clone(), + count: if matches.is_empty() { + SelectorCount::None + } else { + SelectorCount::Multiple(matches.len()) + }, + focused: self + .ui() + .focused_node() + .map(|node| self.describe_node(node)), + candidates: self.candidate_dump(), + tree: self.compact_dump(), + }) + } + + fn resolve_all(&self, selector: &Selector) -> Vec { + if let Selector::Key(key) = selector { + return self + .ui() + .node_ids() + .iter() + .copied() + .filter(|node| self.ui().key(*node) == Some(key.as_str())) + .filter(|node| self.node_visible(*node)) + .map(|node| self.resolved_node(node)) + .collect(); + } + if matches!(selector, Selector::Focused) { + return self + .ui() + .focused_node() + .into_iter() + .map(|node| self.resolved_node(node)) + .collect(); + } + self.semantics() + .nodes + .iter() + .filter(|node| match selector { + Selector::RoleName { role, name } => { + node.semantics.role == *role + && node.semantics.label.as_deref() == Some(name.as_str()) + } + Selector::Text(text) => { + node.semantics.label.as_deref() == Some(text.as_str()) + || matches!( + &node.semantics.value, + Some(SemanticValue::Text(value)) if value == text + ) + } + Selector::Label(label) => node.semantics.label.as_deref() == Some(label.as_str()), + Selector::State(state) => state.matches(&node.semantics.state), + Selector::Key(_) | Selector::Focused => false, + }) + .filter_map(|semantic| { + self.ui() + .node_ids() + .iter() + .copied() + .find(|node| node.get() == semantic.id.get()) + }) + .map(|node| self.resolved_node(node)) + .collect() + } + + fn resolved_node(&self, node: NodeId) -> ResolvedNode { + let semantic = self + .semantics() + .nodes + .into_iter() + .find(|semantic| semantic.id.get() == node.get()); + ResolvedNode { + node, + role: semantic + .as_ref() + .map_or(Role::Generic, |value| value.semantics.role), + label: semantic + .as_ref() + .and_then(|value| value.semantics.label.clone()), + state: semantic + .as_ref() + .map_or_else(SemanticState::default, |value| { + value.semantics.state.clone() + }), + bounds: self + .output() + .nodes + .iter() + .find(|layout| layout.node == node) + .map(|layout| layout.bounds), + } + } + + fn node_visible(&self, node: NodeId) -> bool { + let mut current = Some(node); + while let Some(candidate) = current { + let Some(element) = self.ui().element_for(candidate) else { + return false; + }; + if self.ui().resolved_layout_style(candidate, element).display == Display::None { + return false; + } + current = self.ui().parent_of(candidate); + } + true + } + + fn texts(&self) -> Vec { + self.semantics() + .nodes + .into_iter() + .flat_map(|node| { + let mut values = node.semantics.label.into_iter().collect::>(); + if let Some(SemanticValue::Text(value)) = node.semantics.value { + values.push(value); + } + values + }) + .collect() + } + + fn describe_node(&self, node: NodeId) -> String { + let resolved = self.resolved_node(node); + format!( + "role={:?} label={:?} key={:?}", + resolved.role, + resolved.label, + self.ui().key(node) + ) + } + + fn candidate_dump(&self) -> String { + let candidates = self + .semantics() + .nodes + .iter() + .take(12) + .map(|node| { + format!( + " {:?} label={:?} value={:?}", + node.semantics.role, node.semantics.label, node.semantics.value + ) + }) + .collect::>() + .join("\n"); + format!("close candidates:\n{candidates}") + } + + pub(crate) fn compact_dump(&self) -> String { + let mut lines = vec![format!( + "tree viewport={:?} focused={:?} pending={}", + self.viewport, + self.ui() + .focused_node() + .map(|node| self.describe_node(node)), + self.pending + )]; + for node in self.semantics().nodes.iter().take(40) { + lines.push(format!( + "- {:?} label={:?} value={:?} state={:?} bounds={:?}", + node.semantics.role, + node.semantics.label, + node.semantics.value, + node.semantics.state, + node.bounds + )); + } + lines.join("\n") + } +} + +impl From<&str> for Selector { + fn from(value: &str) -> Self { + Self::key(value) + } +} + +impl From for Selector { + fn from(value: String) -> Self { + Self::key(value) + } +} diff --git a/crates/argui-testing/src/lib.rs b/crates/argui-testing/src/lib.rs new file mode 100644 index 00000000..586884b0 --- /dev/null +++ b/crates/argui-testing/src/lib.rs @@ -0,0 +1,31 @@ +//! Deterministic, renderer-independent testing for complete Argui applications. +//! +//! [`TestApp`] renders through the retained model runtime, computes real layout, +//! hit-tests pointer input, routes ordinary UI events, and settles resulting +//! application work without creating a native window or GPU surface. +//! +//! The harness uses embedded fonts and an in-memory clipboard. Native tests can +//! therefore exercise complete controlled views on CI hosts without a display +//! server; renderer and operating-system integration remain separate concerns. +//! +//! ```ignore +//! let mut app = argui_testing::TestApp::new(Counter::default()); +//! app.click("increment")?; +//! app.assert_text("Count: 1"); +//! app.assert_quiescent(); +//! # Ok::<(), argui_testing::TestError>(()) +//! ``` + +mod actions; +mod app; +mod error; +mod inspect; +mod node; +mod query; +mod windows; + +pub use app::TestApp; +pub use error::{SelectorCount, TestError}; +pub use node::TestNode; +pub use query::{Selector, SemanticMatcher}; +pub use windows::TestWindows; diff --git a/crates/argui-testing/src/node.rs b/crates/argui-testing/src/node.rs new file mode 100644 index 00000000..27ace78d --- /dev/null +++ b/crates/argui-testing/src/node.rs @@ -0,0 +1,76 @@ +use argui_accessibility::{SemanticAction, SemanticValue}; +use argui_runtime::Render; + +use crate::{Selector, TestApp, TestError}; + +/// Deferred operation handle for one uniquely selected application node. +pub struct TestNode<'a, A: Render> { + app: &'a mut TestApp, + selector: Selector, +} + +impl<'a, A: Render> TestNode<'a, A> { + pub(crate) const fn new(app: &'a mut TestApp, selector: Selector) -> Self { + Self { app, selector } + } + + /// Clicks this node using real layout, pointer input, and hit testing. + /// + /// # Errors + /// Returns a selector, bounds, layout, or stabilization error. + pub fn click(self) -> Result<(), TestError> { + self.app.click_selector(&self.selector) + } + + /// Focuses this node through the retained focus system. + /// + /// # Errors + /// Returns a selector or stabilization error. + pub fn focus(self) -> Result<(), TestError> { + self.app.focus(self.selector) + } + + /// Types `text` into this text-input node. + /// + /// # Errors + /// Returns a selector, input-kind, layout, or stabilization error. + pub fn type_text(self, text: &str) -> Result<(), TestError> { + self.app.type_text(self.selector, text) + } + + /// Replaces this text input's complete value with `value`. + /// + /// # Errors + /// Returns a selector, input-kind, layout, or stabilization error. + pub fn replace_text(self, value: &str) -> Result<(), TestError> { + self.app.replace_text(self.selector, value) + } + + /// Pastes `text` into this text-input node. + /// + /// # Errors + /// Returns a selector, input-kind, layout, or stabilization error. + pub fn paste(self, text: &str) -> Result<(), TestError> { + self.app.paste(self.selector, text) + } + + /// Submits this text input with Enter. + /// + /// # Errors + /// Returns a selector, input-kind, layout, or stabilization error. + pub fn submit(self) -> Result<(), TestError> { + self.app.submit(self.selector) + } + + /// Invokes `action` through the accessibility event path. + /// + /// # Errors + /// Returns a selector, unsupported-action, layout, or stabilization error. + pub fn accessibility_action( + self, + action: SemanticAction, + value: Option, + ) -> Result<(), TestError> { + self.app.accessibility_action(self.selector, action, value) + } +} diff --git a/crates/argui-testing/src/query.rs b/crates/argui-testing/src/query.rs new file mode 100644 index 00000000..a9cb1269 --- /dev/null +++ b/crates/argui-testing/src/query.rs @@ -0,0 +1,90 @@ +use std::fmt; + +use argui_accessibility::{Role, SemanticState}; + +/// Semantic-state predicate used by [`Selector::State`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SemanticMatcher { + Enabled, + Disabled, + Checked, + Selected, + Expanded, + Busy, + Invalid, +} + +impl SemanticMatcher { + pub(crate) fn matches(self, state: &SemanticState) -> bool { + match self { + Self::Enabled => !state.disabled, + Self::Disabled => state.disabled, + Self::Checked => state + .checked + .is_some_and(|value| value != argui_accessibility::CheckedState::Unchecked), + Self::Selected => state.selected, + Self::Expanded => state.expanded == Some(true), + Self::Busy => state.busy, + Self::Invalid => state.invalid, + } + } +} + +/// Stable selector accepted by headless queries and diagnostics. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Selector { + Key(String), + RoleName { role: Role, name: String }, + Text(String), + Label(String), + State(SemanticMatcher), + Focused, +} + +impl Selector { + /// Selects an element by its application key. + #[must_use] + pub fn key(key: impl Into) -> Self { + Self::Key(key.into()) + } + + /// Selects an accessible node by exact role and name. + #[must_use] + pub fn role(role: Role, name: impl Into) -> Self { + Self::RoleName { + role, + name: name.into(), + } + } + + /// Selects a visible text semantic by exact content. + #[must_use] + pub fn text(text: impl Into) -> Self { + Self::Text(text.into()) + } + + /// Selects an accessible node by exact label. + #[must_use] + pub fn label(label: impl Into) -> Self { + Self::Label(label.into()) + } + + /// Selects accessible nodes matching `state`. + #[must_use] + pub const fn state(state: SemanticMatcher) -> Self { + Self::State(state) + } +} + +impl fmt::Display for Selector { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Key(key) => write!(formatter, "key({key:?})"), + Self::RoleName { role, name } => write!(formatter, "role({role:?}, {name:?})"), + Self::Text(text) => write!(formatter, "text({text:?})"), + Self::Label(label) => write!(formatter, "label({label:?})"), + Self::State(state) => write!(formatter, "state({state:?})"), + Self::Focused => formatter.write_str("focused()"), + } + } +} diff --git a/crates/argui-testing/src/windows.rs b/crates/argui-testing/src/windows.rs new file mode 100644 index 00000000..c18795a6 --- /dev/null +++ b/crates/argui-testing/src/windows.rs @@ -0,0 +1,110 @@ +use argui_platform::WindowKey; +use argui_runtime::{Entity, Render}; + +use crate::{TestApp, TestError}; + +/// Headless presentations that share one retained application entity. +/// +/// Each window has independent focus, layout, event handlers, and +/// presentation-scoped resources while mutations affect the shared model. +pub struct TestWindows { + entity: Entity, + windows: Vec>, +} + +impl TestWindows { + /// Creates a multi-window harness with a settled main window. + /// + /// # Panics + /// Panics if the main presentation cannot be initialized. Use + /// [`Self::try_new`] to handle the failure explicitly. + #[must_use] + pub fn new(app: A) -> Self { + Self::try_new(app).unwrap_or_else(|error| panic!("{error}")) + } + + /// Creates a multi-window harness and returns initialization failures. + /// + /// # Errors + /// Returns a layout, closed-presentation, or stabilization error. + pub fn try_new(app: A) -> Result { + let entity = Entity::new(app); + let main = TestApp::from_entity_in_window(entity.clone(), WindowKey::main())?; + Ok(Self { + entity, + windows: vec![main], + }) + } + + /// Returns the retained entity shared by all windows. + #[must_use] + pub const fn entity(&self) -> &Entity { + &self.entity + } + + /// Opens and settles an independent presentation named by `key`. + /// + /// # Errors + /// Returns a duplicate-key, layout, closed-presentation, or stabilization error. + pub fn open(&mut self, key: WindowKey) -> Result<&mut TestApp, TestError> { + if self + .windows + .iter() + .any(|window| window.window_key() == &key) + { + return Err(TestError::DuplicateWindow { window: key }); + } + self.windows + .push(TestApp::from_entity_in_window(self.entity.clone(), key)?); + Ok(self.windows.last_mut().expect("a window was just inserted")) + } + + /// Returns the open window named by `key`. + /// + /// # Errors + /// Returns [`TestError::MissingWindow`] when no presentation has that key. + pub fn window(&mut self, key: &WindowKey) -> Result<&mut TestApp, TestError> { + self.windows + .iter_mut() + .find(|window| window.window_key() == key) + .ok_or_else(|| TestError::MissingWindow { + window: key.clone(), + }) + } + + /// Closes the presentation named by `key` and cancels its scoped resources. + /// + /// # Errors + /// Returns [`TestError::MissingWindow`] when no presentation has that key. + pub fn close(&mut self, key: &WindowKey) -> Result<(), TestError> { + let index = self + .windows + .iter() + .position(|window| window.window_key() == key) + .ok_or_else(|| TestError::MissingWindow { + window: key.clone(), + })?; + self.windows.remove(index); + Ok(()) + } + + /// Settles every open window after shared state changes. + /// + /// # Errors + /// Returns the first layout or stabilization error. + pub fn settle(&mut self) -> Result<(), TestError> { + for window in &mut self.windows { + window.settle()?; + } + Ok(()) + } + + /// Returns the currently open application-window keys. + #[must_use] + pub fn window_keys(&self) -> Vec { + self.windows + .iter() + .map(|window| window.window_key().clone()) + .collect() + } +} diff --git a/crates/argui-testing/tests/actions.rs b/crates/argui-testing/tests/actions.rs new file mode 100644 index 00000000..2c697c44 --- /dev/null +++ b/crates/argui-testing/tests/actions.rs @@ -0,0 +1,305 @@ +use argui_accessibility::{Role, SemanticAction, SemanticValue, Semantics}; +use argui_core::{Key, Modifiers, Point, ScrollDelta, Size}; +use argui_runtime::{Context, Render}; +use argui_testing::{SelectorCount, TestApp, TestError}; +use argui_ui::{Element, EventType, length}; +use argui_widgets::{Button, Input, ScrollArea, default_theme}; + +#[derive(Default)] +struct ButtonMatrix { + pointer: usize, + touch: usize, + keyboard: usize, + accessibility: usize, + disabled: usize, + busy: usize, + custom: usize, + multiple: usize, + direct_bubble: usize, + parent_bubble: usize, + cancelled: usize, + cancelled_parent: usize, + stale_replacement: usize, + replace_handler: bool, +} + +impl Render for ButtonMatrix { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + let button = |key, label| Button::new(key, label, theme.button()); + let replacement_increment = if self.replace_handler { 10 } else { 1 }; + + let bubble = Element::container([button("bubble", "Bubble") + .on_click(cx.callback(|app| app.direct_bubble += 1)) + .build()]) + .on(cx.listener(EventType::Click, |app, event, _| { + if event.target_key() == Some("bubble") { + app.parent_bubble += 1; + } + })); + let cancelled = Element::container([button("cancelled", "Cancelled") + .on_click(cx.event_handler(|app, event, cx| { + app.cancelled += 1; + event.stop_propagation(); + cx.notify(); + })) + .build()]) + .on(cx.listener(EventType::Click, |app, event, _| { + if event.target_key() == Some("cancelled") { + app.cancelled_parent += 1; + } + })); + + Element::column([ + button("pointer", "Pointer") + .on_click(cx.callback(|app| app.pointer += 1)) + .build(), + button("touch", "Touch") + .on_click(cx.callback(|app| app.touch += 1)) + .build(), + button("keyboard", "Keyboard") + .on_click(cx.callback(|app| app.keyboard += 1)) + .build(), + button("accessibility", "Accessibility") + .on_click(cx.callback(|app| app.accessibility += 1)) + .build(), + button("disabled", "Disabled") + .enabled(false) + .on_click(cx.callback(|app| app.disabled += 1)) + .build(), + button("busy", "Busy") + .loading(Element::text("Loading")) + .on_click(cx.callback(|app| app.busy += 1)) + .build(), + button("custom", "Custom content") + .content(Element::row([Element::text("Save"), Element::text("now")])) + .on_click(cx.callback(|app| app.custom += 1)) + .build(), + button("multiple", "Multiple") + .on_click(cx.callback(|app| app.multiple += 1)) + .on_click(cx.callback(|app| app.multiple += 10)) + .build(), + bubble, + cancelled, + button("replacement", "Replacement") + .on_click(cx.callback(move |app| { + app.stale_replacement += replacement_increment; + app.replace_handler = true; + })) + .build(), + ]) + .gap(4.0) + .width(length(320.0)) + } +} + +#[test] +fn direct_button_handlers_cover_every_activation_path_and_guard() { + let mut app = TestApp::new(ButtonMatrix::default()); + + app.click("pointer").unwrap(); + app.tap("touch").unwrap(); + app.focus("keyboard").unwrap(); + app.key(Key::Enter, Modifiers::default()).unwrap(); + app.key(Key::Character(" ".to_owned()), Modifiers::default()) + .unwrap(); + app.accessibility_action("accessibility", SemanticAction::Click, None) + .unwrap(); + app.click("disabled").unwrap(); + app.accessibility_action("busy", SemanticAction::Click, None) + .unwrap(); + app.click("custom").unwrap(); + app.click("multiple").unwrap(); + app.click("bubble").unwrap(); + app.click("cancelled").unwrap(); + app.click("replacement").unwrap(); + app.click("replacement").unwrap(); + + assert_eq!( + app.entity().read(|state| [ + state.pointer, + state.touch, + state.keyboard, + state.accessibility, + state.disabled, + state.busy, + state.custom, + state.multiple, + state.direct_bubble, + state.parent_bubble, + state.cancelled, + state.cancelled_parent, + state.stale_replacement, + ]), + [1, 1, 2, 1, 0, 0, 1, 11, 1, 1, 1, 0, 11] + ); +} + +#[derive(Default)] +struct ActionErrors { + value: String, +} + +impl Render for ActionErrors { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + let input = cx.input_callback(|state, value| state.value = value); + let content = Element::column([ + Element::container([]).height(length(280.0)), + Element::text("Scroll target"), + ]); + Element::column([ + Input::new("field", &self.value, "Value", theme.input()) + .label("Value") + .on_input(input) + .build(), + Button::new("plain", "Plain", theme.button()).build(), + Button::new("prevented", "Prevented", theme.button()) + .on_click(cx.event_handler(|_, event, _| { + let _ = event.prevent_default(); + })) + .build(), + Button::new("disabled-action", "Disabled action", theme.button()) + .enabled(false) + .build(), + Element::container([]) + .keyed("semantic-input") + .semantics(Semantics::new(Role::TextInput).label("Semantic input")), + Element::container([]) + .keyed("zero-bounds") + .width(length(0.0)) + .height(length(0.0)), + ScrollArea::new("scroll", "Scroll", 80.0, content).build(&theme), + ScrollArea::new( + "blocked-scroll", + "Blocked scroll", + 80.0, + Element::container([]).height(length(280.0)), + ) + .build(&theme) + .on(cx.listener(EventType::Wheel, |_, event, _| { + let _ = event.prevent_default(); + })), + ]) + .gap(4.0) + .width(length(300.0)) + } +} + +#[test] +fn action_helpers_cover_validation_accessibility_and_scroll_branches() { + let mut app = TestApp::new(ActionErrors::default()); + app.set_settle_limit(0); + + app.pointer_move(Point::new(4.0, 4.0)).unwrap(); + assert!(matches!( + app.pointer_move(Point::new(-1.0, 4.0)).unwrap_err(), + TestError::MissingBounds { .. } + )); + assert!(matches!( + app.drag(Point::new(-1.0, 0.0), Point::new(1.0, 1.0), 1) + .unwrap_err(), + TestError::MissingBounds { .. } + )); + assert!(matches!( + app.drag(Point::new(1.0, 1.0), Point::new(4000.0, 1.0), 1) + .unwrap_err(), + TestError::MissingBounds { .. } + )); + let button = app.bounds("plain").unwrap(); + let center = Point::new( + button.origin.x + button.size.width * 0.5, + button.origin.y + button.size.height * 0.5, + ); + app.drag(center, center, 0).unwrap(); + app.click("prevented").unwrap(); + app.tap("prevented").unwrap(); + app.focus("plain").unwrap(); + app.shortcut( + Key::Character("k".to_owned()), + Modifiers { + control: true, + ..Modifiers::default() + }, + ) + .unwrap(); + app.blur().unwrap(); + + for error in [ + app.type_text("plain", "x").unwrap_err(), + app.replace_text("plain", "x").unwrap_err(), + app.paste("plain", "x").unwrap_err(), + app.submit("plain").unwrap_err(), + ] { + assert!(matches!(error, TestError::NotTextInput { .. })); + } + app.accessibility_action("plain", SemanticAction::Focus, None) + .unwrap(); + app.accessibility_action("plain", SemanticAction::Blur, None) + .unwrap(); + app.accessibility_action( + "field", + SemanticAction::SetValue, + Some(SemanticValue::Text("replacement".to_owned())), + ) + .unwrap(); + app.assert_input_value("field", "replacement"); + assert!(matches!( + app.accessibility_action("plain", SemanticAction::SetValue, None) + .unwrap_err(), + TestError::UnsupportedAction { .. } + )); + assert!(matches!( + app.accessibility_action( + "semantic-input", + SemanticAction::SetValue, + Some(SemanticValue::Text("missing editor".to_owned())), + ) + .unwrap_err(), + TestError::NotTextInput { .. } + )); + assert!(matches!( + app.accessibility_action("plain", SemanticAction::Increment, None) + .unwrap_err(), + TestError::UnsupportedAction { .. } + )); + app.accessibility_action("disabled-action", SemanticAction::Increment, None) + .unwrap(); + + let scroll = app.bounds("scroll").unwrap(); + let scroll_point = Point::new(scroll.origin.x + 8.0, scroll.origin.y + 8.0); + app.wheel(scroll_point, ScrollDelta::Pixels(Point::new(0.0, -80.0))) + .unwrap(); + assert!(app.scroll_offset("scroll").unwrap().y > 0.0); + let blocked = app.bounds("blocked-scroll").unwrap(); + let blocked_point = Point::new(blocked.origin.x + 8.0, blocked.origin.y + 8.0); + app.wheel(blocked_point, ScrollDelta::Pixels(Point::new(0.0, -80.0))) + .unwrap(); + assert_eq!(app.scroll_offset("blocked-scroll").unwrap().y, 0.0); + app.wheel( + Point::new(900.0, 700.0), + ScrollDelta::Pixels(Point::new(0.0, -80.0)), + ) + .unwrap(); + + assert!(matches!( + app.click("zero-bounds").unwrap_err(), + TestError::MissingBounds { .. } + )); + + app.resize(Size::new(-10.0, 0.0)).unwrap(); + assert_eq!(app.take_commands(), []); + assert!(matches!( + app.click("missing").unwrap_err(), + TestError::Selector { + count: SelectorCount::None, + .. + } + )); +} diff --git a/crates/argui-testing/tests/app.rs b/crates/argui-testing/tests/app.rs new file mode 100644 index 00000000..634975f7 --- /dev/null +++ b/crates/argui-testing/tests/app.rs @@ -0,0 +1,486 @@ +use argui_accessibility::{CheckedState, Role, SemanticAction}; +use argui_core::{Color, ColorScheme, Key, Modifiers, Point, Rect, Size}; +use argui_runtime::{AppCommand, Context, LayoutSnapshot, Render, ThemeRequest}; +use argui_testing::{Selector, SelectorCount, TestApp, TestError, TestWindows}; +use argui_ui::{ClipboardRequest, Element, ScrollAlignment, ScrollRequest, Sides, length}; +use argui_widgets::{Button, Checkbox, Input, ScrollArea, VList, default_theme}; + +#[derive(Default)] +struct Counter { + count: usize, +} + +impl Render for Counter { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + let increment = cx.callback(|counter| counter.count += 1); + Element::column([ + Element::text(format!("Count: {}", self.count)), + Button::new("increment", "Increment", theme.button()) + .on_click(increment) + .build(), + ]) + .width(length(240.0)) + } +} + +#[test] +fn click_uses_layout_hit_testing_and_settles_the_controlled_view() { + let mut app = TestApp::new(Counter::default()); + + app.get_by_role(Role::Button, "Increment").click().unwrap(); + + app.assert_text("Count: 1"); + app.assert_focused("increment"); + app.run_until_idle().unwrap(); + app.assert_quiescent(); + assert_eq!(app.entity().read(|counter| counter.count), 1); +} + +#[derive(Default)] +struct Form { + email: String, + accepted: CheckedState, + submissions: usize, +} + +impl Render for Form { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + let input = cx.input_callback(|form, email| form.email = email); + let submit = cx.submit_callback(|form, _| form.submissions += 1); + let accepted = cx.value_callback(|form, value| form.accepted = value); + Element::column([ + Input::new("email", &self.email, "Email", theme.input()) + .label("Email") + .on_input(input) + .on_submit(submit) + .build(), + Checkbox::new("terms", "Accept terms", self.accepted) + .on_change(accepted) + .build(&theme), + ]) + .width(length(320.0)) + } +} + +#[test] +fn accessible_queries_edit_submit_and_toggle_through_typed_handlers() { + let mut app = TestApp::new(Form::default()); + + app.get_by_role(Role::TextInput, "Email") + .type_text("person@example.com") + .unwrap(); + app.get_by_role(Role::TextInput, "Email") + .paste(".test") + .unwrap(); + app.assert_clipboard(".test"); + app.get_by_role(Role::TextInput, "Email").submit().unwrap(); + app.key(Key::Tab, Modifiers::default()).unwrap(); + app.assert_focused("terms"); + app.get_by_role(Role::CheckBox, "Accept terms") + .accessibility_action(SemanticAction::Click, None) + .unwrap(); + + app.assert_input_value( + Selector::role(Role::TextInput, "Email"), + "person@example.com.test", + ); + assert_eq!( + app.entity() + .read(|form| (form.email.clone(), form.accepted, form.submissions)), + ( + "person@example.com.test".to_owned(), + CheckedState::Checked, + 1 + ) + ); +} + +struct Effects; + +impl Render for Effects { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + Button::new("effects", "Apply effects", theme.button()) + .on_click(cx.event_handler(|_, _, cx| { + cx.write_clipboard(ClipboardRequest::Write("copied".to_owned())); + cx.set_theme(ThemeRequest { + color_scheme: Some(ColorScheme::Dark), + primary: None, + }); + cx.command(AppCommand::Quit); + })) + .build() + } +} + +#[test] +fn effects_resize_environment_and_lifecycle_are_observable() { + let mut app = TestApp::new(Effects); + app.resize(Size::new(640.0, 360.0)).unwrap(); + let mut environment = app.environment().clone(); + environment.high_contrast = true; + app.set_environment(environment).unwrap(); + + app.click("effects").unwrap(); + + app.assert_clipboard("copied"); + app.assert_commands(&[AppCommand::Quit]); + assert_eq!(app.environment().color_scheme, ColorScheme::Dark); + assert!(app.environment().high_contrast); + app.lifecycle(argui_platform::PlatformEvent::VisibilityChanged(false)) + .unwrap(); + app.assert_no_text("Apply effects"); + app.lifecycle(argui_platform::PlatformEvent::VisibilityChanged(true)) + .unwrap(); + app.assert_text("Apply effects"); +} + +#[test] +fn duplicate_queries_return_actionable_diagnostics() { + struct Duplicate; + impl Render for Duplicate { + fn render(&mut self, _cx: &mut Context) -> Element { + Element::column([Element::text("same"), Element::text("same")]) + } + } + let mut app = TestApp::new(Duplicate); + + let error = app.get_by_text("same").click().unwrap_err(); + + assert!(matches!( + error, + TestError::Selector { + count: SelectorCount::Multiple(2), + .. + } + )); + assert!(error.to_string().contains("close candidates")); +} + +#[test] +fn a_non_settling_layout_callback_hits_the_documented_bound() { + struct Loop(bool); + impl Render for Loop { + fn render(&mut self, _cx: &mut Context) -> Element { + Element::text("loop").width(length(if self.0 { 100.0 } else { 101.0 })) + } + + fn layout_changed(&mut self, _layout: &LayoutSnapshot, cx: &mut Context) { + self.0 = !self.0; + cx.notify(); + } + } + + let error = match TestApp::try_new(Loop(false)) { + Ok(_) => panic!("layout loop unexpectedly settled"), + Err(error) => error, + }; + + assert!(matches!(error, TestError::DidNotSettle { limit: 32, .. })); +} + +#[test] +fn shared_model_has_independent_window_presentations() { + let mut windows = TestWindows::new(Counter::default()); + let auxiliary = argui_platform::WindowKey::new("auxiliary"); + windows.open(auxiliary.clone()).unwrap(); + + windows + .window(&auxiliary) + .unwrap() + .click("increment") + .unwrap(); + windows.settle().unwrap(); + + windows + .window(&argui_platform::WindowKey::main()) + .unwrap() + .assert_text("Count: 1"); + assert_eq!(windows.entity().read(|counter| counter.count), 1); + windows.close(&auxiliary).unwrap(); + assert_eq!(windows.window_keys(), [argui_platform::WindowKey::main()]); +} + +#[cfg(feature = "tasks")] +struct TimedTask { + completed: bool, + task: Option, +} + +#[cfg(feature = "tasks")] +impl Render for TimedTask { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + let start = cx.event_handler(|task, _, cx| { + task.task = Some( + cx.spawn( + async { + argui_runtime::tasks::sleep(std::time::Duration::from_millis(500)).await; + }, + |task, result, cx| { + result.unwrap(); + task.completed = true; + task.task = None; + cx.notify(); + }, + ) + .unwrap(), + ); + }); + Element::column([ + Button::new("start", "Start", theme.button()) + .on_click(start) + .build(), + Element::text(if self.completed { "Done" } else { "Waiting" }), + ]) + } +} + +#[cfg(all(feature = "tasks", not(target_arch = "wasm32")))] +#[test] +fn controlled_time_completes_tasks_without_wall_clock_sleep() { + let mut app = TestApp::new(TimedTask { + completed: false, + task: None, + }); + app.click("start").unwrap(); + assert_eq!(app.pending_tasks(), 1); + + app.advance(std::time::Duration::from_millis(499)).unwrap(); + app.assert_text("Waiting"); + app.advance(std::time::Duration::from_millis(2)).unwrap(); + + assert_eq!(app.pending_tasks(), 0); + app.assert_text("Done"); + app.run_until_idle().unwrap(); + app.assert_quiescent(); +} + +#[cfg(all(feature = "tasks", not(target_arch = "wasm32")))] +#[test] +fn closing_a_window_cancels_its_presentation_task() { + let mut windows = TestWindows::new(TimedTask { + completed: false, + task: None, + }); + let auxiliary = argui_platform::WindowKey::new("task-window"); + windows + .open(auxiliary.clone()) + .unwrap() + .click("start") + .unwrap(); + let token = windows.entity().read(|task| { + task.task + .as_ref() + .expect("task started") + .cancellation_token() + }); + + windows.close(&auxiliary).unwrap(); + + assert!(token.is_cancelled()); + assert!(!windows.entity().read(|task| task.completed)); +} + +struct RevealDemo; + +impl Render for RevealDemo { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + let reveal = cx.event_handler(|_, _, cx| { + cx.scroll(argui_ui::ScrollRequest::reveal("target")); + }); + let content = Element::column([ + Element::container([]).height(length(320.0)), + Element::text("Target").keyed("target").height(length(32.0)), + ]); + Element::column([ + Button::new("reveal", "Reveal", theme.button()) + .on_click(reveal) + .build(), + ScrollArea::new("viewport", "Results", 96.0, content).build(&theme), + ]) + .width(length(320.0)) + } +} + +#[test] +fn programmatic_reveal_applies_to_real_scroll_layout() { + let mut app = TestApp::new(RevealDemo); + assert_eq!(app.scroll_offset("viewport").unwrap().y, 0.0); + + app.click("reveal").unwrap(); + + assert!(app.scroll_offset("viewport").unwrap().y > 0.0); + assert!(app.scroll_requests().iter().any(|request| matches!( + &request.target, + argui_ui::ScrollTarget::Element(argui_ui::FocusTarget::Key(key)) if key == "target" + ))); +} + +struct Virtualized; + +impl Render for Virtualized { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + VList::new("virtual", 20.0, 100.0, 800.0).build(100, &theme, |index| { + Element::text(format!("Virtual row {index}")).height(length(20.0)) + }) + } +} + +#[test] +fn virtualized_content_mounts_only_the_current_window() { + let app = TestApp::new(Virtualized); + + app.assert_no_text("Virtual row 0"); + app.assert_text("Virtual row 40"); + app.assert_visible("virtual"); +} + +struct EffectMatrix; + +impl Render for EffectMatrix { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + let button = |key, label| Button::new(key, label, theme.button()); + let content = Element::column([ + Element::text("Top").height(length(20.0)), + Element::container([]).height(length(300.0)), + Element::text("Bottom") + .keyed("effect-target") + .height(length(20.0)), + ]); + Element::column([ + Input::new("effect-input", "seed", "Effect input", theme.input()) + .label("Effect input") + .build(), + Element::row([ + button("focus-effect", "Focus effect") + .on_click(cx.event_handler(|_, _, cx| { + cx.request_focus("effect-input"); + })) + .build(), + button("clear-effect", "Clear effect") + .on_click(cx.event_handler(|_, _, cx| cx.clear_focus())) + .build(), + button("selection-effect", "Selection effect") + .on_click(cx.event_handler(|_, _, cx| { + cx.select_text("effect-input", argui_ui::TextSelection::All); + })) + .build(), + button("read-effect", "Read clipboard") + .on_click(cx.event_handler(|_, _, cx| { + cx.write_clipboard(ClipboardRequest::Read { target: None }); + })) + .build(), + ]), + Element::row([ + button("theme-effect", "Theme") + .on_click(cx.event_handler(|_, _, cx| { + cx.set_theme(ThemeRequest { + color_scheme: None, + primary: Some(Color::srgb(0.8, 0.2, 0.3)), + }); + })) + .build(), + button("offset-effect", "Offset") + .on_click(cx.event_handler(|_, _, cx| { + cx.scroll(ScrollRequest::offset( + "effect-scroll", + Point::new(0.0, 120.0), + )); + })) + .build(), + button("missing-effect", "Missing") + .on_click(cx.event_handler(|_, _, cx| { + cx.scroll(ScrollRequest::reveal("missing-target")); + })) + .build(), + ]), + ScrollArea::new("effect-scroll", "Effects", 90.0, content).build(&theme), + Element::row([ + scroll_button(cx, &theme, "start-effect", ScrollAlignment::Start), + scroll_button(cx, &theme, "center-effect", ScrollAlignment::Center), + scroll_button(cx, &theme, "end-effect", ScrollAlignment::End), + scroll_button(cx, &theme, "nearest-effect", ScrollAlignment::Nearest), + ]), + ]) + .width(length(640.0)) + } +} + +fn scroll_button( + cx: &mut Context, + theme: &argui_widgets::WidgetTheme, + key: &'static str, + alignment: ScrollAlignment, +) -> Element { + Button::new(key, key, theme.button()) + .on_click(cx.event_handler(move |_, _, cx| { + cx.scroll( + ScrollRequest::rect( + "effect-scroll", + Rect::new(Point::new(0.0, 300.0), Size::new(20.0, 20.0)), + ) + .align(alignment, alignment) + .margin(Sides { + left: 2.0, + right: 2.0, + top: 2.0, + bottom: 2.0, + }), + ); + })) + .build() +} + +#[test] +fn model_effect_matrix_applies_focus_clipboard_theme_and_scroll_targets() { + let mut app = TestApp::new(EffectMatrix); + app.click("read-effect").unwrap(); + app.paste("effect-input", " clipboard").unwrap(); + app.click("focus-effect").unwrap(); + app.assert_focused("effect-input"); + app.click("selection-effect").unwrap(); + app.click("read-effect").unwrap(); + app.click("clear-effect").unwrap(); + app.click("theme-effect").unwrap(); + assert_eq!(app.environment().primary, Color::srgb(0.8, 0.2, 0.3)); + + app.click("missing-effect").unwrap(); + app.click("offset-effect").unwrap(); + assert!(app.scroll_offset("effect-scroll").unwrap().y > 0.0); + for key in [ + "start-effect", + "center-effect", + "end-effect", + "nearest-effect", + ] { + app.click(key).unwrap(); + } + assert!(app.scroll_requests().len() >= 6); +} diff --git a/crates/argui-testing/tests/inspect.rs b/crates/argui-testing/tests/inspect.rs new file mode 100644 index 00000000..071ac39f --- /dev/null +++ b/crates/argui-testing/tests/inspect.rs @@ -0,0 +1,64 @@ +use argui_accessibility::{Role, SemanticState, Semantics}; +use argui_runtime::{Context, Render}; +use argui_testing::{Selector, SelectorCount, SemanticMatcher, TestApp, TestError}; +use argui_ui::{Element, length}; +use argui_widgets::{Button, default_theme}; + +struct Inspectable; + +impl Render for Inspectable { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + Element::column([ + Element::text("Visible value").keyed("text"), + Button::new("focusable", "Focusable", theme.button()).build(), + Element::container([]) + .keyed("semantic") + .semantics(Semantics::new(Role::Button).label("Semantic label").state( + SemanticState { + busy: true, + ..SemanticState::default() + }, + )) + .width(length(120.0)) + .height(length(32.0)), + ]) + .width(length(240.0)) + } +} + +#[test] +fn inspection_exposes_snapshots_bounds_focus_and_diagnostics() { + let mut app = TestApp::new(Inspectable); + + assert!(!app.semantics().nodes.is_empty()); + assert!(app.dump().contains("Semantic label")); + app.assert_text("Visible"); + app.assert_no_text("Absent"); + app.assert_exists(Selector::role(Role::Button, "Semantic label")); + app.assert_exists(Selector::label("Semantic label")); + app.assert_exists(Selector::text("Visible value")); + app.assert_visible("semantic"); + app.assert_state("semantic", SemanticMatcher::Busy); + let bounds = app.bounds("semantic").unwrap(); + app.assert_bounds("semantic", bounds); + + app.focus("focusable").unwrap(); + app.assert_focused("focusable"); + app.focused().focus().unwrap(); + app.assert_quiescent(); + app.assert_no_pending_tasks(); + + let error = app.bounds("missing").unwrap_err(); + assert!(matches!( + error, + TestError::Selector { + count: SelectorCount::None, + .. + } + )); + assert!(error.to_string().contains("close candidates")); +} diff --git a/crates/argui-testing/tests/node.rs b/crates/argui-testing/tests/node.rs new file mode 100644 index 00000000..3fd4c281 --- /dev/null +++ b/crates/argui-testing/tests/node.rs @@ -0,0 +1,171 @@ +use argui_accessibility::{SemanticAction, SemanticValue}; +use argui_core::{Key, Modifiers, Point}; +use argui_runtime::{Context, Render}; +use argui_testing::{SemanticMatcher, TestApp}; +use argui_ui::{Element, length}; +use argui_widgets::{ + Breadcrumb, BreadcrumbLink, Button, Collapsible, Dialog, DialogBehavior, Input, Pagination, + RangeConfig, Slider, Toggle, default_theme, +}; + +#[derive(Default)] +struct HandlerMatrix { + toggled: bool, + volume: f32, + volume_commits: usize, + open: bool, + page: usize, + destination: String, + expanded: bool, +} + +impl Render for HandlerMatrix { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + let dialog = DialogBehavior::new("settings", "Settings", self.open); + Element::column([ + Toggle::new("notifications", "Notifications", self.toggled) + .on_change(cx.value_callback(|app, value| app.toggled = value)) + .build(&theme), + Slider::new( + "volume", + "Volume", + self.volume, + RangeConfig::new(0.0, 10.0, 1.0), + ) + .on_change(cx.value_callback(|app, value| app.volume = value)) + .on_commit(cx.value_callback(|app, _| app.volume_commits += 1)) + .build(&theme) + .width(length(240.0)), + Pagination::new("pages", self.page.max(1), 4) + .on_select(cx.value_callback(|app, page| app.page = page)) + .build(&theme), + Breadcrumb::new("crumbs", [BreadcrumbLink::new("home", "Home")], "Current") + .on_activate(cx.value_callback(|app, id| app.destination = id)) + .build(&theme), + Collapsible::new( + "details", + "Details", + self.expanded, + Element::text("Expanded content"), + ) + .on_open_change(cx.value_callback(|app, open| app.expanded = open)) + .build(&theme), + Dialog::new( + "settings", + "Settings", + self.open, + Button::new(dialog.trigger_key(), "Open settings", theme.button()).build(), + Element::text("Dialog content"), + ) + .on_open_change(cx.value_callback(|app, open| app.open = open)) + .build(&theme), + ]) + .gap(12.0) + .width(length(480.0)) + } +} + +#[test] +fn typed_handlers_cover_selection_range_navigation_and_overlay_families() { + let mut app = TestApp::new(HandlerMatrix { + page: 1, + ..HandlerMatrix::default() + }); + + app.click("notifications").unwrap(); + let slider = app.bounds("volume").unwrap(); + app.drag( + Point::new( + slider.origin.x + 1.0, + slider.origin.y + slider.size.height * 0.5, + ), + Point::new( + slider.origin.x + slider.size.width * 0.5, + slider.origin.y + slider.size.height * 0.5, + ), + 3, + ) + .unwrap(); + app.focus("volume").unwrap(); + app.key(Key::ArrowRight, Modifiers::default()).unwrap(); + app.click("pages::next").unwrap(); + app.click("crumbs::link::home").unwrap(); + app.click("details::trigger").unwrap(); + app.assert_text("Expanded content"); + app.click("settings::trigger").unwrap(); + + app.assert_text("Dialog content"); + assert_eq!( + app.entity().read(|state| ( + state.toggled, + state.volume, + state.volume_commits, + state.page, + state.destination.clone(), + state.expanded, + state.open, + )), + (true, 6.0, 2, 2, "home".to_owned(), true, true) + ); +} + +#[derive(Default)] +struct NodeForm { + value: String, + submissions: usize, +} + +impl Render for NodeForm { + fn render(&mut self, cx: &mut Context) -> Element { + let environment = cx.environment(); + let theme = default_theme(environment.clone()) + .resolve(environment.color_scheme) + .clone(); + Element::column([ + Input::new("node-input", &self.value, "Node input", theme.input()) + .label("Node input") + .on_input(cx.input_callback(|app, value| app.value = value)) + .on_submit(cx.submit_callback(|app, _| app.submissions += 1)) + .build(), + Button::new("node-button", "Node button", theme.button()).build(), + Button::new("node-disabled", "Node disabled", theme.button()) + .enabled(false) + .build(), + ]) + .width(length(300.0)) + } +} + +#[test] +fn node_handles_forward_the_complete_action_surface() { + let mut app = TestApp::new(NodeForm::default()); + + app.get_by_key("node-button").click().unwrap(); + app.get_by_text("Node button").focus().unwrap(); + app.get_by_label("Node input").type_text("typed").unwrap(); + app.get_by_role(argui_accessibility::Role::TextInput, "Node input") + .replace_text("replaced") + .unwrap(); + app.get_by_key("node-input").paste(" pasted").unwrap(); + app.get_by_key("node-input").submit().unwrap(); + app.get_by_key("node-input") + .accessibility_action( + SemanticAction::SetValue, + Some(SemanticValue::Text("semantic".to_owned())), + ) + .unwrap(); + app.get_by_state(SemanticMatcher::Disabled) + .accessibility_action(SemanticAction::Increment, None) + .unwrap(); + app.focused().focus().unwrap(); + + assert_eq!( + app.entity() + .read(|state| (state.value.clone(), state.submissions)), + ("semantic".to_owned(), 1) + ); +} diff --git a/crates/argui-testing/tests/query.rs b/crates/argui-testing/tests/query.rs new file mode 100644 index 00000000..061eff8e --- /dev/null +++ b/crates/argui-testing/tests/query.rs @@ -0,0 +1,106 @@ +use argui_accessibility::{CheckedState, Role, SemanticState, Semantics}; +use argui_runtime::{Context, Render}; +use argui_testing::{Selector, SemanticMatcher, TestApp}; +use argui_ui::Element; + +struct SemanticMatrix; + +impl Render for SemanticMatrix { + fn render(&mut self, _cx: &mut Context) -> Element { + let semantic = |key, label, state| { + Element::container([]) + .keyed(key) + .semantics(Semantics::new(Role::Button).label(label).state(state)) + }; + Element::column([ + semantic("enabled", "Enabled", SemanticState::default()), + semantic( + "disabled", + "Disabled", + SemanticState { + disabled: true, + ..SemanticState::default() + }, + ), + semantic( + "checked", + "Checked", + SemanticState { + checked: Some(CheckedState::Checked), + ..SemanticState::default() + }, + ), + semantic( + "selected", + "Selected", + SemanticState { + selected: true, + ..SemanticState::default() + }, + ), + semantic( + "expanded", + "Expanded", + SemanticState { + expanded: Some(true), + ..SemanticState::default() + }, + ), + semantic( + "busy", + "Busy", + SemanticState { + busy: true, + ..SemanticState::default() + }, + ), + semantic( + "invalid", + "Invalid", + SemanticState { + invalid: true, + ..SemanticState::default() + }, + ), + ]) + } +} + +#[test] +fn selectors_format_stably_and_semantic_matchers_cover_every_state() { + let selectors = [ + (Selector::key("save"), "key(\"save\")".to_owned()), + ( + Selector::role(Role::Button, "Save"), + "role(Button, \"Save\")".to_owned(), + ), + (Selector::text("Saved"), "text(\"Saved\")".to_owned()), + (Selector::label("Email"), "label(\"Email\")".to_owned()), + ( + Selector::state(SemanticMatcher::Busy), + "state(Busy)".to_owned(), + ), + (Selector::Focused, "focused()".to_owned()), + ]; + for (selector, expected) in selectors { + assert_eq!(selector.to_string(), expected); + } + assert_eq!(Selector::from("borrowed"), Selector::key("borrowed")); + assert_eq!( + Selector::from(String::from("owned")), + Selector::key("owned") + ); + + let app = TestApp::new(SemanticMatrix); + for (key, state) in [ + ("enabled", SemanticMatcher::Enabled), + ("disabled", SemanticMatcher::Disabled), + ("checked", SemanticMatcher::Checked), + ("selected", SemanticMatcher::Selected), + ("expanded", SemanticMatcher::Expanded), + ("busy", SemanticMatcher::Busy), + ("invalid", SemanticMatcher::Invalid), + ] { + app.assert_state(key, state); + } +} diff --git a/crates/argui-testing/tests/windows.rs b/crates/argui-testing/tests/windows.rs new file mode 100644 index 00000000..3fe10fa4 --- /dev/null +++ b/crates/argui-testing/tests/windows.rs @@ -0,0 +1,37 @@ +use argui_platform::WindowKey; +use argui_runtime::{Context, Render}; +use argui_testing::{TestError, TestWindows}; +use argui_ui::Element; + +struct WindowContent; + +impl Render for WindowContent { + fn render(&mut self, _cx: &mut Context) -> Element { + Element::text("Window") + } +} + +#[test] +fn window_collection_reports_duplicate_and_missing_keys() { + let mut windows = TestWindows::try_new(WindowContent).unwrap(); + assert_eq!(windows.entity().read(|_| 7), 7); + let auxiliary = WindowKey::new("auxiliary-errors"); + windows.open(auxiliary.clone()).unwrap(); + + let duplicate = match windows.open(auxiliary.clone()) { + Ok(_) => panic!("duplicate window unexpectedly opened"), + Err(error) => error, + }; + assert!(matches!(duplicate, TestError::DuplicateWindow { .. })); + windows.settle().unwrap(); + windows.close(&auxiliary).unwrap(); + let missing = match windows.window(&auxiliary) { + Ok(_) => panic!("closed window unexpectedly found"), + Err(error) => error, + }; + assert!(matches!(missing, TestError::MissingWindow { .. })); + assert!(matches!( + windows.close(&auxiliary).unwrap_err(), + TestError::MissingWindow { .. } + )); +} diff --git a/crates/argui-text/src/input.rs b/crates/argui-text/src/input.rs index 343f9728..69b573e3 100644 --- a/crates/argui-text/src/input.rs +++ b/crates/argui-text/src/input.rs @@ -1,13 +1,15 @@ +use std::collections::{HashMap, HashSet}; + use argui_core::{CaretAffinity, Point, Rect, Size, TextPosition}; -use cosmic_text::{Buffer, Metrics, Shaping}; +use cosmic_text::{Buffer, Metrics, Scroll}; use unicode_segmentation::UnicodeSegmentation; -use crate::{TextEngine, TextStyle, engine}; +use crate::{TextContent, TextEngine, TextStyle, engine}; const INPUT_BUFFER_CACHE_CAPACITY: usize = 8; pub(crate) struct InputBuffer { - text: String, + content: TextContent, style: TextStyle, viewport: Size, buffer: Buffer, @@ -83,22 +85,85 @@ impl TextEngine { selection: Option<(TextPosition, TextPosition)>, scroll: TextInputScroll, ) -> TextInputLayout { + self.input_layout_content( + &TextContent::plain(text), + style, + viewport, + cursor, + selection, + scroll, + ) + } + + /// Shapes rich editor content and computes caret, selection, and scrolling geometry. + /// + /// * `content` — displayed editor content with optional per-span styling. + /// * `style` — base editor text style inherited by spans. + /// * `viewport` — available input size in logical pixels. + /// * `cursor` — caret position and affinity in the displayed text. + /// * `selection` — optional anchor and focus positions. + /// * `scroll` — current offset and caret reveal policy. + /// + /// Returns caret stops, selection rectangles, content size, and resolved offsets. + pub fn input_layout_content( + &mut self, + content: &TextContent, + style: &TextStyle, + viewport: Size, + cursor: TextPosition, + selection: Option<(TextPosition, TextPosition)>, + scroll: TextInputScroll, + ) -> TextInputLayout { + let text = content.as_str(); let cache_index = self .input_buffers .iter() - .position(|entry| entry.matches(text, style, viewport)) + .position(|entry| entry.matches(content, style, viewport)) .unwrap_or_else(|| { - let entry = InputBuffer::new(&mut self.fonts, text, style, viewport); + let entry = InputBuffer::new(&mut self.fonts, content, style, viewport); if self.input_buffers.len() == INPUT_BUFFER_CACHE_CAPACITY { self.input_buffers.remove(0); } self.input_buffers.push(entry); self.input_buffers.len() - 1 }); - let buffer = &self.input_buffers[cache_index].buffer; - let cursor = TextPosition::new(boundary(text, cursor.index), cursor.affinity); - let raw_stops = caret_stops(buffer, text); + let line_offsets = engine::source_line_offsets(text); + let no_wrap = style.wrap == crate::TextWrap::None; + let content_height = if no_wrap { + line_offsets.len().max(1) as f32 * style.line_height + } else { + 0.0 + }; + let cursor_line = line_offsets + .partition_point(|offset| *offset <= cursor.index) + .saturating_sub(1); + let cursor_y = cursor_line as f32 * style.line_height; + let scroll_y = no_wrap.then(|| { + resolved_scroll( + scroll.caret, + scroll.offset.y, + cursor_y, + style.line_height, + viewport.height, + content_height, + ) + }); + let buffer = &mut self.input_buffers[cache_index].buffer; + if let Some(scroll_y) = scroll_y { + let line = (scroll_y / style.line_height.max(1.0)).floor() as usize; + let line = line.min(line_offsets.len().saturating_sub(1)); + let vertical = scroll_y - line as f32 * style.line_height; + buffer.set_scroll(Scroll::new(line, vertical, 0.0)); + buffer.shape_until_scroll(&mut self.fonts, false); + } + let y_offset = scroll_y.unwrap_or_default(); + let mut raw_stops = caret_stops(buffer, text); + if no_wrap { + for stop in &mut raw_stops { + stop.point.y += y_offset; + } + } let caret_stop = raw_stops .iter() .find(|stop| stop.position == cursor) @@ -107,17 +172,25 @@ impl TextEngine { .iter() .find(|stop| stop.position.index == cursor.index) }); - let (caret_x, caret_y) = caret_stop - .map(|stop| (stop.point.x, stop.point.y)) - .unwrap_or_default(); - let content_width = buffer + let (caret_x, caret_y) = + caret_stop.map_or((0.0, cursor_y), |stop| (stop.point.x, stop.point.y)); + let shaped_width = buffer .layout_runs() .map(|run| run.line_w) .fold(0.0_f32, f32::max); - let content_height = buffer - .layout_runs() - .map(|run| run.line_top + run.line_height) - .fold(style.line_height, f32::max); + let content_width = if no_wrap && style.family == crate::FontFamily::Monospace { + shaped_width.max(unwrapped_width_estimate(text, style.font_size)) + } else { + shaped_width + }; + let content_height = if no_wrap { + content_height + } else { + buffer + .layout_runs() + .map(|run| run.line_top + run.line_height) + .fold(style.line_height, f32::max) + }; let scroll_x = resolved_scroll( scroll.caret, scroll.offset.x, @@ -126,14 +199,16 @@ impl TextEngine { viewport.width, content_width, ); - let scroll_y = resolved_scroll( - scroll.caret, - scroll.offset.y, - caret_y, - style.line_height, - viewport.height, - content_height, - ); + let scroll_y = scroll_y.unwrap_or_else(|| { + resolved_scroll( + scroll.caret, + scroll.offset.y, + caret_y, + style.line_height, + viewport.height, + content_height, + ) + }); let caret = Rect::new( Point::new(caret_x - scroll_x, caret_y - scroll_y), Size::new(1.5, style.line_height), @@ -145,6 +220,7 @@ impl TextEngine { anchor, focus, Point::new(scroll_x, scroll_y), + y_offset, ) }); let stops = raw_stops @@ -166,20 +242,35 @@ impl TextEngine { } } +fn unwrapped_width_estimate(text: &str, font_size: f32) -> f32 { + let columns = text + .lines() + .map(|line| { + line.chars() + .map(|character| if character == '\t' { 4 } else { 1 }) + .sum::() + }) + .max() + .unwrap_or_default(); + columns as f32 * font_size * 0.62 +} + pub(crate) fn caret_stops(buffer: &Buffer, text: &str) -> Vec { let mut boundaries = text .unicode_word_indices() .flat_map(|(index, word)| [index, index + word.len()]) - .collect::>(); + .collect::>(); boundaries.extend([0, text.len()]); let line_offsets = engine::source_line_offsets(text); let mut stops = Vec::new(); + let mut seen = HashSet::new(); for run in buffer.layout_runs() { let base = line_offsets.get(run.line_i).copied().unwrap_or_default(); let visuals = visual_clusters(&run); if visuals.is_empty() { push_stop( &mut stops, + &mut seen, TextPosition::new(base, CaretAffinity::Before), 0.0, run.line_top, @@ -199,6 +290,7 @@ pub(crate) fn caret_stops(buffer: &Buffer, text: &str) -> Vec { }; push_stop( &mut stops, + &mut seen, TextPosition::new(base + visual.start + offset, CaretAffinity::After), x, run.line_top, @@ -216,7 +308,14 @@ pub(crate) fn caret_stops(buffer: &Buffer, text: &str) -> Vec { visual.right, ) }; - push_stop(&mut stops, position, x, run.line_top, &boundaries); + push_stop( + &mut stops, + &mut seen, + position, + x, + run.line_top, + &boundaries, + ); } } if stops.is_empty() { @@ -236,39 +335,39 @@ pub(crate) fn caret_stops(buffer: &Buffer, text: &str) -> Vec { } fn visual_clusters(run: &cosmic_text::LayoutRun<'_>) -> Vec { - let mut clusters: Vec = Vec::new(); + let mut clusters: HashMap<(usize, usize), VisualCluster> = HashMap::new(); for glyph in run.glyphs { - if let Some(cluster) = clusters - .iter_mut() - .find(|cluster| cluster.start == glyph.start && cluster.end == glyph.end) - { + if let Some(cluster) = clusters.get_mut(&(glyph.start, glyph.end)) { cluster.left = cluster.left.min(glyph.x); cluster.right = cluster.right.max(glyph.x + glyph.w); } else { - clusters.push(VisualCluster { - start: glyph.start, - end: glyph.end, - left: glyph.x, - right: glyph.x + glyph.w, - rtl: glyph.level.is_rtl(), - }); + clusters.insert( + (glyph.start, glyph.end), + VisualCluster { + start: glyph.start, + end: glyph.end, + left: glyph.x, + right: glyph.x + glyph.w, + rtl: glyph.level.is_rtl(), + }, + ); } } + let mut clusters = clusters.into_values().collect::>(); clusters.sort_by(|a, b| a.left.total_cmp(&b.left)); clusters } fn push_stop( stops: &mut Vec, + seen: &mut HashSet<(usize, bool)>, position: TextPosition, x: f32, y: f32, - word_boundaries: &[usize], + word_boundaries: &HashSet, ) { - if stops - .iter() - .any(|stop| stop.position == position && (stop.point.x - x).abs() < 0.01) - { + let key = (position.index, position.affinity == CaretAffinity::After); + if !seen.insert(key) { return; } stops.push(CaretStop { @@ -281,7 +380,7 @@ fn push_stop( impl InputBuffer { fn new( fonts: &mut cosmic_text::FontSystem, - text: &str, + content: &TextContent, style: &TextStyle, viewport: Size, ) -> Self { @@ -297,24 +396,18 @@ impl InputBuffer { crate::TextWrap::Word => cosmic_text::Wrap::Word, crate::TextWrap::WordOrGlyph => cosmic_text::Wrap::WordOrGlyph, }); - let attrs = engine::attrs(style); - buffer.set_text( - text, - &attrs, - Shaping::Advanced, - engine::text_align(style.align), - ); + engine::set_content(&mut buffer, content, style); buffer.shape_until_scroll(fonts, false); Self { - text: text.to_owned(), + content: content.clone(), style: style.clone(), viewport, buffer, } } - fn matches(&self, text: &str, style: &TextStyle, viewport: Size) -> bool { - self.text == text && self.style == *style && self.viewport == viewport + fn matches(&self, content: &TextContent, style: &TextStyle, viewport: Size) -> bool { + self.content == *content && self.style == *style && self.viewport == viewport } } @@ -351,22 +444,21 @@ fn visual_selection_rects( anchor: TextPosition, focus: TextPosition, scroll: Point, + y_offset: f32, ) -> Vec { - let (Some(anchor), Some(focus)) = (find_stop(stops, anchor), find_stop(stops, focus)) else { - return Vec::new(); - }; - let (start, end) = if anchor.position.index <= focus.position.index { - (anchor.position.index, focus.position.index) + let (start, end) = if anchor.index <= focus.index { + (anchor.index, focus.index) } else { - (focus.position.index, anchor.position.index) + (focus.index, anchor.index) }; buffer .layout_runs() .filter_map(|run| { + let line_top = run.line_top + y_offset; let selected = stops .iter() .filter(|stop| { - (stop.point.y - run.line_top).abs() < 0.01 + (stop.point.y - line_top).abs() < 0.01 && stop.position.index >= start && stop.position.index <= end }) @@ -381,7 +473,7 @@ fn visual_selection_rects( .max_by(f32::total_cmp)?; (right > left).then(|| { Rect::new( - Point::new(left - scroll.x, run.line_top - scroll.y), + Point::new(left - scroll.x, line_top - scroll.y), Size::new(right - left, run.line_height), ) }) @@ -389,17 +481,6 @@ fn visual_selection_rects( .collect() } -fn find_stop(stops: &[CaretStop], position: TextPosition) -> Option<&CaretStop> { - stops - .iter() - .find(|stop| stop.position == position) - .or_else(|| { - stops - .iter() - .find(|stop| stop.position.index == position.index) - }) -} - fn boundary(text: &str, index: usize) -> usize { let index = index.min(text.len()); if text.is_char_boundary(index) { diff --git a/crates/argui-text/src/style.rs b/crates/argui-text/src/style.rs index f0f51153..2f0755fb 100644 --- a/crates/argui-text/src/style.rs +++ b/crates/argui-text/src/style.rs @@ -1,4 +1,4 @@ -use std::{num::NonZeroUsize, ops::Range}; +use std::{num::NonZeroUsize, ops::Range, sync::Arc}; use argui_core::{Rect, Size}; @@ -262,12 +262,23 @@ impl TextSpan { } } -#[derive(Clone, Debug, PartialEq)] -pub struct TextContent { +#[derive(Debug, PartialEq)] +struct TextContentData { text: String, runs: Vec<(Range, TextSpanStyle)>, } +#[derive(Clone, Debug)] +pub struct TextContent { + data: Arc, +} + +impl PartialEq for TextContent { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.data, &other.data) || self.data == other.data + } +} + impl TextContent { /// Creates unstyled text content. /// @@ -275,8 +286,10 @@ impl TextContent { #[must_use] pub fn plain(text: impl Into) -> Self { Self { - text: text.into(), - runs: Vec::new(), + data: Arc::new(TextContentData { + text: text.into(), + runs: Vec::new(), + }), } } @@ -295,23 +308,54 @@ impl TextContent { runs.push((start..end, span.style)); } } - Self { text, runs } + Self { + data: Arc::new(TextContentData { text, runs }), + } } /// Returns the concatenated UTF-8 text, without style metadata. #[must_use] pub fn as_str(&self) -> &str { - &self.text + &self.data.text } /// Returns whether this content contains any styled runs. #[must_use] pub fn is_rich(&self) -> bool { - !self.runs.is_empty() + !self.data.runs.is_empty() + } + + /// Copies one UTF-8 byte range while retaining intersecting span styles. + /// + /// `range` is clamped to valid character boundaries in the stored text. + #[must_use] + pub fn slice(&self, range: Range) -> Self { + let mut start = range.start.min(self.data.text.len()); + let mut end = range.end.min(self.data.text.len()).max(start); + while !self.data.text.is_char_boundary(start) { + start -= 1; + } + while !self.data.text.is_char_boundary(end) { + end -= 1; + } + let text = self.data.text[start..end].to_owned(); + let runs = self + .data + .runs + .iter() + .filter_map(|(range, style)| { + let run_start = range.start.max(start); + let run_end = range.end.min(end); + (run_start < run_end).then(|| (run_start - start..run_end - start, style.clone())) + }) + .collect(); + Self { + data: Arc::new(TextContentData { text, runs }), + } } pub(crate) fn runs(&self) -> &[(Range, TextSpanStyle)] { - &self.runs + &self.data.runs } } diff --git a/crates/argui-text/tests/input.rs b/crates/argui-text/tests/input.rs index e08f8dae..7f087b93 100644 --- a/crates/argui-text/tests/input.rs +++ b/crates/argui-text/tests/input.rs @@ -348,3 +348,34 @@ fn empty_multiline_runs_keep_a_real_caret_stop_and_scroll_position() { .any(|stop| stop.position == pos(value.len())) ); } + +#[test] +fn large_code_buffers_build_unique_caret_stops_in_source_order() { + let value = (0..600) + .map(|line| format!("let value_{line} = compute({line}); // retained editor geometry")) + .collect::>() + .join("\n"); + let style = TextStyle { + font_size: 14.0, + line_height: 20.0, + wrap: TextWrap::None, + ..TextStyle::default() + }; + let layout = engine().input_layout( + &value, + &style, + Size::new(720.0, 400.0), + pos(value.len()), + None, + reveal(Point::default()), + ); + let unique = layout + .stops + .iter() + .map(|stop| (stop.position.index, stop.position.affinity)) + .collect::>(); + + assert!(layout.content_size.height > 10_000.0); + assert!(unique.windows(2).all(|pair| pair[0] != pair[1])); + assert!(layout.stops.iter().any(|stop| stop.word_boundary)); +} diff --git a/crates/argui-text/tests/style.rs b/crates/argui-text/tests/style.rs index b39b11a6..a8d3f7e2 100644 --- a/crates/argui-text/tests/style.rs +++ b/crates/argui-text/tests/style.rs @@ -95,3 +95,19 @@ fn rich_text_and_typography_builders_preserve_web_style_properties() { assert_eq!(block.style.align, TextAlign::Justify); assert_eq!(span_style.font_style, Some(FontStyle::Italic)); } + +#[test] +fn rich_text_slices_clamp_utf8_boundaries_and_rebase_styles() { + let content = TextContent::rich([ + TextSpan::new("é").style(TextSpanStyle::default().color(TextColor::WHITE)), + TextSpan::new("abc").style(TextSpanStyle::default().weight(700)), + ]); + + let clamped = content.slice(1..4); + let rebased = content.slice(2..4); + + assert_eq!(clamped.as_str(), "éab"); + assert_eq!(rebased.as_str(), "ab"); + assert!(clamped.is_rich()); + assert!(rebased.is_rich()); +} diff --git a/crates/argui-ui/src/binding.rs b/crates/argui-ui/src/binding.rs index cccd2bf5..9b035da4 100644 --- a/crates/argui-ui/src/binding.rs +++ b/crates/argui-ui/src/binding.rs @@ -13,6 +13,7 @@ use layout::{layout_value, set_layout_value}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum BindingImpact { + Composite, Paint, Layout, Scroll, @@ -100,6 +101,7 @@ impl PropertyBinding { #[must_use] pub const fn impact(&self) -> BindingImpact { match self { + Self::Transform(_) | Self::LayerOpacity(_) => BindingImpact::Composite, Self::Layout(..) | Self::BorderWidths(_) => BindingImpact::Layout, Self::Scroll(_) => BindingImpact::Scroll, _ => BindingImpact::Paint, diff --git a/crates/argui-ui/src/caret.rs b/crates/argui-ui/src/caret.rs index 947a42cd..d73601ef 100644 --- a/crates/argui-ui/src/caret.rs +++ b/crates/argui-ui/src/caret.rs @@ -179,6 +179,40 @@ impl CaretAnimation { / self.duration.as_nanos() as f32; self.keyframes.sample(progress) } + + /// Returns the delay until a held keyframe can visibly change. + /// + /// * `elapsed` — elapsed time since the looping animation began. + /// + /// Returns `None` while the current segment interpolates continuously. + pub(crate) fn next_sample_in(&self, elapsed: Duration) -> Option { + let duration = self.duration.as_nanos(); + let cycle = elapsed.as_nanos() % duration; + let progress = cycle as f64 / duration as f64; + let frames = self.keyframes.as_slice(); + let upper = frames.partition_point(|frame| f64::from(frame.offset) <= progress); + let from = &frames[upper.saturating_sub(1)]; + if !from.hold { + return None; + } + let target = frames.get(upper).map_or(duration, |frame| { + (f64::from(frame.offset) * duration as f64).ceil() as u64 + }); + Some(Duration::from_nanos(target.saturating_sub(cycle).max(1))) + } + + /// Returns whether the animation can reuse retained pixels in the compositor. + /// + /// Opacity and transforms are composition properties. A changing tint must + /// repaint the caret primitives to preserve authored color interpolation. + #[must_use] + pub fn supports_composition(&self) -> bool { + let tint = self.keyframes.as_slice()[0].value.tint; + self.keyframes + .as_slice() + .iter() + .all(|frame| frame.value.tint == tint) + } } #[derive(Clone, Debug, PartialEq)] @@ -254,10 +288,14 @@ pub(crate) struct CaretAnimator { } impl CaretAnimator { + /// Resets the retained caret identity and timeline. pub fn reset(&mut self) { *self = Self::default(); } + /// Advances the retained caret timeline to `now` for `node`. + /// + /// Returns whether an animated caret should be repainted. pub fn advance(&mut self, node: Option, now: Time) -> bool { let Some(node) = node else { self.reset(); @@ -273,6 +311,31 @@ impl CaretAnimator { true } + /// Returns whether `node` needs display-linked frames for `animation`. + /// + /// A new caret starts immediately; held keyframe segments sleep until their + /// next visible change. + pub fn wants_frame(self, node: NodeId, animation: &CaretAnimation) -> bool { + self.node != Some(node) + || self.started.is_none() + || animation.next_sample_in(self.elapsed).is_none() + } + + /// Returns the absolute animation time of the next held-keyframe change. + /// + /// Returns `None` for an uninitialized caret or a continuously interpolated + /// segment. + pub fn next_frame_at(self, node: NodeId, animation: &CaretAnimation) -> Option` should own or coordinate: + +- an `Entity` and `SingleWindowModel`; +- the current `UiTree`; +- `LayoutEngine` and deterministic embedded-font `TextEngine` instances; +- a logical viewport and `WindowEnvironment`; +- the latest layout output and hit regions; +- pending application effects and a bounded settle loop. + +The settle loop must render, reconcile, lay out, deliver resulting layout +changes and repeat until no work remains. It must use a documented iteration +limit and return a diagnostic error for a non-settling application rather than +hanging a test. + +### Queries + +Provide queries by: + +- application key; +- accessible role and name; +- visible text; +- label; +- semantic state; +- focused element. + +Prefer accessible queries in documentation because they verify both usability +and testability. Keys remain available for exact internal selection. Duplicate +matches must fail with a useful diagnostic rather than selecting arbitrarily. + +### Input operations + +The first stable release must support: + +- `click` and pointer movement; +- keyboard press/release and shortcuts; +- focus, blur, Tab and Shift-Tab traversal; +- text entry, replacement and submission; +- paste and clipboard requests; +- wheel/scroll input; +- pointer drag; +- accessibility actions; +- viewport resize and environment changes. + +`click` must exercise the real pointer sequence and hit testing. A separate +explicit accessibility activation helper may synthesize an accessibility +click, but it must not be the implementation of normal pointer clicking. + +### Assertions and inspection + +Provide clear assertions for: + +- text presence and absence; +- text-input value; +- focus; +- existence and visibility; +- enabled, disabled, checked, selected, expanded, busy and invalid states; +- layout bounds; +- clipboard, focus, scroll, theme and application commands; +- pending work and final quiescence. + +Action methods should return `Result` with structured errors. Convenience +assertions may panic, but the panic must include: + +- the requested selector; +- zero, one or multiple matches; +- close candidate roles, labels, text and keys; +- the current focused node; +- a compact semantic/tree dump. + +Never expose private engine state solely to satisfy the harness. Add public +inspection only when it represents behavior visible at a crate boundary. + +### Deterministic time and tasks + +After synchronous input is stable, add: + +```rust,ignore +app.advance(Duration::from_millis(500)); +app.run_until_idle(); +``` + +Animations, timers and task completion in tests must not depend on wall-clock +sleep. Introduce an injectable clock/executor boundary only as far as needed by +the public harness. Preserve the normal native and WebAssembly executors. + +Cover cancellation caused by unmounting, replacement, hidden presentations and +application shutdown. A dropped task must not call a stale handler. + +### Later harness coverage required before completion + +The plan is not complete until the harness also covers: + +- overlays and modal focus; +- multiple application windows; +- application lifecycle events; +- async tasks and controlled time; +- scroll reveal and virtualized content; +- semantic/accessibility actions; +- error diagnostics for missing and duplicate selectors. + +## Phase 5: migrate repository applications + +Migrate examples and application code only after each corresponding widget API +is tested. + +1. Convert simple local handlers in `app_examples/docs-examples`. +2. Fix the dialog example so the built trigger key and handled key cannot + diverge, then cover opening and closing it through `argui-testing`. +3. Break the Widget Gallery's large root event router into local callbacks where + the event is local. +4. Keep deliberate delegation for catalogue navigation, commands and other + genuinely centralized behavior. +5. Migrate the fake AI harness and focused examples where direct callbacks + improve clarity. +6. Add one headless behavior test for every interactive documentation example. + +Do not perform mechanical migration that makes code longer. When a complex +widget is clearer with its typed behavior reducer, retain that reducer and +document it as the advanced pattern. + +## Phase 6: documentation and onboarding + +Reorganize public documentation around increasing complexity. + +### Required learning path + +1. Installation and a first window. +2. Counter using a direct button callback. +3. Controlled form with input, checkbox and submit. +4. Headless test of that form. +5. Async task and loading state. +6. Reusable child model. +7. Typed actions and shortcuts. +8. Event bubbling, capture and delegation. +9. Custom controls and low-level interaction. + +Every interactive widget page must contain: + +- the shortest useful example; +- its controlled state; +- its direct handler and payload; +- keyboard and accessibility behavior; +- a small `argui-testing` example; +- a link to the advanced behavior/action API when one exists. + +The Events page must explicitly explain that bubbling is useful for delegation +but is not required to handle an ordinary button. + +### Compile and behavior verification + +- Compile every Rust documentation snippet that is intended to compile. +- Run the documentation examples through their headless tests. +- Generate the website catalogue and fail CI on duplicate slugs or stale + generated output. +- Ensure the live gallery demonstrates the same API shown in the guides. + +## Phase 7: simplify imports and feature selection + +### Prelude + +Add a deliberately small `argui::prelude` for application authors. Re-export +only the types required by common views and callbacks. Do not turn the prelude +into a dump of the complete public API, and do not require library authors to +use it. + +At minimum, evaluate inclusion of: + +- `Render`, `Context` and common application launch types; +- `Element` and common layout helpers; +- the direct callback/handler types; +- common theme types; +- widgets enabled by the application's selected features. + +Check name collisions with standard-library and common ecosystem types. + +### Feature profiles + +Keep every granular `widget-*` feature, but add documented convenience profiles +for new applications if package-size measurements justify them. Candidate +profiles are a basic application set, desktop integrations and web. Do not make +all integrations default, and do not pull platform-only dependencies into +unrelated targets. + +Measure clean compile time and stripped binary/Wasm size before and after each +profile. The simple installation path must state clearly what it enables. + +### Builder consistency + +Inventory widget constructors and `build()` signatures. Make the pattern +consistent where possible, but do not introduce implicit conversions that make +ownership or feature requirements unclear. Any breaking constructor cleanup +belongs in `0.3.0` and must be performed once rather than through aliases. + +## Phase 8: compatibility and release preparation + +1. Treat the work as the `0.3.0` API line if it includes breaking constructor, + naming or payload changes. +2. Keep `Context::listener`, `Element::on` and behavior/action APIs because they + are the advanced layer, not deprecated compatibility shims. +3. Remove genuinely replaced APIs and migrate all repository callers in the + same change, following the contributor rules. +4. Add public API or semantic-version checking to CI. +5. Write a migration guide containing old and new examples for buttons, forms, + overlays and complex controlled widgets. +6. Update `CHANGELOG.md`, the README, feature tables and crate metadata. +7. Verify all crates in a temporary registry in dependency order. +8. Verify native desktop, WebAssembly and package/archive jobs. +9. Confirm the protected branch is green before marking the plan complete. + +## Test matrix + +The final implementation must cover these layers independently: + +| Layer | Required evidence | +| --- | --- | +| Handler registry | identity, replacement, mount isolation, routing and cleanup tests | +| Event dispatch | pointer, keyboard, accessibility, capture, bubble and cancellation tests | +| Widgets | direct handler and typed payload tests for every interactive category | +| Harness | query, action, settle-loop and diagnostic tests | +| Examples | one headless behavior test per interactive documentation example | +| WebAssembly | workspace target check with the final feature set | +| Native | hidden-display interaction tests plus existing renderer checks | +| Packaging | temporary-registry crate archive verification | +| Website | catalogue generation, snippet compilation and browser scenarios | + +Each affected crate must remain above the repository's 85% floor for lines, +functions, regions and branches. + +## Implementation workflow + +Use small, reviewable commits or pull requests in this order: + +1. Restore the green baseline. +2. Add opaque handler registration. +3. Complete Button plus the minimal harness vertical slice. +4. Complete text and boolean form controls. +5. Complete selection, range and overlay controls. +6. Complete navigation, menu and data widgets. +7. Complete the test harness, deterministic time and multi-window behavior. +8. Migrate examples, gallery and documentation. +9. Add the prelude and measured feature profiles. +10. Add compatibility checks and prepare `0.3.0`. + +For each step: + +1. inspect overlapping user changes before editing; +2. implement the smallest complete behavior; +3. run only directly affected crate tests with `--all-features`; +4. run Clippy for the affected crate; +5. check the affected public API on WebAssembly; +6. update documentation and tests in the same step; +7. do not leave the repository between two public API designs. + +Do not repeatedly run the entire workspace during development. After all phases +are implemented, run the full quality command exactly once immediately before +the final commit, as required by the contributor guide: + +```sh +./scripts/linux-hidden-display.sh env ARGUI_NATIVE_TESTS=1 ./scripts/quality.sh +``` + +If that command fails, fix the failure with targeted checks and then rerun the +full gate only when the implementation is again believed complete. + +## Definition of done + +Every item below is mandatory: + +- [ ] The protected branch and release/package workflows are green. +- [ ] `EventHandler`, `Context::callback` and `Context::event_handler` are + documented and tested. +- [ ] The low-level listener and propagation APIs retain their behavior. +- [ ] The Counter uses a handler attached directly to its button. +- [ ] A beginner can implement the Counter without `EventType`, + `target_key()`, `UiEventKind` or explicit `cx.notify()`. +- [ ] Every interactive widget has a consistent direct API or an explicit, + documented reason to remain action-only. +- [ ] Common widget handlers receive useful typed values rather than forcing + event-kind matching. +- [ ] Disabled, busy and unavailable controls never call direct handlers. +- [ ] Mouse, touch, keyboard and accessibility behavior remain equivalent. +- [ ] `argui-testing` works without a native window or GPU. +- [ ] Normal `click()` uses real layout, hit testing and pointer dispatch. +- [ ] The harness supports focus, keyboard, editing, scrolling, dragging, + accessibility, overlays, tasks, controlled time and multiple windows. +- [ ] Missing or ambiguous test selectors produce actionable diagnostics. +- [ ] Every interactive documentation example has a headless behavior test. +- [ ] The Dialog example opens and closes in its automated test. +- [ ] The Widget Gallery no longer uses one global handler for interactions that + are naturally local. +- [ ] Deliberate bubbling/delegation remains demonstrated on the Events page. +- [ ] Public snippets compile and website catalogue generation is clean. +- [ ] The prelude and convenience feature profiles are documented and measured. +- [ ] The migration guide and `CHANGELOG.md` are complete. +- [ ] Public API/SemVer checks run in CI. +- [ ] All affected crates and the workspace meet every 85% coverage threshold. +- [ ] Native hidden-display checks and the WebAssembly check pass. +- [ ] Crates.io archives resolve only the intended staged versions. +- [ ] No placeholder, migration shim, stale caller or unfinished checklist item + remains. +- [ ] The final quality gate passes from the exact commit intended for merge. + +The work may be reported as complete only when all boxes are supported by test, +documentation or CI evidence. A partially completed phase should be reported as +progress, never as completion. diff --git a/docs/todo_simplify_api_checklist.md b/docs/todo_simplify_api_checklist.md new file mode 100644 index 00000000..a1d5b01a --- /dev/null +++ b/docs/todo_simplify_api_checklist.md @@ -0,0 +1,152 @@ +# Simplified API implementation checklist + +This checklist tracks the evidence required by +[`todo_simplify_api.md`](todo_simplify_api.md). A box is checked only after the +implementation, its tests, and the relevant documentation or CI evidence are +present. Remote-only checks remain open until the exact merge commit has run on +the protected branch. + +## Phase 0 — green baseline + +- [x] Record the failing and passing jobs from the latest protected-branch run. +- [x] Remove every duplicate website catalogue slug and regenerate the catalogue. +- [x] Make archive verification resolve staged workspace crates only. +- [x] Split or optimize the quality workflow so no job reaches its timeout. +- [x] Run the affected website, release-script, and workflow checks. +- [x] Record the baseline test count and per-crate/workspace coverage. +- [ ] Confirm the protected-branch workflow is green. + +## Phase 1 — handler foundation + +- [x] Add and document opaque `argui_ui::EventHandler` identity. +- [x] Bind handlers to event types without exposing owner/slot construction. +- [x] Model and test direct-target delivery across capture, target, and bubble phases. +- [x] Re-export the handler from `argui-ui` and the facade. +- [x] Share one runtime registration primitive between all callback APIs. +- [x] Add and document `Context::callback` with implicit invalidation. +- [x] Add and document `Context::event_handler` without implicit invalidation. +- [x] Reimplement `Context::listener` through the shared primitive. +- [x] Test stable identity, replacement, removal, routing, mount isolation, visibility, + closure, propagation, once, and passive semantics. +- [x] Pass targeted `argui-ui` and `argui-runtime` tests and Clippy. + +## Phase 2 — Button vertical slice + +- [x] Add documented, additive `Button::on_click(EventHandler)`. +- [x] Attach direct handlers to the interactive root built by `ButtonBehavior`. +- [x] Test pointer, touch, keyboard, accessibility, disabled, busy, custom-content, + multiple-handler, bubbling, cancellation, and stale-replacement behavior. +- [x] Rewrite the Counter example and add its headless behavior test. +- [x] Keep Events as the explicit delegation example. +- [x] Add the direct-handler Button catalogue example. +- [x] Pass the relevant WebAssembly check. + +## Phase 3 — consistent widget APIs + +- [x] Inventory every exported widget as interactive or intentionally presentational. +- [x] Add typed handlers for `Input`, `TextArea`, and `InputOtp`. +- [x] Add typed handlers for `Checkbox`, `Switch`, `Toggle`, and `ToggleGroup`. +- [x] Add typed handlers for `RadioGroup`, `Tabs`, `Select`, `NativeSelect`, and `Combobox`. +- [x] Add typed handlers for `Range`, `Slider`, and `ColorPicker`. +- [x] Add typed handlers for collapsible, accordion, dialog, alert-dialog, sheet, + drawer, popover, and hover-card controls. +- [x] Add typed handlers for menu, context-menu, menubar, and command-palette controls. +- [x] Add typed handlers for calendar, date-picker, pagination, carousel, and breadcrumb. +- [x] Add typed handlers for list, VList, tree-view, table, and data-table controls. +- [x] Add typed handlers for interactive composites, including questionnaire, + message-scroller, toast, updater, and sidebar controls. +- [x] Preserve controlled business state and retain only temporary interaction state. +- [x] Test typed payloads and disabled/busy/unavailable behavior for every category. +- [x] Document an explicit reason for every action-only or presentational widget. + +## Phase 4 — `argui-testing` + +- [x] Add the publishable `argui-testing` workspace crate and facade documentation. +- [x] Implement a bounded render/reconcile/layout/event settle loop with diagnostics. +- [x] Implement unique queries by key, role/name, text, label, semantic state, and focus. +- [x] Implement pointer click/move, real hit testing, keyboard, shortcuts, focus, + Tab traversal, text editing, paste/clipboard, scrolling, drag, accessibility, + resize, and environment changes. +- [x] Implement assertions for text, value, focus, visibility, semantic states, bounds, + effects, pending work, and quiescence. +- [x] Include selector candidates, focus, and a compact semantic/tree dump in errors. +- [x] Implement deterministic time, task completion, cancellation, and idle draining. +- [x] Cover overlays/modal focus, multiple windows, lifecycle, virtualization/scroll + reveal, accessibility actions, tasks, time, and selector diagnostics. +- [x] Prove the crate needs no native window, display server, or GPU. + +## Phase 5 — repository applications + +- [x] Migrate simple handlers in `app_examples/docs-examples`. +- [x] Fix the dialog trigger-key mismatch and test open/close headlessly. +- [x] Replace naturally local Widget Gallery routing with local callbacks. +- [x] Preserve deliberate navigation, command, and delegation routers. +- [x] Migrate the fake-AI harness and focused examples where clearer. +- [x] Add one headless behavior test per interactive documentation example. + +## Phase 6 — documentation and onboarding + +- [x] Document installation and the first window. +- [x] Document the direct-callback Counter. +- [x] Document a controlled form and its headless test. +- [x] Document async/loading, reusable children, typed actions/shortcuts, delegation, + and custom low-level controls in increasing-complexity order. +- [x] Give every interactive widget page a short controlled example, handler/payload, + keyboard/accessibility behavior, harness example, and advanced API link. +- [x] Explain on the Events page that ordinary buttons do not require bubbling. +- [x] Compile all intended Rust snippets and run their headless behavior tests. +- [x] Regenerate the unique, current website catalogue and verify browser scenarios. +- [x] Keep the live gallery API aligned with the guides. + +## Phase 7 — imports, features, and builders + +- [x] Add and document a deliberately small `argui::prelude`. +- [x] Check prelude name collisions and feature-gated widget exports. +- [x] Measure clean compile time and stripped native/Wasm size before profiles. +- [x] Add only justified basic/desktop/web convenience feature profiles. +- [x] Measure and document compile time and native/Wasm size after profiles. +- [x] Inventory constructors and `build()` signatures and complete the one-time 0.3 cleanup. + +## Phase 8 — compatibility and release preparation + +- [x] Move the workspace to the `0.3.0` API line. +- [x] Preserve `Context::listener`, `Element::on`, and typed behavior/action APIs. +- [x] Remove replaced APIs and migrate every repository caller without shims. +- [x] Add public API/SemVer checking to CI. +- [x] Write the button, form, overlay, and controlled-widget migration guide. +- [x] Update `CHANGELOG.md`, README, feature tables, and crate metadata. +- [x] Verify every crate archive in dependency order against a temporary registry. +- [x] Verify native desktop, WebAssembly, mobile, package, and website jobs. +- [ ] Confirm the protected branch is green. + +## Final acceptance evidence + +- [x] Handler registry, event dispatch, widgets, harness, and examples have the full + independent test coverage listed in the plan. +- [x] Every affected crate and the workspace exceed 85% for lines, functions, + regions, and branches. +- [x] Native checks pass on the hidden display and captures are non-blank. +- [x] The final WebAssembly workspace check passes. +- [x] No `TODO`, `FIXME`, placeholder, compatibility shim, stale caller, or unchecked + implementation item remains. +- [x] The exact final commit passes + `./scripts/linux-hidden-display.sh env ARGUI_NATIVE_TESTS=1 ./scripts/quality.sh`. + +## Recorded evidence + +- Protected-branch run 35016485097 failed in the archive and website jobs and + cancelled the monolithic quality job after 87 minutes; security, both desktop + checks, mobile cross-check, Android packaging, and iOS packaging passed. +- The baseline report recorded 720 passing tests. Its workspace coverage was + 96.01% lines, 95.52% functions, 95.42% regions, and 89.36% branches; every + measured crate met the 85% gate. +- Targeted API verification passed 838 tests; the headless harness passed 18 + tests and all 14 interactive documentation examples passed independently. +- Android and iOS cross-checks, the complete native and Wasm example workspaces, + all 24 staged crates.io archives, and 111 generated website routes passed. +- The hidden-display browser suite passed its desktop and mobile flows, themes, + search, routing, copy, live Wasm interaction, fallback, 404, and hydration checks. +- The private Wayland capture was 1600×1200 and 643,396 bytes with visible + gallery content; it was not a blank-frame acceptance. +- The final protected-branch boxes remain open until this branch is pushed and + the exact commit has completed remote CI. diff --git a/docs/ui/animation.md b/docs/ui/animation.md index 8f994393..dc5657ac 100644 --- a/docs/ui/animation.md +++ b/docs/ui/animation.md @@ -15,10 +15,15 @@ and shader uniforms. The renderer owns no timeline state. `Render::animation_frame` receives the shared `Frame`. Retained property motions use a compact registry built during reconciliation. A shared motion advances once even when several properties consume it. Settled motions leave scheduling. +Held caret keyframes publish their next visible-change deadline instead of +keeping a display-linked loop alive. The native event loop sleeps until that +deadline; ordinary input still wakes it immediately. This preserves a single +ordered animation clock without paying for idle 60 Hz redraws. -Paint changes reuse layout and shaped text. Transforms update painting and hit -geometry without running Taffy. Layout properties invalidate retained layout -nodes, subject to ancestor dependencies and explicit layout boundaries. +Paint changes reuse layout and shaped text. Transform and group-opacity changes +reuse layout, shaped text, paint primitives, and GPU uploads through the retained +compositor while updating hit geometry. Layout properties invalidate retained +layout nodes, subject to ancestor dependencies and explicit layout boundaries. ## Timelines and property motions @@ -41,7 +46,9 @@ use argui::{ let opacity = Motion::new(1.0_f32); opacity.animate_to(0.4, Tween::new(Duration::from_millis(240))); -let panel = Element::container([]).bind(property::Opacity, opacity); +let panel = Element::container([]) + .opacity(1.0) + .bind(property::LayerOpacity, opacity); ``` `Schedule`, `ScheduleBuilder` and `Cue` compose sequences, parallel groups, diff --git a/docs/ui/editing.md b/docs/ui/editing.md index 6a65ad9e..85cc73ad 100644 --- a/docs/ui/editing.md +++ b/docs/ui/editing.md @@ -49,6 +49,17 @@ external value replaces the buffer, cancels preedit, clears history, and clamps positions to grapheme boundaries. Use a different element key when switching documents. +`Input::on_edit` and `TextArea::on_edit` deliver a `TextEdit`: one half-open +UTF-8 byte range in the previous controlled value plus its replacement. Apply it +with `TextEdit::apply_to` to mutate only that range. This is the preferred path +for code editors because ordinary typing no longer clones the complete document +for history or callback delivery. `on_input(String)` remains compatible and the +engine materializes that complete value only when an input listener exists. + +History stores the removed and inserted fragments for normal edits. Undo or +redo may consolidate a grouped transaction into one delta; only that uncommon +path snapshots the current value to calculate the combined replacement. + Cosmic Text supplies shaping, bidi-aware caret stops, word boundaries, selection rectangles, and wrapping. Editing never splits a grapheme. Boundary affinity keeps the correct visual caret when one byte position has two positions at a diff --git a/docs/ui/interaction.md b/docs/ui/interaction.md index e22e702f..f83f9238 100644 --- a/docs/ui/interaction.md +++ b/docs/ui/interaction.md @@ -22,6 +22,21 @@ then bubbling to root. `stop_propagation`, `stop_immediate_propagation`, and `prevent_default` act on the shared event control. Passive listeners cannot prevent defaults. +For an ordinary widget, prefer its local direct API: `Button::on_click`, +`Input::on_input`, `TextArea::on_edit`, `Checkbox::on_change`, `Select::on_select`, and the other +handlers listed in the [widget interaction inventory](../widgets/interaction-api.md). +These bindings use the same dispatch pipeline and keyboard/accessibility +behavior, but do not require bubbling, `target_key()` comparisons, event-kind +matching, or explicit invalidation when paired with `Context::callback` or +`value_callback`. Use listeners when capture or deliberate ancestor delegation +is the actual design. + +`EventType::TextEdit` carries `UiEventKind::TextEdited(TextEdit)` through that +same pipeline. `Context::edit_callback` invalidates automatically; +`edit_event_handler` leaves invalidation to the app. The legacy +`EventType::Input`/`TextChanged(String)` path is still supported, but a complete +value is copied only when that event has a listener on the propagation path. + Hit regions follow paint order and are tested in reverse. A target must pass its own hit shape and every ancestor clip. Paint and hit-test ordering change together. @@ -46,6 +61,12 @@ Pinch and rotation can run together. Velocity uses a bounded recent history. Touch can drive scroll containers while the same contacts remain available to configured gestures. +Document text selection follows browser-style mouse behavior. Pressing directly +on selectable text anchors at that glyph; starting a drag in non-interactive +whitespace anchors at the nearest selectable text and extends across the +document. Interactive hit regions such as buttons, editors, resize handles, and +custom gestures keep ownership of the pointer and never trigger this fallback. + ## Keyboard and focus Physical key transitions reach the focused node with modifiers and repeat state. @@ -83,9 +104,9 @@ its ancestor. - hit slop enlarges a touch target without changing layout or pixels. Transforms and clips apply before hit testing. Style properties keep their -invalidation class: color updates paint, transforms update paint and hit -geometry, and dimensions update layout. Transitions retain tracks beside stable -node IDs and stop requesting frames when settled. +invalidation class: color updates paint, transforms update retained composition +and hit geometry, and dimensions update layout. Transitions retain tracks beside +stable node IDs and stop requesting frames when settled. Container queries are for deliberate presentation changes that Flexbox or Grid cannot infer. See [responsive styling](styling.md). diff --git a/docs/widgets/builder-inventory.md b/docs/widgets/builder-inventory.md new file mode 100644 index 00000000..657631bf --- /dev/null +++ b/docs/widgets/builder-inventory.md @@ -0,0 +1,40 @@ +# Widget builder inventory + +The 0.3 API audit applies one ownership rule throughout the widget crate: +constructors receive controlled application data, chainable methods configure +optional behavior, and `build` produces the element. Direct handlers are +additive configuration and never replace controlled state. + +## Consistent families + +| Build shape | Widgets | Reason | +| --- | --- | --- | +| `build(theme)` | Accordion, Alert, AlertDialog, Attachment, Avatar, Badge, Breadcrumb, Bubble, Calendar, Card, Carousel, Collapsible, ColorPicker, Combobox, DataTable, DatePicker, Dialog, Drawer, Empty, Field, HoverCard, InputOtp, Item, Kbd, Label, List, Marker, MessageScroller, NativeSelect, NavigationMenu, Pagination, Popover, Progress, Questionnaire, ScrollArea, Select, Separator, Sheet, Sidebar, Skeleton, Slider, SplitPane, Tabs, Table, ToastHost, Toggle, ToggleGroup, Tooltip, TreeView, Typography, Updater | Rendering depends on shared widget colors, metrics, or assets. | +| `build()` | AspectRatio, Button, ButtonGroup, Direction, Input, Message, TextArea | All required visual data is already explicit or the widget is structural. | +| `build(theme, row/cell closure)` | List, Table, VList, SplitPane | The caller owns lazily or structurally produced children. | +| `build(trigger/target, theme)` | CommandPalette, ContextMenu, Menu | The wrapped trigger or target remains explicit, so ownership is unambiguous. | +| `build(theme, assets)` | ToastHost | Icons are an explicit application asset dependency. | +| stateful `build(&mut self, theme, context)` | AnimatedText, FilePicker | These models own retained animation or platform-task state. | + +Pure state and behavior helpers (`Collection`, `CalendarState`, +`ColorPickerState`, `DataTableModel`, `DatePickerState`, `Presence`, range and +selection behaviors, `ToastState`, `TooltipState`, and `Typeahead`) intentionally +do not follow the visual builder shape because they do not independently build +a widget surface. + +## Audit result + +- Constructors named `new` establish stable identity and required controlled + values; optional values remain chainable configuration. +- Theme-dependent widgets consistently accept `&WidgetTheme` at build time. +- Structural widgets do not accept a theme they cannot use. +- Collection closures remain explicit rather than hidden behind conversions. +- Direct handlers use typed payloads and are attached with `on_*` methods. +- Presentational widgets expose no invented change event. Action-only behavior + remains available through the public action APIs. +- No deprecated constructor alias or compatibility shim was introduced. The + repository and examples use the 0.3 signatures directly. + +This inventory is the one-time 0.3 constructor cleanup. Future additions should +choose an existing row rather than add a second spelling for the same build +operation. diff --git a/docs/widgets/interaction-api.md b/docs/widgets/interaction-api.md new file mode 100644 index 00000000..3dc3b2a3 --- /dev/null +++ b/docs/widgets/interaction-api.md @@ -0,0 +1,99 @@ +# Widget interaction APIs + +Every widget remains controlled: handlers report intent or a next value, and +the application supplies the resulting state on the next render. Direct +handlers are additive and use the same capture/target/bubble dispatch pipeline +as `Element::on`. + +## Direct handlers + +| Widget | Direct API and payload | Keyboard and accessibility contract | +| --- | --- | --- | +| Button | `on_click(EventHandler)` | Enter, Space, pointer, touch, and accessibility click converge. Disabled or busy buttons do not emit. | +| Input, TextArea | `on_edit(TextEdit)`, `on_input(String)`, `on_submit(String)` | Editing, paste, IME, Enter submission, and accessibility set-value use the text-input policy. `on_edit` avoids cloning a large complete value. | +| InputOtp | `on_input(String)`, `on_submit(String)` | Numeric filtering happens before delivery; completion submits the controlled value. | +| Checkbox | `on_change(CheckedState)` | Click/Space/accessibility click; Mixed advances to Checked. | +| Switch, Toggle | `on_change(bool)` | Click/Space/accessibility click; disabled controls do not emit. | +| ToggleGroup | `on_change(Vec)` | Roving focus and activation emit the next stable selection IDs. | +| RadioGroup, Tabs | `on_select(usize)` | Arrow navigation and activation emit the source index. | +| Select, NativeSelect | `on_select(usize)`, `on_open_change(bool)` | Disabled options are skipped; Escape and outside dismissal request close. | +| Combobox | `on_input(String)`, `on_select(usize)`, `on_open_change(bool)` | Active-descendant navigation and Enter selection share the same payloads. | +| Slider | `on_change(f32)`, `on_commit(f32)` | Pointer pan, arrows, Home/End, and accessibility value changes use the configured bounds and step. | +| ColorPicker | `on_change(Color)` | Pad, hue, alpha, and text-field edits produce the next complete color. | +| SplitPane | `on_change(f32)` | Drag, double click, arrows, Home, and End produce the next constrained pane size. | +| Collapsible | `on_open_change(bool)` | Trigger click, Enter, and Space request the next open state. | +| Accordion | `on_open_change(Vec)` | Arrow/Home/End navigation plus activation emit the next open section IDs. | +| Dialog, Sheet, Drawer | `on_open_change(bool)`, `on_dismiss(EventHandler)` | Trapped modal focus, Escape, close controls, gestures, and configured outside behavior remain unified. | +| AlertDialog | `on_open_change(bool)`, `on_action(String)` | Initial cancel focus and confirmation/cancel activation emit stable action IDs. | +| Popover, HoverCard | `on_open_change(bool)`, `on_dismiss(EventHandler)` | Trigger/focus/hover rules and Escape/outside dismissal request controlled state. | +| Menu, ContextMenu, Menubar | `on_action(String)`, `on_open_change(bool)` | Roving focus, typeahead, submenus, Enter/Space, context-key, and Escape preserve menu behavior. | +| CommandPalette | `on_input(String)`, `on_action(String)`, `on_open_change(bool)` | Search and active-command keyboard navigation emit stable command IDs. | +| Calendar | `on_select(String)` | Day activation emits ISO `YYYY-MM-DD`; constrained days never emit. | +| DatePicker | `on_input(String)`, `on_select(String)`, `on_open_change(bool)` | Input validation, calendar keyboard behavior, and dismissal remain controlled. | +| Pagination, Carousel | `on_select(usize)` | Buttons, arrows, swipe (carousel), and accessibility activation emit the next source index/page. | +| Breadcrumb | `on_activate(String)` | Actionable ancestors emit their stable IDs. | +| List, VList, Table, TreeView | `on_select(String)`, `on_activate(String)` | Pointer, Enter/Space, arrows, Home/End, typeahead, and accessibility actions emit stable row/node IDs. | +| DataTable | `on_select(String)`, `on_activate(Vec)` | Selection emits a row ID; activation emits `[row_id, column_id]`. Editing/sort/resize stay available through `DataTableAction`. | +| Chart | `on_activate(Vec)` | Pointer and accessible points emit `[series_index, point_index]`. | +| NavigationMenu | `on_action(String)`, `on_open_change(String)` | Stable item IDs are emitted; an empty open value means closed. | +| Questionnaire | `on_input(String)`, `on_choice(Vec)`, `on_navigate(usize)`, `on_submit(EventHandler)` | Choice emits `[question_id, choice_id, selected_bool]`; controlled answers and validation stay in the model. | +| MessageScroller | `on_load_earlier(EventHandler)`, `on_latest(EventHandler)` | Buttons and accessible activation preserve scroll/follow-bottom behavior. | +| ToastHost | `on_close(String)` | Close buttons emit the toast ID; hover/focus pause remains available through `pause_action`. | +| Sidebar | `on_collapsed_change(bool)`, `on_open_change(bool)` | Rail and mobile sheet controls emit the requested controlled state. | +| UpdateDialog | `on_action(String)`, `on_open_change(bool)` | Emits `check`, `download`, `cancel`, or `install`; engine state stays controlled. | + +## Incremental text edits + +Use `on_input(cx.input_callback(...))` when the complete next value is the most +convenient payload. For document-sized buffers, use +`on_edit(cx.edit_callback(...))`: `TextEdit` contains the half-open UTF-8 byte +range in the previous value and its replacement text. + +```rust,ignore +TextArea::new("source", &self.source, "Start writing…", theme.input()) + .on_edit(cx.edit_callback(|model, edit| { + edit.apply_to(&mut model.source) + .expect("an editor edit matches the controlled revision"); + })) + .build() +``` + +The engine emits both event forms only when both are observed. A control with +only an `on_edit` handler does not allocate or copy its complete value for +delivery. `Context::edit_event_handler` is the non-invalidating counterpart for +apps that update retained text immediately and defer expensive derived work. +The learning site's live Interaction API example shows the delivered byte range +and replacement length after every edit. [Astra Editor](../../app_examples/astra-editor/README.md) +uses the same path for its code buffer and includes reproducible +incremental-delivery and native latency profiles. + +## Intentionally action-only or presentational + +| Widget | Reason | +| --- | --- | +| RangeBehavior and typed behavior/action types | They are the runtime-neutral advanced layer used to build Slider and custom controls. | +| DataTable editing, sorting, filtering, resizing | These operations mutate a typed table model and can carry validation/focus effects; `DataTableAction` is clearer than flattening them into unrelated callbacks. | +| Complex multi-selection reducers | Modifier/range selection needs the complete action and prior controlled state; List/Table behavior APIs remain available alongside simple direct selection. | +| FilePicker | Opening the OS picker is an asynchronous platform capability with cancellation/error results, not a synchronous element value. | +| Tooltip | Hover/focus delays and hoverable-content transitions are temporary interaction state; `TooltipBehavior` remains the explicit controller. | +| Label | Its click is a focus request for another key, handled through `Label::focus_target` and `Context::request_focus`. | +| ScrollArea | Wheel, gesture, keyboard, scrollbar, and programmatic requests are scroll-engine operations rather than business-data callbacks. | +| Attachment | File operations are application-defined child actions; the widget only presents controlled metadata and progress. | +| Alert, AnimatedText, AspectRatio, Avatar, Badge, Bubble, ButtonGroup, Card, Direction, Empty, Field, InputGroup, Item, Kbd, Marker, Message, Progress, Separator, Skeleton, Spinner, TextSelection, Typography | These widgets are presentational or compose interactive children. They intentionally do not invent a business action. | +| Icons and theme/assets helpers | These produce presentation resources, not controls. | + +## Testing pattern + +Prefer an accessible query and assert the controlled result: + +```rust,ignore +let mut app = TestApp::new(Settings::default()); +app.get_by_role(Role::CheckBox, "Email alerts").click()?; +app.assert_state("email-alerts", SemanticMatcher::Checked); +# Ok::<(), argui_testing::TestError>(()) +``` + +For advanced reducers, keep a focused behavior test for every action and add one +application test proving that layout, input routing, focus, and model +reconciliation work together. See [lists and tables](lists-tables.md), +[overlays](overlays.md), and [interaction](../ui/interaction.md). diff --git a/docs/widgets/overlays.md b/docs/widgets/overlays.md index 48443e5e..c4bf6ad4 100644 --- a/docs/widgets/overlays.md +++ b/docs/widgets/overlays.md @@ -57,7 +57,7 @@ including default shadows. Custom WGSL effects use the same filter API; see ## Feature selection ```toml -argui = { version = "0.2.1", features = [ +argui = { version = "0.3.0", features = [ "widget-popover", "widget-tooltip", ] } diff --git a/docs/widgets/shadcn.md b/docs/widgets/shadcn.md index 21040fdc..d932ab4c 100644 --- a/docs/widgets/shadcn.md +++ b/docs/widgets/shadcn.md @@ -81,10 +81,15 @@ updater dialog is a separate opt-in. ## Integration -Widgets are controlled. Build from model state, forward events to the widget's -behavior method, apply the returned action, then notify the owning model. Focus -requests go through the runtime. Network transfers, persistence, validation, and -navigation remain application responsibilities. +Widgets are controlled. For local reactions, attach the widget's typed direct +handler and write the emitted value into model state. For multi-effect reducers, +custom controls, and intentional delegation, forward events to the existing +behavior/action API. Focus requests go through the runtime. Network transfers, +persistence, validation, and navigation remain application responsibilities. + +The complete direct-handler payloads, keyboard/accessibility behavior, harness +pattern, and explicit reasons for action-only widgets are documented in the +[interaction API inventory](interaction-api.md). Direction inherits through layout. Pass the same RTL value to collection controllers whose arrow-key behavior depends on direction. diff --git a/mobile/android/README.md b/mobile/android/README.md index ef50b3b5..b4c784b5 100644 --- a/mobile/android/README.md +++ b/mobile/android/README.md @@ -2,9 +2,38 @@ This project packages the existing Rust Widget Gallery as a native Android app. Android's built-in `NativeActivity` starts Winit and Argui; the app contains no -Java or Kotlin UI. The Rust shared library is built from the workspace and +Java or Kotlin UI. A small Java service provides the optional ongoing background +activity notification. The Rust shared library is built from the workspace and included in the APK or AAB by Gradle. +## Background activity UI + +Android represents long-running, user-visible work with a foreground service +and an ongoing notification. The shared Rust API owns the activity title, +status, progress, lifetime, and worker-facing update handle. The Java adapter +under `crates/argui-android/android/src/main/java/dev/argui/android` maps that +state to Android's notification template and service lifecycle. + +This is deliberately separate from the iOS SwiftUI layout: Android and iOS do +not share a native view system, but both consume the same semantic state. The +Android shell must package the Java adapter and declare its service, +foreground-service permissions, notification permission, and monochrome small +icon. Applications that never call `MobileActivity::begin` may omit all of +those optional pieces. + +The shell renders edge to edge. Its status and navigation bars are transparent, +the current theme background is painted underneath them, and Android +`WindowInsets` keep interactive content outside system icons, gesture handles, +and display cutouts. An overlay that reaches a system edge should paint its own +safe-area padding instead of exposing the renderer clear color. + +To test the reference integration, open **Background activity** in the mobile +gallery and start the demo. Android 13 or newer asks for notification +permission on first use. Accept it, leave the app, and verify that the progress +notification remains visible while the foreground service is active. A +foreground service is not permission for unlimited background execution; use +the Android service type and workload limits appropriate to the real task. + ## Install the build tools Install Android Studio or the Android command-line tools and a JDK 17 or newer, @@ -52,11 +81,14 @@ Launch the installed gallery from the device, or use: ```sh adb install -r mobile/android/app/build/outputs/apk/debug/app-debug.apk -adb shell monkey -p dev.argui.widgetgallery.debug 1 +adb shell am start -W -a android.intent.action.MAIN \ + -c android.intent.category.LAUNCHER \ + -n dev.argui.widgetgallery.debug/android.app.NativeActivity adb logcat -s RustStdoutStderr ``` -The first command is also available as `./scripts/android-gallery.sh launch`. +The `am start` launch command is also available as +`./scripts/android-gallery.sh launch`. ## Build a release AAB diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index b173d566..015c05a9 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -25,6 +25,12 @@ android { compileSdk = 36 ndkVersion = '28.2.13676358' + sourceSets { + main { + java.srcDir(new File(repositoryRoot, 'crates/argui-android/android/src/main/java')) + } + } + defaultConfig { applicationId = 'dev.argui.widgetgallery' minSdk = 26 @@ -52,6 +58,7 @@ def registerRustBuild = { String variant, boolean optimized -> inputs.file(cargoManifest) inputs.file(new File(repositoryRoot, 'Cargo.lock')) inputs.dir(new File(repositoryRoot, 'crates')) + inputs.dir(new File(repositoryRoot, 'crates/argui-android/android')) inputs.dir(new File(repositoryRoot, '.cargo')) inputs.property('androidAbis', requestedAbis) inputs.property('androidApiLevel', 26) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 76b8a9ca..9f86e9ca 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -2,11 +2,16 @@ + + + + android:roundIcon="@mipmap/ic_launcher_round" + android:theme="@style/ArguiTheme"> + diff --git a/mobile/android/app/src/main/res/drawable/ic_notification_activity.xml b/mobile/android/app/src/main/res/drawable/ic_notification_activity.xml new file mode 100644 index 00000000..066e4b56 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_notification_activity.xml @@ -0,0 +1,10 @@ + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..4681dce5 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..4681dce5 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..c1ee826b Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..3c73fcc6 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..c1ee826b Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..7b6568e3 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..f2214793 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..7b6568e3 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..8c580eb8 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..f7672d50 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..8c580eb8 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d4f6a97a Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..36d0a06f Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..d4f6a97a Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..509b3002 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..30c1cedf Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..509b3002 Binary files /dev/null and b/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/mobile/android/app/src/main/res/values/colors.xml b/mobile/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..93cb258d --- /dev/null +++ b/mobile/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #000000 + diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..29dc146f --- /dev/null +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,15 @@ + + + + diff --git a/mobile/ios/ActivityExtension-Info.plist b/mobile/ios/ActivityExtension-Info.plist new file mode 100644 index 00000000..47c836ca --- /dev/null +++ b/mobile/ios/ActivityExtension-Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 0.2.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/mobile/ios/ArguiWidgetGallery.xcodeproj/project.pbxproj b/mobile/ios/ArguiWidgetGallery.xcodeproj/project.pbxproj index 226ede15..fb3718c6 100644 --- a/mobile/ios/ArguiWidgetGallery.xcodeproj/project.pbxproj +++ b/mobile/ios/ArguiWidgetGallery.xcodeproj/project.pbxproj @@ -8,6 +8,12 @@ /* Begin PBXBuildFile section */ A10000000000000000000001 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* main.m */; }; A10000000000000000000002 /* ArguiWidgetGallery.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* ArguiWidgetGallery.xcframework */; }; + A10000000000000000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* Assets.xcassets */; }; + A10000000000000000000004 /* ArguiActivityBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* ArguiActivityBridge.swift */; }; + A10000000000000000000005 /* ArguiActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* ArguiActivityAttributes.swift */; }; + A10000000000000000000006 /* ArguiActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* ArguiActivityWidget.swift */; }; + A10000000000000000000007 /* ArguiActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* ArguiActivityAttributes.swift */; }; + A10000000000000000000008 /* ArguiActivityWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* ArguiActivityWidgetExtension.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -15,6 +21,12 @@ A20000000000000000000002 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A20000000000000000000003 /* ArguiWidgetGallery.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = build/ArguiWidgetGallery.xcframework; sourceTree = ""; }; A20000000000000000000004 /* ArguiWidgetGallery.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ArguiWidgetGallery.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A20000000000000000000005 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A20000000000000000000006 /* ArguiActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../../crates/argui-ios/swift/activity-kit/app/ArguiActivityBridge.swift; sourceTree = SOURCE_ROOT; }; + A20000000000000000000007 /* ArguiActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../../crates/argui-ios/swift/activity-kit/shared/ArguiActivityAttributes.swift; sourceTree = SOURCE_ROOT; }; + A20000000000000000000008 /* ArguiActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../../crates/argui-ios/swift/activity-kit/extension/ArguiActivityWidget.swift; sourceTree = SOURCE_ROOT; }; + A20000000000000000000011 /* ActivityExtension-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = ActivityExtension-Info.plist; sourceTree = ""; }; + A20000000000000000000010 /* ArguiActivityWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ArguiActivityWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +38,12 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A30000000000000000000004 /* ArguiActivityWidgetExtension Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = (); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,7 +51,10 @@ isa = PBXGroup; children = ( A40000000000000000000002 /* Sources */, + A40000000000000000000004 /* Activity Extension Sources */, + A20000000000000000000011 /* ActivityExtension-Info.plist */, A20000000000000000000002 /* Info.plist */, + A20000000000000000000005 /* Assets.xcassets */, A20000000000000000000003 /* ArguiWidgetGallery.xcframework */, A40000000000000000000003 /* Products */, ); @@ -41,13 +62,26 @@ }; A40000000000000000000002 /* Sources */ = { isa = PBXGroup; - children = (A20000000000000000000001 /* main.m */, ); + children = ( + A20000000000000000000001 /* main.m */, + A20000000000000000000006 /* ArguiActivityBridge.swift */, + A20000000000000000000007 /* ArguiActivityAttributes.swift */, + ); path = Sources; sourceTree = ""; }; + A40000000000000000000004 /* Activity Extension Sources */ = { + isa = PBXGroup; + children = (A20000000000000000000008 /* ArguiActivityWidget.swift */, ); + name = "Activity Extension Sources"; + sourceTree = ""; + }; A40000000000000000000003 /* Products */ = { isa = PBXGroup; - children = (A20000000000000000000004 /* ArguiWidgetGallery.app */, ); + children = ( + A20000000000000000000004 /* ArguiWidgetGallery.app */, + A20000000000000000000010 /* ArguiActivityWidgetExtension.appex */, + ); name = Products; sourceTree = ""; }; @@ -61,14 +95,29 @@ A30000000000000000000002 /* Sources */, A30000000000000000000001 /* Frameworks */, A30000000000000000000003 /* Resources */, + A30000000000000000000006 /* Embed App Extensions */, ); buildRules = (); - dependencies = (); + dependencies = (A80000000000000000000002 /* PBXTargetDependency */, ); name = ArguiWidgetGallery; productName = ArguiWidgetGallery; productReference = A20000000000000000000004 /* ArguiWidgetGallery.app */; productType = "com.apple.product-type.application"; }; + A50000000000000000000002 /* ArguiActivityWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = A90000000000000000000003 /* Build configuration list for PBXNativeTarget "ArguiActivityWidgetExtension" */; + buildPhases = ( + A30000000000000000000005 /* ArguiActivityWidgetExtension Sources */, + A30000000000000000000004 /* ArguiActivityWidgetExtension Frameworks */, + ); + buildRules = (); + dependencies = (); + name = ArguiActivityWidgetExtension; + productName = ArguiActivityWidgetExtension; + productReference = A20000000000000000000010 /* ArguiActivityWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -86,15 +135,36 @@ mainGroup = A40000000000000000000001; productRefGroup = A40000000000000000000003 /* Products */; projectDirPath = ""; - targets = (A50000000000000000000001 /* ArguiWidgetGallery */, ); + targets = ( + A50000000000000000000001 /* ArguiWidgetGallery */, + A50000000000000000000002 /* ArguiActivityWidgetExtension */, + ); }; /* End PBXProject section */ +/* Begin PBXContainerItemProxy section */ + A80000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A60000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A50000000000000000000002; + remoteInfo = ArguiActivityWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXTargetDependency section */ + A80000000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A50000000000000000000002 /* ArguiActivityWidgetExtension */; + targetProxy = A80000000000000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXResourcesBuildPhase section */ A30000000000000000000003 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; - files = (); + files = (A10000000000000000000003 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ @@ -103,11 +173,36 @@ A30000000000000000000002 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; - files = (A10000000000000000000001 /* main.m in Sources */, ); + files = ( + A10000000000000000000001 /* main.m in Sources */, + A10000000000000000000004 /* ArguiActivityBridge.swift in Sources */, + A10000000000000000000005 /* ArguiActivityAttributes.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A30000000000000000000005 /* ArguiActivityWidgetExtension Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000006 /* ArguiActivityWidget.swift in Sources */, + A10000000000000000000007 /* ArguiActivityAttributes.swift in Sources */, + ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXCopyFilesBuildPhase section */ + A30000000000000000000006 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = (A10000000000000000000008 /* ArguiActivityWidgetExtension.appex in Embed App Extensions */, ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin XCBuildConfiguration section */ A70000000000000000000001 /* Debug project */ = { isa = XCBuildConfiguration; @@ -132,6 +227,7 @@ A70000000000000000000003 /* Debug target */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; GENERATE_INFOPLIST_FILE = NO; HEADER_SEARCH_PATHS = "$(inherited) $(SRCROOT)/include"; INFOPLIST_FILE = Info.plist; @@ -156,6 +252,8 @@ ); PRODUCT_BUNDLE_IDENTIFIER = dev.argui.widgetgallery; PRODUCT_NAME = ArguiWidgetGallery; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + SWIFT_VERSION = 5.0; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -164,6 +262,7 @@ A70000000000000000000004 /* Release target */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; GENERATE_INFOPLIST_FILE = NO; HEADER_SEARCH_PATHS = "$(inherited) $(SRCROOT)/include"; INFOPLIST_FILE = Info.plist; @@ -188,7 +287,43 @@ ); PRODUCT_BUNDLE_IDENTIFIER = dev.argui.widgetgallery; PRODUCT_NAME = ArguiWidgetGallery; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + SWIFT_VERSION = 5.0; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + A70000000000000000000005 /* Debug activity extension */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ActivityExtension-Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + PRODUCT_BUNDLE_IDENTIFIER = dev.argui.widgetgallery.activitywidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A70000000000000000000006 /* Release activity extension */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ActivityExtension-Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.1; + PRODUCT_BUNDLE_IDENTIFIER = dev.argui.widgetgallery.activitywidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -212,6 +347,14 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A90000000000000000000003 /* Build configuration list for activity extension */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A70000000000000000000005 /* Debug activity extension */, + A70000000000000000000006 /* Release activity extension */,); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = A60000000000000000000001 /* Project object */; diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 00000000..ebdc10d3 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png new file mode 100644 index 00000000..e409b57b Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x-ipad.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x-ipad.png new file mode 100644 index 00000000..42cd6cab Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x-ipad.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png new file mode 100644 index 00000000..42cd6cab Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png new file mode 100644 index 00000000..70e6f94f Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png new file mode 100644 index 00000000..257fd1fc Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x-ipad.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x-ipad.png new file mode 100644 index 00000000..08294053 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x-ipad.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png new file mode 100644 index 00000000..08294053 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png new file mode 100644 index 00000000..ce8e2e00 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png new file mode 100644 index 00000000..42cd6cab Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x-ipad.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x-ipad.png new file mode 100644 index 00000000..f8d3ef55 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x-ipad.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png new file mode 100644 index 00000000..f8d3ef55 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png new file mode 100644 index 00000000..005f0227 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png new file mode 100644 index 00000000..005f0227 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png new file mode 100644 index 00000000..ce57d261 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png new file mode 100644 index 00000000..3bdbaf8d Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png new file mode 100644 index 00000000..469e3f96 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png new file mode 100644 index 00000000..4ee2c335 Binary files /dev/null and b/mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png differ diff --git a/mobile/ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..4b6fe828 --- /dev/null +++ b/mobile/ios/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,26 @@ +{ + "images": [ + { "filename": "AppIcon-20@2x.png", "idiom": "iphone", "scale": "2x", "size": "20x20" }, + { "filename": "AppIcon-20@3x.png", "idiom": "iphone", "scale": "3x", "size": "20x20" }, + { "filename": "AppIcon-29@2x.png", "idiom": "iphone", "scale": "2x", "size": "29x29" }, + { "filename": "AppIcon-29@3x.png", "idiom": "iphone", "scale": "3x", "size": "29x29" }, + { "filename": "AppIcon-40@2x.png", "idiom": "iphone", "scale": "2x", "size": "40x40" }, + { "filename": "AppIcon-40@3x.png", "idiom": "iphone", "scale": "3x", "size": "40x40" }, + { "filename": "AppIcon-60@2x.png", "idiom": "iphone", "scale": "2x", "size": "60x60" }, + { "filename": "AppIcon-60@3x.png", "idiom": "iphone", "scale": "3x", "size": "60x60" }, + { "filename": "AppIcon-20.png", "idiom": "ipad", "scale": "1x", "size": "20x20" }, + { "filename": "AppIcon-20@2x-ipad.png", "idiom": "ipad", "scale": "2x", "size": "20x20" }, + { "filename": "AppIcon-29.png", "idiom": "ipad", "scale": "1x", "size": "29x29" }, + { "filename": "AppIcon-29@2x-ipad.png", "idiom": "ipad", "scale": "2x", "size": "29x29" }, + { "filename": "AppIcon-40.png", "idiom": "ipad", "scale": "1x", "size": "40x40" }, + { "filename": "AppIcon-40@2x-ipad.png", "idiom": "ipad", "scale": "2x", "size": "40x40" }, + { "filename": "AppIcon-76.png", "idiom": "ipad", "scale": "1x", "size": "76x76" }, + { "filename": "AppIcon-76@2x.png", "idiom": "ipad", "scale": "2x", "size": "76x76" }, + { "filename": "AppIcon-83.5@2x.png", "idiom": "ipad", "scale": "2x", "size": "83.5x83.5" }, + { "filename": "AppIcon-1024.png", "idiom": "ios-marketing", "scale": "1x", "size": "1024x1024" } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/mobile/ios/Assets.xcassets/Contents.json b/mobile/ios/Assets.xcassets/Contents.json new file mode 100644 index 00000000..74d6a722 --- /dev/null +++ b/mobile/ios/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/mobile/ios/Info.plist b/mobile/ios/Info.plist index 6c4cc908..c858fd4c 100644 --- a/mobile/ios/Info.plist +++ b/mobile/ios/Info.plist @@ -20,6 +20,8 @@ 1 LSRequiresIPhoneOS + NSSupportsLiveActivities + UIApplicationSupportsIndirectInputEvents UILaunchScreen diff --git a/mobile/ios/README.md b/mobile/ios/README.md index 722ab038..243862e2 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -1,9 +1,65 @@ # iOS Widget Gallery packaging -This target uses the existing Rust `argui-widget-gallery` UI. The only native -source is a short Objective-C `main` that calls the C entry point exported by -`argui-ios`; Winit starts UIKit and Argui draws the application interface. -There is no SwiftUI or UIKit view implementation. +The app's primary interface is the existing Rust `argui-widget-gallery` UI: +Winit starts UIKit and Argui draws the application window. A small Swift +ActivityKit bridge and WidgetKit extension provide the system-owned Live +Activity on iOS 16.1 and later; its attributes, bridge, and extension sources +live in `crates/argui-ios/swift/activity-kit/{shared,app,extension}`. The Xcode +shell references those sources and embeds the extension in the app. + +The gallery's mobile Activity example appears only on mobile. On supported +iOS versions, it starts a Live Activity showing its title, current status, and +progress in the Lock Screen and Dynamic Island. If Live Activities are +unavailable or a shell does not link the Swift bridge, `argui-platform` falls +back to `UIApplication.beginBackgroundTask`; that grants finite extra runtime +for cleanup and does not keep an iOS process alive indefinitely. + +## ActivityKit UI and source ownership + +ActivityKit requires the system surface to be declared with SwiftUI inside a +WidgetKit extension. Argui therefore shares activity **state**, not rendered +pixels, across Android and iOS. Rust owns the title, status message, progress, +lifetime, and worker-facing update handle. The extension turns that state into +the platform-specific Lock Screen and Dynamic Island layouts. + +All Swift sources intentionally stay under one crate-owned tree: + +```text +crates/argui-ios/swift/activity-kit/ +├── shared/ArguiActivityAttributes.swift # data contract used by both targets +├── app/ArguiActivityBridge.swift # C ABI called by Rust +└── extension/ArguiActivityWidget.swift # SwiftUI system surfaces +``` + +`ArguiActivityWidget.swift` supplies four presentations from the same content +state: + +- a branded Lock Screen card with title, live status, progress bar, percentage, + and completion treatment; +- expanded Dynamic Island leading, trailing, center, and bottom regions; +- compact leading/trailing progress; +- a minimal circular progress indicator. + +To customize appearance, edit only the extension view. To add shared data, +first extend `ContentState` in the shared file, then populate it in the app +bridge and consume it in the extension. Keep attributes for values fixed for +the activity lifetime and content state for values that update. Both the app +and extension targets must compile the shared file; only the app target owns +the bridge, and only the extension target owns the `@main` widget. + +Do not attempt to render the Argui/WGPU application tree inside a Live +Activity. iOS owns this constrained surface and expects SwiftUI. Keep business +state and progress calculation in Rust, while keeping native layout, SF +Symbols, accessibility labels, and Dynamic Island composition in SwiftUI. + +The main app renders against the outer iOS surface so backgrounds extend edge +to edge. Safe-area padding keeps controls outside the status bar, Dynamic +Island/notch, and home indicator; paint that wrapper with the active theme so +the system-edge regions remain visually continuous instead of becoming black. + +The checked-in extension is a working reference rather than a requirement for +every Argui app. A shell that omits it still runs the main Rust UI and uses the +finite UIKit background-task fallback, but it cannot show a Live Activity. ## Build artifacts @@ -17,7 +73,8 @@ The command builds three Rust static libraries (arm64 iOS device, arm64 iOS Simulator and x86_64 iOS Simulator), combines the simulator architectures, packages both platform variants into `mobile/ios/build/ArguiWidgetGallery.xcframework`, and builds an unsigned -`ArguiWidgetGallery.app` for the iOS Simulator. The app is at: +`ArguiWidgetGallery.app` for the iOS Simulator, including the ActivityKit +WidgetKit extension. The app is at: ```text mobile/ios/build/DerivedData/Build/Products/Debug-iphonesimulator/ArguiWidgetGallery.app @@ -50,7 +107,15 @@ The Rust target checks can run on Linux without Xcode: ``` They verify the gallery's Rust entry point for device and both simulator -architectures. A Linux cross-check does not create Apple binaries or an app. +architectures, including the Rust-side UIKit fallback and optional ActivityKit +bridge interface. A Linux cross-check does not compile Swift sources or create +Apple binaries; run `simulator` on macOS with Xcode to validate the extension +and build an installable Simulator app. + +After launch, open **Background activity** in the gallery and start the demo. +On a compatible simulator, lock the simulated device to inspect the Lock +Screen view. Dynamic Island layouts require a simulator device that exposes +the island. Live Activities must also be enabled for the app in iOS settings. ## Device builds and TestFlight diff --git a/mobile/ios/Sources/main.m b/mobile/ios/Sources/main.m index 12db8024..ec5e7a78 100644 --- a/mobile/ios/Sources/main.m +++ b/mobile/ios/Sources/main.m @@ -4,6 +4,7 @@ int main(int argc, char *argv[]) { (void)argc; (void)argv; @autoreleasepool { + argui_ios_activity_bridge_anchor(); start_argui_widget_gallery(); } return 0; diff --git a/mobile/ios/include/ArguiWidgetGallery.h b/mobile/ios/include/ArguiWidgetGallery.h index 1ed62e8d..333d3a6a 100644 --- a/mobile/ios/include/ArguiWidgetGallery.h +++ b/mobile/ios/include/ArguiWidgetGallery.h @@ -6,6 +6,7 @@ extern "C" { #endif void start_argui_widget_gallery(void); +void argui_ios_activity_bridge_anchor(void); #ifdef __cplusplus } diff --git a/scripts/android-gallery.sh b/scripts/android-gallery.sh index a470a0d5..4a204bb9 100755 --- a/scripts/android-gallery.sh +++ b/scripts/android-gallery.sh @@ -23,7 +23,11 @@ case "$command_name" in ;; launch) command -v adb >/dev/null || { echo "adb is missing; install Android platform-tools" >&2; exit 1; } - exec adb shell monkey -p dev.argui.widgetgallery.debug 1 "$@" + exec adb shell am start -W \ + -a android.intent.action.MAIN \ + -c android.intent.category.LAUNCHER \ + -n dev.argui.widgetgallery.debug/android.app.NativeActivity \ + "$@" ;; *) echo "unknown command: $command_name" >&2 diff --git a/scripts/check-quality-static.sh b/scripts/check-quality-static.sh new file mode 100755 index 00000000..309b5871 --- /dev/null +++ b/scripts/check-quality-static.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" +command -v rg >/dev/null || { + echo "error: ripgrep is required for source structure checks" >&2 + exit 1 +} + +echo "quality: source structure" +./scripts/check-rust-size.sh +./scripts/check-test-layout.sh +./scripts/check-source-hygiene.sh +echo "quality: formatting" +cargo fmt --all -- --check +echo "quality: native clippy" +cargo clippy --workspace --all-targets --all-features -- -D warnings +echo "quality: wasm check" +cargo check --workspace --all-targets --target wasm32-unknown-unknown +echo "quality: model API compilation contracts" +cargo test -p argui-runtime --all-features --doc model::model_context::ModelContext diff --git a/scripts/coverage-gate.py b/scripts/coverage-gate.py index 6de50691..6101de04 100644 --- a/scripts/coverage-gate.py +++ b/scripts/coverage-gate.py @@ -21,6 +21,8 @@ def only_reexports(crate): text = re.sub(r"//[^\n]*", "", source.read_text()) text = re.sub(r"#!?\[[^\]]*\]", "", text) text = re.sub(r"pub\s+use\s+[^;]+;", "", text) + text = re.sub(r"pub\s+mod\s+[A-Za-z_][A-Za-z0-9_]*\s*\{", "", text) + text = text.replace("}", "") if text.strip(): return False return True diff --git a/scripts/profile-astra-editor.py b/scripts/profile-astra-editor.py new file mode 100644 index 00000000..d254e2ef --- /dev/null +++ b/scripts/profile-astra-editor.py @@ -0,0 +1,518 @@ +#!/usr/bin/env python3 +"""Profile Astra Editor through the repository's private native X11 display.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time + +from PIL import Image +from Xlib import X, XK, display +from Xlib.ext import xtest + + +WINDOW_WIDTH = 1360 +WINDOW_HEIGHT = 900 +TREE_POINT = (115, 80) +EDITOR_POINT = (760, 430) +TREE_REGION = (45, 64, 295, 158) +SIDEBAR_REGION = (0, 0, 350, WINDOW_HEIGHT) +EDITOR_REGION = (300, 70, WINDOW_WIDTH, WINDOW_HEIGHT) + + +def cpu_seconds(process): + """Return user and system CPU seconds consumed by `process`.""" + fields = (process / "stat").read_text().rpartition(") ")[2].split() + return (int(fields[11]) + int(fields[12])) / os.sysconf("SC_CLK_TCK") + + +def memory(process): + """Return proportional and private resident memory for `process` in MiB.""" + fields = {} + for line in (process / "smaps_rollup").read_text().splitlines()[1:]: + key, value, *_ = line.split() + fields[key.rstrip(":")] = int(value) / 1024 + return { + "rss_mib": round(fields["Rss"], 2), + "pss_mib": round(fields["Pss"], 2), + "private_mib": round( + sum( + fields.get(key, 0) + for key in ("Private_Clean", "Private_Dirty", "Private_Hugetlb") + ), + 2, + ), + } + + +def gpu_memory(process): + """Return resident DRM memory for `process` when the kernel exposes it.""" + clients = {} + for entry in (process / "fdinfo").iterdir(): + try: + fields = dict( + line.split(":", 1) + for line in entry.read_text().splitlines() + if ":" in line + ) + except FileNotFoundError: + continue + if "drm-client-id" in fields: + clients[(fields.get("drm-pdev"), fields["drm-client-id"])] = fields + resident = [ + int(value.split()[0]) + for fields in clients.values() + for key, value in fields.items() + if key.startswith("drm-resident-") + ] + return {"gpu_resident_mib": round(sum(resident) / 1024, 2)} if resident else {} + + +class NativeSession: + """Drive and capture one Astra Editor process on the private X server.""" + + def __init__(self, binary, output, app_cpu): + """Create a session for executable `binary`, artifacts `output`, and `app_cpu`.""" + self.binary = binary + self.output = output + self.app_cpu = app_cpu + self.connection = display.Display() + self.root = self.connection.screen().root + self.child = None + self.window = None + + def visible_windows(self): + """Return mapped top-level windows that expose window-manager protocols.""" + return [ + window + for window in self.root.query_tree().children + if window.get_attributes().map_state == X.IsViewable + and window.get_wm_protocols() + ] + + def origin(self): + """Return the root-relative origin of the application window.""" + x = 0 + y = 0 + window = self.window + while window.id != self.root.id: + geometry = window.get_geometry() + x += geometry.x + y += geometry.y + window = window.query_tree().parent + return x, y + + def frame(self): + """Capture the current application window as an RGB image.""" + left, top = self.origin() + geometry = self.window.get_geometry() + shot = self.root.get_image( + left, + top, + geometry.width, + geometry.height, + X.ZPixmap, + 0xFFFFFFFF, + ) + return Image.frombytes( + "RGB", + (geometry.width, geometry.height), + shot.data, + "raw", + "BGRX", + ) + + def capture(self, name): + """Save a non-blank application frame named `name` and return it.""" + image = self.frame() + assert max(high - low for low, high in image.getextrema()) > 100, ( + f"blank native capture: {name}" + ) + image.save(self.output / f"{name}.png") + return image + + def motion(self, x, y): + """Move the private pointer to window-relative coordinates `x`, `y`.""" + left, top = self.origin() + xtest.fake_input(self.connection, X.MotionNotify, x=left + x, y=top + y) + self.connection.sync() + + def button(self, button=1): + """Press and release pointer `button` without inserting an artificial delay.""" + xtest.fake_input(self.connection, X.ButtonPress, button) + xtest.fake_input(self.connection, X.ButtonRelease, button) + + def key(self, name, pressed=True): + """Queue one key transition for X keysym `name`.""" + code = self.connection.keysym_to_keycode(XK.string_to_keysym(name)) + assert code, f"unknown X keysym: {name}" + xtest.fake_input( + self.connection, + X.KeyPress if pressed else X.KeyRelease, + code, + ) + + def tap(self, name): + """Queue one press and release for X keysym `name`.""" + self.key(name, True) + self.key(name, False) + + def shortcut(self, *names): + """Queue a chord containing `names`, then flush it to the X server.""" + for name in names: + self.key(name, True) + for name in reversed(names): + self.key(name, False) + self.connection.sync() + + def region_digest(self, region): + """Return a compact digest of `region` in the current application frame.""" + pixels = self.frame().crop(region).tobytes() + return hashlib.blake2b(pixels, digest_size=12).digest() + + def visual_action(self, region, action, timeout=0.8, stable=0.12): + """Measure CPU and presented-frame changes caused by `action` in `region`.""" + process = Path(f"/proc/{self.child.pid}") + previous = self.region_digest(region) + unique = {previous} + before_cpu = cpu_seconds(process) + started = time.monotonic() + action() + self.connection.sync() + first_change = None + last_change = None + deadline = started + timeout + while time.monotonic() < deadline: + digest = self.region_digest(region) + now = time.monotonic() + if digest != previous: + unique.add(digest) + previous = digest + first_change = first_change or now + last_change = now + if last_change is not None and now - last_change >= stable: + break + time.sleep(0.004) + finished = time.monotonic() + return { + "presented": first_change is not None, + "first_present_ms": ( + round((first_change - started) * 1000, 2) if first_change else None + ), + "last_change_ms": ( + round((last_change - started) * 1000, 2) if last_change else None + ), + "sample_wall_ms": round((finished - started) * 1000, 2), + "distinct_frames": len(unique) - 1, + "app_cpu_ms": round((cpu_seconds(process) - before_cpu) * 1000, 2), + } + + def start(self): + """Launch Astra Editor and return mapping and first-frame latency metrics.""" + log = (self.output / "runtime.log").open("w") + self.log = log + launched = time.monotonic() + command = [self.binary] + if self.app_cpu is not None: + command = ["taskset", "-c", str(self.app_cpu), self.binary] + self.child = subprocess.Popen(command, stdout=log, stderr=log) + deadline = launched + 20 + while time.monotonic() < deadline: + self.window = next( + ( + window + for window in self.visible_windows() + if (window.get_wm_name() or "").startswith("Astra Editor") + ), + None, + ) + if self.window is not None: + break + time.sleep(0.01) + assert self.window is not None, "Astra Editor native window did not open" + mapped = time.monotonic() + self.window.configure(width=WINDOW_WIDTH, height=WINDOW_HEIGHT) + self.window.set_input_focus(X.RevertToParent, X.CurrentTime) + self.connection.sync() + deadline = launched + 20 + while time.monotonic() < deadline: + image = self.frame() + if max(high - low for low, high in image.getextrema()) > 100: + ready = time.monotonic() + break + time.sleep(0.01) + else: + raise AssertionError("Astra Editor never presented a non-blank frame") + return { + "window_mapped_ms": round((mapped - launched) * 1000, 2), + "first_frame_ms": round((ready - launched) * 1000, 2), + } + + def stop(self): + """Terminate the application and close the private display connection.""" + if self.child is not None: + self.child.terminate() + try: + self.child.wait(timeout=5) + except subprocess.TimeoutExpired: + self.child.kill() + self.child.wait(timeout=5) + self.connection.close() + if hasattr(self, "log"): + self.log.close() + + +def check_private_display(): + """Reject execution outside the repository-owned hidden X11 server.""" + assert os.environ.get("ARGUI_HIDDEN_DISPLAY") == "1" + assert not os.environ.get("WAYLAND_DISPLAY") + server_pid = int(os.environ["ARGUI_HIDDEN_X11_PID"]) + server_env = Path(f"/proc/{server_pid}/environ").read_bytes().split(b"\0") + assert f"XDG_RUNTIME_DIR={os.environ['XDG_RUNTIME_DIR']}".encode() in server_env + assert b"WAYLAND_DISPLAY=argui-test" in server_env + + +def idle_sample(session, seconds): + """Measure CPU and memory while `session` receives no input for `seconds`.""" + process = Path(f"/proc/{session.child.pid}") + before = cpu_seconds(process) + started = time.monotonic() + time.sleep(seconds) + elapsed = time.monotonic() - started + return { + "seconds": round(elapsed, 2), + "cpu_percent_one_core": round( + 100 * (cpu_seconds(process) - before) / elapsed, + 2, + ), + **memory(process), + **gpu_memory(process), + } + + +def profile(session, args): + """Run the complete native scenario and return its measurements.""" + result = { + "environment": { + "window": f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}", + "display": os.environ.get("DISPLAY"), + "sampler_cpu_affinity": sorted(os.sched_getaffinity(0)), + "app_cpu": session.app_cpu, + }, + "launch": session.start(), + } + time.sleep(args.warmup_seconds) + session.capture("initial") + result["idle"] = idle_sample(session, args.idle_seconds) + + session.motion(*TREE_POINT) + time.sleep(0.1) + result["folder_close"] = session.visual_action( + TREE_REGION, + session.button, + timeout=args.animation_timeout, + ) + session.capture("folder-closed") + session.motion(*TREE_POINT) + time.sleep(0.1) + result["folder_open"] = session.visual_action( + TREE_REGION, + session.button, + timeout=args.animation_timeout, + ) + settled_tree = session.region_digest(TREE_REGION) + time.sleep(0.25) + result["folder_open"]["final_matches_steady_state"] = ( + session.region_digest(TREE_REGION) == settled_tree + ) + session.capture("folder-opened") + + result["explorer_hide"] = session.visual_action( + SIDEBAR_REGION, + lambda: session.shortcut("Control_L", "b"), + ) + result["explorer_show"] = session.visual_action( + SIDEBAR_REGION, + lambda: session.shortcut("Control_L", "b"), + ) + session.capture("explorer-restored") + + session.motion(*EDITOR_POINT) + session.button() + session.connection.sync() + time.sleep(0.12) + session.shortcut("Control_L", "End") + time.sleep(0.08) + + def prepare_large_document(): + for _ in range(args.prepare_lines): + session.tap("a") + session.tap("Return") + session.shortcut("Control_L", "b") + + result["document_preparation"] = session.visual_action( + SIDEBAR_REGION, + prepare_large_document, + timeout=args.queue_timeout, + ) + result["document_preparation"]["inserted_lines"] = args.prepare_lines + session.visual_action( + SIDEBAR_REGION, + lambda: session.shortcut("Control_L", "b"), + ) + session.shortcut("Control_L", "Home") + time.sleep(0.6) + + session.motion(*EDITOR_POINT) + session.button() + session.connection.sync() + time.sleep(0.08) + result["single_character"] = session.visual_action( + EDITOR_REGION, + lambda: session.tap("z"), + timeout=0.3, + stable=0.08, + ) + + def typing_burst(): + for _ in range(args.typing_events): + session.tap("a") + session.shortcut("Control_L", "b") + + result["typing_burst"] = session.visual_action( + SIDEBAR_REGION, + typing_burst, + timeout=args.queue_timeout, + ) + result["typing_burst"]["characters"] = args.typing_events + first_present = result["typing_burst"]["first_present_ms"] + result["typing_burst"]["characters_per_second"] = ( + round(args.typing_events * 1000 / first_present, 1) if first_present else None + ) + session.visual_action( + SIDEBAR_REGION, + lambda: session.shortcut("Control_L", "b"), + ) + time.sleep(0.3) + + session.motion(*EDITOR_POINT) + session.button() + session.connection.sync() + session.shortcut("Control_L", "Home") + time.sleep(0.12) + + def scroll_down(): + for _ in range(args.scroll_events): + session.button(5) + + result["scroll"] = session.visual_action( + EDITOR_REGION, + scroll_down, + timeout=0.3, + stable=0.08, + ) + result["scroll"]["wheel_events"] = args.scroll_events + session.capture("after-scroll") + process = Path(f"/proc/{session.child.pid}") + result["after_stress"] = {**memory(process), **gpu_memory(process)} + return result + + +def budget_results(result): + """Return conservative interactive-performance checks for `result`.""" + checks = { + "first frame <= 1500 ms": result["launch"]["first_frame_ms"] <= 1500, + "idle CPU <= 1% of one core": result["idle"]["cpu_percent_one_core"] <= 1, + "folder animation responds <= 100 ms": ( + result["folder_open"]["first_present_ms"] is not None + and result["folder_open"]["first_present_ms"] <= 100 + ), + "folder animation has intermediate frames": ( + result["folder_open"]["distinct_frames"] >= 2 + ), + "folder animation settles <= 400 ms": ( + result["folder_open"]["last_change_ms"] is not None + and result["folder_open"]["last_change_ms"] <= 400 + ), + "folder animation reaches its steady visual": result["folder_open"][ + "final_matches_steady_state" + ], + "Ctrl+B responds <= 100 ms": ( + result["explorer_hide"]["first_present_ms"] is not None + and result["explorer_hide"]["first_present_ms"] <= 100 + ), + "Ctrl+B restore responds <= 100 ms": ( + result["explorer_show"]["first_present_ms"] is not None + and result["explorer_show"]["first_present_ms"] <= 100 + ), + "single character presents <= 100 ms": ( + result["single_character"]["first_present_ms"] is not None + and result["single_character"]["first_present_ms"] <= 100 + ), + "typing burst drains <= 1000 ms": ( + result["typing_burst"]["first_present_ms"] is not None + and result["typing_burst"]["first_present_ms"] <= 1000 + ), + "large document scroll presents <= 100 ms": ( + result["scroll"]["first_present_ms"] is not None + and result["scroll"]["first_present_ms"] <= 100 + ), + } + return {"passed": all(checks.values()), "checks": checks} + + +def main(): + """Parse options, execute the profile, save JSON, and optionally enforce budgets.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", required=True) + parser.add_argument("--output", default="target/astra-editor-profile") + parser.add_argument("--warmup-seconds", type=float, default=3.0) + parser.add_argument("--idle-seconds", type=float, default=3.0) + parser.add_argument("--prepare-lines", type=int, default=300) + parser.add_argument("--typing-events", type=int, default=200) + parser.add_argument("--scroll-events", type=int, default=24) + parser.add_argument("--animation-timeout", type=float, default=0.8) + parser.add_argument("--queue-timeout", type=float, default=3.0) + parser.add_argument("--sampler-cpu", type=int) + parser.add_argument("--app-cpu", type=int) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + if ( + args.warmup_seconds <= 0 + or args.idle_seconds <= 0 + or args.animation_timeout <= 0 + or args.queue_timeout <= 0 + ): + parser.error("durations must be positive") + if args.prepare_lines <= 0 or args.typing_events <= 0 or args.scroll_events <= 0: + parser.error("event counts must be positive") + binary = Path(args.binary).resolve() + if not binary.is_file(): + parser.error(f"binary does not exist: {binary}") + check_private_display() + allowed_cpus = os.sched_getaffinity(0) + for name, cpu in (("sampler", args.sampler_cpu), ("app", args.app_cpu)): + if cpu is not None and cpu not in allowed_cpus: + parser.error(f"{name} CPU {cpu} is outside the allowed affinity") + if args.sampler_cpu is not None: + os.sched_setaffinity(0, {args.sampler_cpu}) + output = Path(args.output) + output.mkdir(parents=True, exist_ok=True) + session = NativeSession(str(binary), output, args.app_cpu) + try: + result = profile(session, args) + result["budgets"] = budget_results(result) + payload = json.dumps(result, indent=2) + (output / "profile.json").write_text(payload + "\n") + print(payload, flush=True) + if args.check and not result["budgets"]["passed"]: + raise SystemExit(1) + finally: + session.stop() + + +if __name__ == "__main__": + main() diff --git a/scripts/quality.sh b/scripts/quality.sh index 43dff6c1..9191cb2e 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -3,22 +3,6 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repo_root" -command -v rg >/dev/null || { - echo "error: ripgrep is required for source structure checks" >&2 - exit 1 -} - -echo "quality: source structure" -./scripts/check-rust-size.sh -./scripts/check-test-layout.sh -./scripts/check-source-hygiene.sh -echo "quality: formatting" -cargo fmt --all -- --check -echo "quality: native clippy" -cargo clippy --workspace --all-targets --all-features -- -D warnings -echo "quality: wasm check" -cargo check --workspace --all-targets --target wasm32-unknown-unknown -echo "quality: model API compilation contracts" -cargo test -p argui-runtime --all-features --doc model::model_context::ModelContext +./scripts/check-quality-static.sh echo "quality: instrumented library coverage" ./scripts/check-coverage.sh diff --git a/scripts/release.py b/scripts/release.py index 311e887d..856f1c96 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -10,6 +10,7 @@ import shutil import subprocess import sys +import tarfile import tempfile import time import tomllib @@ -111,13 +112,56 @@ def package_archives(): for name in names: clean.extend(['--package', name]) run(*clean) - args = ['cargo', 'package', '--locked', '--all-features', '--allow-dirty'] - for name in names: - args.extend(['--package', name]) - run(*args) + selection = [argument for name in names for argument in ('--package', name)] + # Package the complete publishable graph in one Cargo transaction so + # unpublished workspace versions resolve to one another instead of crates.io. + run('cargo', 'package', '--locked', '--all-features', '--allow-dirty', + '--no-verify', *selection) + verify_staged_archives(current, names) print(f'Verified {len(names)} crates.io archives at version {current}') +def shared_target_directory(): + """Return Cargo's configured target directory for this checkout.""" + metadata = json.loads(run('cargo', 'metadata', '--locked', '--no-deps', + '--format-version', '1', capture=True)) + return Path(metadata['target_directory']) + + +def verify_staged_archives(current, names): + """Compile packaged sources together against only the staged archives.""" + target = shared_target_directory() + with tempfile.TemporaryDirectory(prefix='argui-package-') as directory: + staged = Path(directory) + config = staged / '.cargo' / 'config.toml' + config.parent.mkdir() + patches = [] + members = [] + for name in names: + archive = target / 'package' / f'{name}-{current}.crate' + if not archive.is_file(): + raise ValueError(f'{name}: missing staged archive {archive}') + with tarfile.open(archive, mode='r:gz') as package: + package.extractall(staged, filter='data') + source = staged / f'{name}-{current}' + if not (source / 'Cargo.toml').is_file(): + raise ValueError(f'{name}: archive has no normalized Cargo.toml') + patches.append(f'{name} = {{ path = {json.dumps(str(source))} }}') + members.append(json.dumps(source.name)) + config.write_text('[patch.crates-io]\n' + '\n'.join(patches) + '\n') + (staged / 'Cargo.toml').write_text( + '[workspace]\nresolver = "2"\nmembers = [' + ', '.join(members) + ']\n' + ) + environment = os.environ.copy() + environment['CARGO_TARGET_DIR'] = str(target) + subprocess.run( + ['cargo', 'check', '--offline', '--workspace', '--all-features'], + cwd=staged, + env=environment, + check=True, + ) + + def registry_versions(name): headers = { 'User-Agent': 'Argui release automation (https://github.com/ExtraBinoss/argui)', diff --git a/tests/scripts/test_coverage_gate.py b/tests/scripts/test_coverage_gate.py index 0bf1dfc8..43d6ff30 100644 --- a/tests/scripts/test_coverage_gate.py +++ b/tests/scripts/test_coverage_gate.py @@ -7,6 +7,7 @@ from contextlib import redirect_stdout, redirect_stderr from io import StringIO import json +import os spec = importlib.util.spec_from_file_location( "coverage_gate", Path(__file__).resolve().parents[2] / "scripts/coverage-gate.py" @@ -28,6 +29,26 @@ def entry(branches, count): class SourceCoverage(unittest.TestCase): + def test_nested_prelude_with_only_reexports_has_no_coverable_code(self): + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "crates" / "facade" / "src" + source.mkdir(parents=True) + (source / "lib.rs").write_text( + "//! Facade.\n" + "pub mod prelude {\n" + " #[cfg(feature = \"widgets\")]\n" + " pub use crate::widgets::{self, Button};\n" + "}\n" + "pub use dependency as widgets;\n" + ) + previous = Path.cwd() + try: + os.chdir(root) + self.assertTrue(gate.only_reexports("facade")) + finally: + os.chdir(previous) + def test_threshold_is_enforced_for_workspace_and_every_crate(self): for false_count, failed in [(0, True), (1, False)]: file = entry([[10, 4, 10, 20, 1, false_count]], 2) diff --git a/tests/scripts/test_release.py b/tests/scripts/test_release.py index 7b89af56..e0cb3d75 100644 --- a/tests/scripts/test_release.py +++ b/tests/scripts/test_release.py @@ -212,7 +212,8 @@ def test_package_verifies_every_archive_in_publication_order(self): (package_directory / 'stale-registry').write_text('old archive') metadata = json.dumps({'target_directory': directory}) with patch.object(release, 'workspace', return_value=('0.1.0', names)), \ - patch.object(release, 'run', side_effect=[metadata, None, None]) as run: + patch.object(release, 'run', side_effect=[metadata, None, None]) as run, \ + patch.object(release, 'verify_staged_archives') as verify: release.package_archives() self.assertFalse(package_directory.exists()) self.assertEqual(run.call_args_list[1].args, ( @@ -221,8 +222,10 @@ def test_package_verifies_every_archive_in_publication_order(self): )) self.assertEqual(run.call_args_list[2].args, ( 'cargo', 'package', '--locked', '--all-features', '--allow-dirty', + '--no-verify', '--package', 'argui-core', '--package', 'argui-render', '--package', 'argui', )) + verify.assert_called_once_with('0.1.0', names) def test_unchanged_version_never_runs_a_publisher(self): with patch.object(release, 'run') as run: diff --git a/web/browser-shortcuts.js b/web/browser-shortcuts.js index ec077528..76e689ee 100644 --- a/web/browser-shortcuts.js +++ b/web/browser-shortcuts.js @@ -1,10 +1,14 @@ // Let browser reload commands bypass the canvas event handlers without cancelling // their native default action. Ordinary widget keyboard input is unaffected. -window.addEventListener("keydown", (event) => { - const target = event.target; - if (!(target instanceof Element) || - !target.closest("canvas, [data-argui-accessibility]")) return; - const reload = event.key === "F5" || - ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "r"); - if (reload && !event.altKey) event.stopPropagation(); -}, { capture: true }); +window.addEventListener( + 'keydown', + (event) => { + const target = event.target + if (!(target instanceof Element) || !target.closest('canvas, [data-argui-accessibility]')) + return + const reload = + event.key === 'F5' || ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'r') + if (reload && !event.altKey) event.stopPropagation() + }, + { capture: true }, +) diff --git a/web/examples/astra-editor/index.html b/web/examples/astra-editor/index.html new file mode 100644 index 00000000..7f11f00e --- /dev/null +++ b/web/examples/astra-editor/index.html @@ -0,0 +1,67 @@ + + + + + + + Astra Editor · Argui + + + + +
Opening Astra Editor…
+ + + diff --git a/website/app/assets/css/docs.css b/website/app/assets/css/docs.css index 08767436..37e3dff1 100644 --- a/website/app/assets/css/docs.css +++ b/website/app/assets/css/docs.css @@ -403,6 +403,49 @@ .docs-section > .code-block { margin-top: 22px; } +.docs-table-wrap { + margin-top: 22px; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 6px; +} +.docs-table-wrap table { + width: 100%; + min-width: 680px; + border-collapse: collapse; + font-size: 13px; + line-height: 1.45; +} +.docs-table-wrap th, +.docs-table-wrap td { + padding: 12px 14px; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} +.docs-table-wrap th:last-child, +.docs-table-wrap td:last-child { + border-right: 0; +} +.docs-table-wrap tbody tr:last-child td { + border-bottom: 0; +} +.docs-table-wrap th { + background: var(--surface); + color: var(--ink); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.docs-table-wrap td { + color: var(--muted); +} +.docs-table-wrap td:first-child { + color: var(--ink); + font-weight: 550; +} .docs-section pre { max-height: 520px; font-size: 12px; diff --git a/website/app/components/DocsExampleFrame.vue b/website/app/components/DocsExampleFrame.vue index 3a558e46..b0c34452 100644 --- a/website/app/components/DocsExampleFrame.vue +++ b/website/app/components/DocsExampleFrame.vue @@ -85,7 +85,6 @@ watch(() => props.example, launch) ref="frame" :src="url" :title="title" - :style="{ visibility: state === 'ready' ? 'visible' : 'hidden' }" @error="state = 'error'" />
{ font-size: 11px; font-weight: 600; line-height: 1.35; + pointer-events: none; text-align: center; transform: translateX(-50%); animation: onboarding-hint-pulse 2.4s ease-in-out infinite; diff --git a/website/app/data/catalogue.json b/website/app/data/catalogue.json index f10c667b..1ac89e83 100644 --- a/website/app/data/catalogue.json +++ b/website/app/data/catalogue.json @@ -80,6 +80,15 @@ "category": "Components", "gallery": "Avatar" }, + { + "slug": "mobile-activity", + "name": "Background activity", + "description": "Continue a cancellable task in the background with native progress where available.", + "source": "crates/argui-widget-gallery/src/pages/mobile_activity.rs", + "feature": null, + "category": "Examples", + "gallery": "Background activity" + }, { "slug": "badge", "name": "Badge", @@ -260,6 +269,15 @@ "category": "Components", "gallery": "Direction" }, + { + "slug": "drag-drop", + "name": "Drag & Drop", + "description": "Reorder image cards live with pointer capture, velocity deformation and momentum.", + "source": "crates/argui-widget-gallery/src/pages/drag_drop.rs", + "feature": null, + "category": "Examples", + "gallery": "Drag & Drop" + }, { "slug": "drawer", "name": "Drawer", @@ -613,12 +631,12 @@ }, { "slug": "split-pane", - "name": "Split pane", - "description": "Resizable panels with limits, pointer gestures and keyboard control.", - "source": "crates/argui-widgets/src/split_pane.rs", + "name": "Split Pane", + "description": "Resize horizontal, vertical, trailing and nested application panes.", + "source": "crates/argui-widget-gallery/src/pages/split_pane.rs", "feature": "split-pane", "category": "Components", - "gallery": null + "gallery": "Split Pane" }, { "slug": "switch", diff --git a/website/app/data/doc-example-sources.generated.ts b/website/app/data/doc-example-sources.generated.ts index 934c98f8..ebab080e 100644 --- a/website/app/data/doc-example-sources.generated.ts +++ b/website/app/data/doc-example-sources.generated.ts @@ -1,15 +1,15 @@ // Generated by scripts/docs-examples.mjs from the compiled Rust modules. export const docExampleSources = { accessibility: - 'use argui::{\n accessibility::{LiveRegion, Role, Semantics},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, EventType, Sides, UiEventKind, percent},\n widgets::{Button, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n announcements: u32,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let status = format!("{} accessible activations", self.announcements);\n Element::column([\n Element::text("Accessible by construction")\n .text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n })\n .semantics(Semantics::new(Role::Heading).level(1)),\n Button::new("announce", "Announce update", theme.button()).build(),\n Element::text(status)\n .text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n })\n .semantics(Semantics::new(Role::Status).live(LiveRegion::Polite)),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(16.0)\n .background(theme.background)\n .on(cx.listener(EventType::Click, |app, event, cx| {\n if event.target_key() == Some("announce") && matches!(event.kind, UiEventKind::Click(_))\n {\n app.announcements = app.announcements.saturating_add(1);\n cx.notify();\n }\n }))\n }\n}\n', + 'use argui::{\n accessibility::{LiveRegion, Role, Semantics},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, Sides, percent},\n widgets::{Button, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n announcements: u32,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let status = format!("{} accessible activations", self.announcements);\n Element::column([\n Element::text("Accessible by construction")\n .text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n })\n .semantics(Semantics::new(Role::Heading).level(1)),\n Button::new("announce", "Announce update", theme.button())\n .on_click(cx.callback(|app| {\n app.announcements = app.announcements.saturating_add(1);\n }))\n .build(),\n Element::text(status.clone())\n .text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n })\n .semantics(\n Semantics::new(Role::Status)\n .label(status)\n .live(LiveRegion::Polite),\n ),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(16.0)\n .background(theme.background)\n }\n}\n', animation: - 'use argui::{\n core::{ColorInterpolation, Transform2D, TransformOrigin},\n paint::{Border, CornerRadii, LayerStyle, Shadow, VectorAsset, VectorId},\n runtime::{Context, LayoutSnapshot, Render},\n text::TextStyle,\n ui::{\n AlignItems, Axes, Element, EventType, FlexWrap, JustifyContent, Overflow, ScrollConfig,\n Sides, UiEventKind, length, percent,\n },\n vector::VectorLibrary,\n widgets::{Button, WidgetTheme, default_theme},\n};\n\nconst ORBIT_SVG: &[u8] = br#"\n \n \n"#;\nconst SPARK_SVG: &[u8] = br#"\n \n"#;\n\npub struct Example {\n elapsed: f32,\n running: bool,\n reduced_motion: bool,\n compact: bool,\n vectors: VectorLibrary,\n orbit: VectorId,\n spark: VectorId,\n}\n\nimpl Default for Example {\n fn default() -> Self {\n let mut vectors = VectorLibrary::new();\n let orbit = vectors.insert_svg(ORBIT_SVG).expect("valid orbit SVG");\n let spark = vectors.insert_svg(SPARK_SVG).expect("valid spark SVG");\n Self {\n elapsed: 0.0,\n running: true,\n reduced_motion: false,\n compact: false,\n vectors,\n orbit,\n spark,\n }\n }\n}\n\nimpl Example {\n fn text(\n &self,\n value: impl Into,\n size: f32,\n weight: u16,\n color: argui::core::Color,\n ) -> Element {\n Element::text(value.into()).text_style(TextStyle {\n color,\n font_size: size,\n line_height: size * 1.35,\n weight,\n ..TextStyle::default()\n })\n }\n\n fn stage(&self, art: Element, theme: &WidgetTheme) -> Element {\n Element::container([art])\n .width(percent(1.0))\n .height(length(132.0))\n .padding(Sides::length(16.0))\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::CENTER)\n .background(theme.muted)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(12.0))\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Hidden,\n })\n }\n\n fn card(\n &self,\n title: &\'static str,\n detail: &\'static str,\n art: Element,\n theme: &WidgetTheme,\n ) -> Element {\n Element::column([\n self.text(title, 16.0, 700, theme.foreground),\n self.text(detail, 12.0, 450, theme.muted_foreground),\n self.stage(art, theme),\n ])\n .width(if self.compact {\n percent(1.0)\n } else {\n length(330.0)\n })\n .grow(1.0)\n .padding(Sides::length(16.0))\n .gap(9.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(14.0))\n }\n\n fn cards(&self, theme: &WidgetTheme) -> Element {\n let cycle = if self.reduced_motion {\n 0.35\n } else {\n (self.elapsed / 2.0).fract()\n };\n let angle = cycle * std::f32::consts::TAU;\n let wave = angle.sin() * 0.5 + 0.5;\n let travel = angle.sin() * if self.compact { 52.0 } else { 82.0 };\n let combined = Element::container([])\n .width(length(58.0))\n .height(length(58.0))\n .background(theme.primary)\n .radius(CornerRadii::all(12.0 + wave * 12.0))\n .transform(\n Transform2D::IDENTITY\n .translate(travel, 0.0)\n .rotate(angle)\n .scale(0.78 + wave * 0.42, 0.78 + wave * 0.42),\n )\n .transform_origin(TransformOrigin::CENTER);\n\n let aura = self\n .text("Production ready", 17.0, 700, theme.foreground)\n .padding(Sides::length(18.0))\n .background(theme.card)\n .border(Border::all(1.0 + wave * 2.0, theme.primary))\n .radius(CornerRadii::all(12.0))\n .layer(\n LayerStyle::new(Default::default()).shadow(\n Shadow::glow(\n 10.0 + wave * 24.0,\n theme.primary.with_alpha(0.18 + wave * 0.28),\n )\n .spread(wave * 4.0),\n ),\n );\n\n let morph_color = theme\n .primary\n .mix(theme.destructive, wave, ColorInterpolation::Oklab);\n let morph = Element::container([])\n .width(length(76.0 + wave * 112.0))\n .height(length(58.0 + wave * 28.0))\n .background(morph_color)\n .radius(CornerRadii {\n top_left: 8.0 + wave * 34.0,\n top_right: 38.0 - wave * 28.0,\n bottom_right: 8.0 + wave * 34.0,\n bottom_left: 38.0 - wave * 28.0,\n });\n\n let orbit = Element::vector(self.orbit)\n .vector_color(theme.primary)\n .paint_opacity(1.0 - wave)\n .width(length(72.0))\n .height(length(72.0))\n .transform(\n Transform2D::IDENTITY\n .rotate(angle)\n .scale(1.0 - wave * 0.28, 1.0 - wave * 0.28),\n );\n let spark = Element::vector(self.spark)\n .vector_color(theme.destructive)\n .paint_opacity(wave)\n .width(length(72.0))\n .height(length(72.0))\n .transform(\n Transform2D::IDENTITY\n .rotate(-angle * 0.35)\n .scale(0.72 + wave * 0.28, 0.72 + wave * 0.28),\n );\n let vector_transition = Element::container([\n orbit.absolute(Sides::length(0.0)),\n spark.absolute(Sides::length(0.0)),\n ])\n .width(length(72.0))\n .height(length(72.0));\n\n let loader = Element::row((0..5).map(|index| {\n let local =\n ((cycle + index as f32 * 0.14).fract() * std::f32::consts::TAU).sin() * 0.5 + 0.5;\n Element::container([])\n .width(length(12.0))\n .height(length(28.0 + local * 48.0))\n .background(\n theme\n .primary\n .mix(theme.destructive, local, ColorInterpolation::Oklab),\n )\n .radius(CornerRadii::all(999.0))\n }))\n .height(length(84.0))\n .gap(9.0)\n .align_items(AlignItems::CENTER);\n\n let pulse = 0.82 + wave * 0.18;\n let notification = Element::row([\n Element::container([])\n .width(length(12.0))\n .height(length(12.0))\n .background(theme.primary)\n .radius(CornerRadii::all(999.0))\n .layer(LayerStyle::new(Default::default()).shadow(Shadow::glow(\n 6.0 + wave * 18.0,\n theme.primary.with_alpha(0.34),\n ))),\n self.text("Sync complete", 15.0, 650, theme.foreground),\n ])\n .padding(Sides::length(16.0))\n .gap(10.0)\n .align_items(AlignItems::CENTER)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(999.0))\n .transform(Transform2D::IDENTITY.scale(pulse, pulse));\n\n Element::row([\n self.card(\n "Composed transform",\n "Translate, rotate, scale, and radius run in one two-second loop.",\n combined,\n theme,\n ),\n self.card(\n "Text aura",\n "A practical focus treatment combines border width and GPU glow.",\n aura,\n theme,\n ),\n self.card(\n "Layout morph",\n "Size, asymmetric radii, and Oklab color interpolate together.",\n morph,\n theme,\n ),\n self.card(\n "SVG transition",\n "Two retained vector assets crossfade, rotate, and scale.",\n vector_transition,\n theme,\n ),\n self.card(\n "Sequenced loader",\n "Phase offsets create a continuous multi-element loading rhythm.",\n loader,\n theme,\n ),\n self.card(\n "Status pulse",\n "A restrained scale and aura draw attention without moving layout.",\n notification,\n theme,\n ),\n ])\n .width(percent(1.0))\n .padding(Sides {\n left: length(16.0),\n right: length(16.0),\n top: length(16.0),\n bottom: length(28.0),\n })\n .gap(12.0)\n .flex_wrap(FlexWrap::Wrap)\n }\n}\n\nimpl Render for Example {\n fn wants_animation_frame(&self) -> bool {\n self.running && !self.reduced_motion\n }\n\n fn animation_frame(&mut self, frame: argui::animation::Frame, cx: &mut Context) {\n if self.running && !self.reduced_motion {\n self.elapsed = (self.elapsed + frame.elapsed.as_secs_f64().min(0.05) as f32) % 2.0;\n cx.notify();\n }\n }\n\n fn render(&mut self, cx: &mut Context) -> Element {\n let environment = cx.environment();\n self.reduced_motion = environment.reduced_motion;\n let themes = default_theme(&environment);\n let theme = themes.resolve(environment.color_scheme);\n let status = if self.reduced_motion {\n "Reduced motion is active"\n } else if self.running {\n "Six loops · 2 seconds"\n } else {\n "Animations paused"\n };\n let controls = Element::row([\n Element::column([\n self.text("Animation lab", 21.0, 760, theme.foreground),\n self.text(status, 12.0, 500, theme.muted_foreground),\n ])\n .gap(2.0)\n .grow(1.0),\n Button::new(\n "toggle-animation",\n if self.running {\n "Pause animations"\n } else {\n "Resume animations"\n },\n theme.button(),\n )\n .enabled(!self.reduced_motion)\n .build(),\n ])\n .width(percent(1.0))\n .padding(Sides::length(14.0))\n .gap(12.0)\n .flex_wrap(FlexWrap::Wrap)\n .align_items(AlignItems::CENTER)\n .background(theme.card)\n .border(Border {\n widths: argui::paint::BorderWidths {\n bottom: 1.0,\n ..argui::paint::BorderWidths::default()\n },\n color: theme.border,\n });\n let gallery = self\n .cards(theme)\n .keyed("animation-gallery")\n .grow(1.0)\n .min_height(length(0.0))\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Auto,\n })\n .scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone()));\n\n Element::column([controls, gallery])\n .width(percent(1.0))\n .height(percent(1.0))\n .min_height(length(0.0))\n .background(theme.background)\n .on(cx.listener(EventType::Click, |app, event, cx| {\n if event.target_key() == Some("toggle-animation")\n && matches!(event.kind, UiEventKind::Click(_))\n {\n app.running = !app.running;\n cx.notify();\n }\n }))\n }\n\n fn layout_changed(&mut self, layout: &LayoutSnapshot, cx: &mut Context) {\n let compact = layout.viewport_size().width < 620.0;\n if self.compact != compact {\n self.compact = compact;\n cx.notify();\n }\n }\n\n fn vector_assets(&self) -> Vec {\n self.vectors.assets().to_vec()\n }\n}\n', + 'use argui::{\n core::{ColorInterpolation, Transform2D, TransformOrigin},\n paint::{Border, CornerRadii, LayerStyle, Shadow, VectorAsset, VectorId},\n runtime::{Context, LayoutSnapshot, Render},\n text::TextStyle,\n ui::{\n AlignItems, Axes, Element, FlexWrap, JustifyContent, Overflow, ScrollConfig, Sides, length,\n percent,\n },\n vector::VectorLibrary,\n widgets::{Button, WidgetTheme, default_theme},\n};\n\nconst ORBIT_SVG: &[u8] = br#"\n \n \n"#;\nconst SPARK_SVG: &[u8] = br#"\n \n"#;\n\npub struct Example {\n elapsed: f32,\n running: bool,\n reduced_motion: bool,\n compact: bool,\n vectors: VectorLibrary,\n orbit: VectorId,\n spark: VectorId,\n}\n\nimpl Default for Example {\n fn default() -> Self {\n let mut vectors = VectorLibrary::new();\n let orbit = vectors.insert_svg(ORBIT_SVG).expect("valid orbit SVG");\n let spark = vectors.insert_svg(SPARK_SVG).expect("valid spark SVG");\n Self {\n elapsed: 0.0,\n running: true,\n reduced_motion: false,\n compact: false,\n vectors,\n orbit,\n spark,\n }\n }\n}\n\nimpl Example {\n fn text(\n &self,\n value: impl Into,\n size: f32,\n weight: u16,\n color: argui::core::Color,\n ) -> Element {\n Element::text(value.into()).text_style(TextStyle {\n color,\n font_size: size,\n line_height: size * 1.35,\n weight,\n ..TextStyle::default()\n })\n }\n\n fn stage(&self, art: Element, theme: &WidgetTheme) -> Element {\n Element::container([art])\n .width(percent(1.0))\n .height(length(132.0))\n .padding(Sides::length(16.0))\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::CENTER)\n .background(theme.muted)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(12.0))\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Hidden,\n })\n }\n\n fn card(\n &self,\n title: &\'static str,\n detail: &\'static str,\n art: Element,\n theme: &WidgetTheme,\n ) -> Element {\n Element::column([\n self.text(title, 16.0, 700, theme.foreground),\n self.text(detail, 12.0, 450, theme.muted_foreground),\n self.stage(art, theme),\n ])\n .width(if self.compact {\n percent(1.0)\n } else {\n length(330.0)\n })\n .grow(1.0)\n .padding(Sides::length(16.0))\n .gap(9.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(14.0))\n }\n\n fn cards(&self, theme: &WidgetTheme) -> Element {\n let cycle = if self.reduced_motion {\n 0.35\n } else {\n (self.elapsed / 2.0).fract()\n };\n let angle = cycle * std::f32::consts::TAU;\n let wave = angle.sin() * 0.5 + 0.5;\n let travel = angle.sin() * if self.compact { 52.0 } else { 82.0 };\n let combined = Element::container([])\n .width(length(58.0))\n .height(length(58.0))\n .background(theme.primary)\n .radius(CornerRadii::all(12.0 + wave * 12.0))\n .transform(\n Transform2D::IDENTITY\n .translate(travel, 0.0)\n .rotate(angle)\n .scale(0.78 + wave * 0.42, 0.78 + wave * 0.42),\n )\n .transform_origin(TransformOrigin::CENTER);\n\n let aura = self\n .text("Production ready", 17.0, 700, theme.foreground)\n .padding(Sides::length(18.0))\n .background(theme.card)\n .border(Border::all(1.0 + wave * 2.0, theme.primary))\n .radius(CornerRadii::all(12.0))\n .layer(\n LayerStyle::new(Default::default()).shadow(\n Shadow::glow(\n 10.0 + wave * 24.0,\n theme.primary.with_alpha(0.18 + wave * 0.28),\n )\n .spread(wave * 4.0),\n ),\n );\n\n let morph_color = theme\n .primary\n .mix(theme.destructive, wave, ColorInterpolation::Oklab);\n let morph = Element::container([])\n .width(length(76.0 + wave * 112.0))\n .height(length(58.0 + wave * 28.0))\n .background(morph_color)\n .radius(CornerRadii {\n top_left: 8.0 + wave * 34.0,\n top_right: 38.0 - wave * 28.0,\n bottom_right: 8.0 + wave * 34.0,\n bottom_left: 38.0 - wave * 28.0,\n });\n\n let orbit = Element::vector(self.orbit)\n .vector_color(theme.primary)\n .paint_opacity(1.0 - wave)\n .width(length(72.0))\n .height(length(72.0))\n .transform(\n Transform2D::IDENTITY\n .rotate(angle)\n .scale(1.0 - wave * 0.28, 1.0 - wave * 0.28),\n );\n let spark = Element::vector(self.spark)\n .vector_color(theme.destructive)\n .paint_opacity(wave)\n .width(length(72.0))\n .height(length(72.0))\n .transform(\n Transform2D::IDENTITY\n .rotate(-angle * 0.35)\n .scale(0.72 + wave * 0.28, 0.72 + wave * 0.28),\n );\n let vector_transition = Element::container([\n orbit.absolute(Sides::length(0.0)),\n spark.absolute(Sides::length(0.0)),\n ])\n .width(length(72.0))\n .height(length(72.0));\n\n let loader = Element::row((0..5).map(|index| {\n let local =\n ((cycle + index as f32 * 0.14).fract() * std::f32::consts::TAU).sin() * 0.5 + 0.5;\n Element::container([])\n .width(length(12.0))\n .height(length(28.0 + local * 48.0))\n .background(\n theme\n .primary\n .mix(theme.destructive, local, ColorInterpolation::Oklab),\n )\n .radius(CornerRadii::all(999.0))\n }))\n .height(length(84.0))\n .gap(9.0)\n .align_items(AlignItems::CENTER);\n\n let pulse = 0.82 + wave * 0.18;\n let notification = Element::row([\n Element::container([])\n .width(length(12.0))\n .height(length(12.0))\n .background(theme.primary)\n .radius(CornerRadii::all(999.0))\n .layer(LayerStyle::new(Default::default()).shadow(Shadow::glow(\n 6.0 + wave * 18.0,\n theme.primary.with_alpha(0.34),\n ))),\n self.text("Sync complete", 15.0, 650, theme.foreground),\n ])\n .padding(Sides::length(16.0))\n .gap(10.0)\n .align_items(AlignItems::CENTER)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(999.0))\n .transform(Transform2D::IDENTITY.scale(pulse, pulse));\n\n Element::row([\n self.card(\n "Composed transform",\n "Translate, rotate, scale, and radius run in one two-second loop.",\n combined,\n theme,\n ),\n self.card(\n "Text aura",\n "A practical focus treatment combines border width and GPU glow.",\n aura,\n theme,\n ),\n self.card(\n "Layout morph",\n "Size, asymmetric radii, and Oklab color interpolate together.",\n morph,\n theme,\n ),\n self.card(\n "SVG transition",\n "Two retained vector assets crossfade, rotate, and scale.",\n vector_transition,\n theme,\n ),\n self.card(\n "Sequenced loader",\n "Phase offsets create a continuous multi-element loading rhythm.",\n loader,\n theme,\n ),\n self.card(\n "Status pulse",\n "A restrained scale and aura draw attention without moving layout.",\n notification,\n theme,\n ),\n ])\n .width(percent(1.0))\n .padding(Sides {\n left: length(16.0),\n right: length(16.0),\n top: length(16.0),\n bottom: length(28.0),\n })\n .gap(12.0)\n .flex_wrap(FlexWrap::Wrap)\n }\n}\n\nimpl Render for Example {\n fn wants_animation_frame(&self) -> bool {\n self.running && !self.reduced_motion\n }\n\n fn animation_frame(&mut self, frame: argui::animation::Frame, cx: &mut Context) {\n if self.running && !self.reduced_motion {\n self.elapsed = (self.elapsed + frame.elapsed.as_secs_f64().min(0.05) as f32) % 2.0;\n cx.notify();\n }\n }\n\n fn render(&mut self, cx: &mut Context) -> Element {\n let environment = cx.environment();\n self.reduced_motion = environment.reduced_motion;\n let themes = default_theme(&environment);\n let theme = themes.resolve(environment.color_scheme);\n let status = if self.reduced_motion {\n "Reduced motion is active"\n } else if self.running {\n "Six loops · 2 seconds"\n } else {\n "Animations paused"\n };\n let controls = Element::row([\n Element::column([\n self.text("Animation lab", 21.0, 760, theme.foreground),\n self.text(status, 12.0, 500, theme.muted_foreground),\n ])\n .gap(2.0)\n .grow(1.0),\n Button::new(\n "toggle-animation",\n if self.running {\n "Pause animations"\n } else {\n "Resume animations"\n },\n theme.button(),\n )\n .enabled(!self.reduced_motion)\n .on_click(cx.callback(|app| app.running = !app.running))\n .build(),\n ])\n .width(percent(1.0))\n .padding(Sides::length(14.0))\n .gap(12.0)\n .flex_wrap(FlexWrap::Wrap)\n .align_items(AlignItems::CENTER)\n .background(theme.card)\n .border(Border {\n widths: argui::paint::BorderWidths {\n bottom: 1.0,\n ..argui::paint::BorderWidths::default()\n },\n color: theme.border,\n });\n let gallery = self\n .cards(theme)\n .keyed("animation-gallery")\n .grow(1.0)\n .min_height(length(0.0))\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Auto,\n })\n .scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone()));\n\n Element::column([controls, gallery])\n .width(percent(1.0))\n .height(percent(1.0))\n .min_height(length(0.0))\n .background(theme.background)\n }\n\n fn layout_changed(&mut self, layout: &LayoutSnapshot, cx: &mut Context) {\n let compact = layout.viewport_size().width < 620.0;\n if self.compact != compact {\n self.compact = compact;\n cx.notify();\n }\n }\n\n fn vector_assets(&self) -> Vec {\n self.vectors.assets().to_vec()\n }\n}\n', clean_code: - 'use argui::{\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, EventType, Sides, UiEventKind, percent},\n widgets::{Button, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n completed: usize,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let checks = [\n "State is explicit",\n "Views stay pure",\n "Effects have owners",\n ];\n let rows = checks.into_iter().enumerate().map(|(index, label)| {\n let mark = if index < self.completed { "✓" } else { "○" };\n Element::text(format!("{mark} {label}")).text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n })\n });\n Element::column(\n rows.chain([Button::new("next", "Complete next", theme.button())\n .enabled(self.completed < checks.len())\n .build()]),\n )\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(14.0)\n .background(theme.background)\n .on(cx.listener(EventType::Click, |app, event, cx| {\n if event.target_key() == Some("next") && matches!(event.kind, UiEventKind::Click(_)) {\n app.completed = (app.completed + 1).min(3);\n cx.notify();\n }\n }))\n }\n}\n', + 'use argui::{\n paint::{Border, CornerRadii},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{AlignItems, Element, Sides, length, percent},\n widgets::{Button, default_theme},\n};\n\nconst CHECKS: [&str; 3] = [\n "State is explicit",\n "Views stay pure",\n "Effects have owners",\n];\n\n#[derive(Default)]\npub struct Example {\n completed: usize,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let rows = CHECKS.into_iter().enumerate().map(|(index, label)| {\n let complete = index < self.completed;\n Element::row([\n Element::text(if complete { "Done" } else { "Pending" })\n .width(length(68.0))\n .text_style(TextStyle {\n color: if complete {\n theme.primary\n } else {\n theme.muted_foreground\n },\n font_size: 12.0,\n weight: 700,\n ..TextStyle::default()\n }),\n Element::text(label).text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n }),\n ])\n .width(percent(1.0))\n .align_items(AlignItems::CENTER)\n .padding(Sides::length(12.0))\n .gap(12.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(8.0))\n });\n let next_label = if self.completed == CHECKS.len() {\n "All complete".to_owned()\n } else {\n format!("Complete next ({} of {})", self.completed + 1, CHECKS.len())\n };\n Element::column(\n rows.chain([Button::new("next", next_label, theme.button())\n .enabled(self.completed < CHECKS.len())\n .on_click(cx.callback(|app| {\n app.completed = (app.completed + 1).min(CHECKS.len());\n }))\n .build()]),\n )\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(14.0)\n .background(theme.background)\n }\n}\n', counter: - 'use argui::{\n runtime::{Context, Render},\n text::TextStyle,\n ui::{AlignItems, Element, EventType, Sides, UiEventKind, percent},\n widgets::{Button, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n count: u32,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n Element::column([\n Element::text(format!("Count: {}", self.count)).text_style(TextStyle {\n font_size: 34.0,\n line_height: 42.0,\n color: theme.foreground,\n weight: 700,\n ..TextStyle::default()\n }),\n Button::new("increment", "Increment", theme.button()).build(),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .align_items(AlignItems::CENTER)\n .padding(Sides::length(28.0))\n .gap(18.0)\n .background(theme.background)\n .on(cx.listener(EventType::Click, |app, event, cx| {\n if event.target_key() == Some("increment")\n && matches!(event.kind, UiEventKind::Click(_))\n {\n app.count = app.count.saturating_add(1);\n cx.notify();\n }\n }))\n }\n}\n', + 'use argui::{\n runtime::{Context, Render},\n text::TextStyle,\n ui::{AlignItems, Element, Sides, percent},\n widgets::{Button, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n count: u32,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n Element::column([\n Element::text(format!("Count: {}", self.count)).text_style(TextStyle {\n font_size: 34.0,\n line_height: 42.0,\n color: theme.foreground,\n weight: 700,\n ..TextStyle::default()\n }),\n Button::new("increment", "Increment", theme.button())\n .on_click(cx.callback(|app| app.count = app.count.saturating_add(1)))\n .build(),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .align_items(AlignItems::CENTER)\n .padding(Sides::length(28.0))\n .gap(18.0)\n .background(theme.background)\n }\n}\n', custom_elements: - "use argui::{\n core::{Color, Point, Rect, Size},\n paint::QuadStyle,\n runtime::{Context, Render},\n ui::{\n CustomElement, CustomLayoutContext, CustomMeasurement, CustomPaintContext, Element, Sides,\n length, percent,\n },\n widgets::default_theme,\n};\n\n#[derive(Debug)]\nstruct Ruler {\n background: Color,\n ticks: Color,\n}\n\nimpl CustomElement for Ruler {\n type State = Vec;\n fn create_state(&self) -> Self::State {\n Vec::new()\n }\n fn layout_revision(&self) -> u64 {\n 0\n }\n fn paint_revision(&self) -> u64 {\n 0\n }\n fn layout(\n &self,\n _: &mut Self::State,\n _: &mut dyn CustomLayoutContext,\n ) -> Result {\n Ok(CustomMeasurement {\n size: Size::new(720.0, 180.0),\n baseline: None,\n })\n }\n fn prepare(&self, ticks: &mut Self::State, size: Size) {\n ticks.clear();\n ticks.extend((0..=(size.width / 40.0) as usize).map(|tick| tick as f32 * 40.0));\n }\n fn paint(&self, ticks: &mut Self::State, cx: &mut CustomPaintContext<'_>) {\n cx.quad(\n Rect::new(Point::default(), cx.bounds.size),\n QuadStyle::solid(self.background),\n );\n for (index, x) in ticks.iter().copied().enumerate() {\n let height = if index.is_multiple_of(5) { 52.0 } else { 24.0 };\n cx.quad(\n Rect::new(Point::new(x, 0.0), Size::new(2.0, height)),\n QuadStyle::solid(self.ticks),\n );\n }\n }\n}\n\npub struct Example;\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n Element::container([Element::custom(Ruler {\n background: theme.card,\n ticks: theme.primary,\n })\n .width(percent(1.0))\n .height(length(180.0))])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(24.0))\n .background(theme.background)\n }\n}\n", + 'use argui::{\n accessibility::{Role, SemanticAction, SemanticValue, Semantics},\n core::{Color, Key, KeyState, Point, Rect, Size},\n paint::{Border, CornerRadii, QuadStyle},\n runtime::{Context, Render},\n text::{TextStyle, TextWrap},\n ui::{\n Axes, CursorIcon, CustomElement, CustomLayoutContext, CustomMeasurement,\n CustomPaintContext, Element, EventType, FocusPolicy, GestureCapture, GestureKind,\n GesturePhase, GestureSet, HitTestStyle, Interaction, Overflow, PanGesture, Sides,\n UiEventKind, UserSelect, length, percent, sides,\n },\n widgets::{Button, default_theme},\n};\n\nconst DURATION: f32 = 20.0;\nconst PIXELS_PER_SECOND: f32 = 32.0;\nconst GUTTER: f32 = 76.0;\nconst RULER_HEIGHT: f32 = 32.0;\nconst TRACK_HEIGHT: f32 = 58.0;\nconst TIMELINE_WIDTH: f32 = GUTTER + DURATION * PIXELS_PER_SECOND + 48.0;\nconst TIME_LABELS: usize = 5;\nconst TRACK_LABELS: usize = 3;\n\n#[derive(Clone, Copy, Debug)]\nstruct Clip {\n label: &\'static str,\n start: f32,\n duration: f32,\n track: usize,\n}\n\nconst CLIPS: [Clip; 5] = [\n Clip {\n label: "Intro",\n start: 0.0,\n duration: 5.0,\n track: 0,\n },\n Clip {\n label: "Interview",\n start: 5.0,\n duration: 9.0,\n track: 0,\n },\n Clip {\n label: "B-roll",\n start: 14.0,\n duration: 6.0,\n track: 0,\n },\n Clip {\n label: "Voiceover",\n start: 2.5,\n duration: 11.0,\n track: 1,\n },\n Clip {\n label: "Music bed",\n start: 0.0,\n duration: 20.0,\n track: 2,\n },\n];\n\n#[derive(Debug)]\nstruct EditorTimeline {\n background: Color,\n track: Color,\n grid: Color,\n playhead: Color,\n seconds: f32,\n clips: [Clip; 5],\n}\n\nimpl CustomElement for EditorTimeline {\n type State = Vec;\n\n fn create_state(&self) -> Self::State {\n Vec::new()\n }\n\n fn layout_revision(&self) -> u64 {\n self.clips\n .iter()\n .fold(u64::from(self.seconds.to_bits()), |revision, clip| {\n revision\n .wrapping_mul(31)\n .wrapping_add(u64::from(clip.start.to_bits()))\n .wrapping_mul(31)\n .wrapping_add(clip.track as u64)\n })\n }\n\n fn paint_revision(&self) -> u64 {\n [self.background, self.track, self.grid, self.playhead]\n .into_iter()\n .fold(u64::from(self.seconds.to_bits()), |revision, color| {\n revision\n .wrapping_mul(31)\n .wrapping_add(u64::from(u32::from_le_bytes(color.to_srgba8())))\n })\n }\n\n fn layout(\n &self,\n _: &mut Self::State,\n cx: &mut dyn CustomLayoutContext,\n ) -> Result {\n for index in 0..TIME_LABELS {\n cx.place_child(\n index,\n Rect::new(\n Point::new(GUTTER + index as f32 * 5.0 * PIXELS_PER_SECOND + 4.0, 7.0),\n Size::new(42.0, 20.0),\n ),\n )?;\n }\n for index in 0..TRACK_LABELS {\n cx.place_child(\n TIME_LABELS + index,\n Rect::new(\n Point::new(10.0, RULER_HEIGHT + index as f32 * TRACK_HEIGHT + 18.0),\n Size::new(56.0, 20.0),\n ),\n )?;\n }\n for (index, clip) in self.clips.into_iter().enumerate() {\n cx.place_child(\n TIME_LABELS + TRACK_LABELS + index,\n Rect::new(\n Point::new(\n GUTTER + clip.start * PIXELS_PER_SECOND,\n RULER_HEIGHT + clip.track as f32 * TRACK_HEIGHT + 6.0,\n ),\n Size::new(clip.duration * PIXELS_PER_SECOND, TRACK_HEIGHT - 12.0),\n ),\n )?;\n }\n cx.place_child(\n TIME_LABELS + TRACK_LABELS + CLIPS.len(),\n Rect::new(\n Point::new(GUTTER + self.seconds * PIXELS_PER_SECOND - 12.0, 0.0),\n Size::new(24.0, RULER_HEIGHT + TRACK_LABELS as f32 * TRACK_HEIGHT),\n ),\n )?;\n Ok(CustomMeasurement {\n size: Size::new(\n TIMELINE_WIDTH,\n RULER_HEIGHT + TRACK_LABELS as f32 * TRACK_HEIGHT,\n ),\n baseline: None,\n })\n }\n\n fn prepare(&self, ticks: &mut Self::State, _: Size) {\n ticks.clear();\n ticks.extend(\n (0..=DURATION as usize).map(|second| GUTTER + second as f32 * PIXELS_PER_SECOND),\n );\n }\n\n fn paint(&self, ticks: &mut Self::State, cx: &mut CustomPaintContext<\'_>) {\n cx.quad(\n Rect::new(Point::default(), cx.bounds.size),\n QuadStyle::solid(self.background),\n );\n for track in 0..TRACK_LABELS {\n cx.quad(\n Rect::new(\n Point::new(0.0, RULER_HEIGHT + track as f32 * TRACK_HEIGHT + 2.0),\n Size::new(cx.bounds.size.width, TRACK_HEIGHT - 4.0),\n ),\n QuadStyle::solid(self.track).radius(CornerRadii::all(6.0)),\n );\n }\n for (second, x) in ticks.iter().copied().enumerate() {\n cx.quad(\n Rect::new(\n Point::new(\n x,\n if second.is_multiple_of(5) {\n 24.0\n } else {\n RULER_HEIGHT\n },\n ),\n Size::new(\n if second.is_multiple_of(5) { 1.5 } else { 1.0 },\n cx.bounds.size.height - RULER_HEIGHT,\n ),\n ),\n QuadStyle::solid(self.grid),\n );\n }\n let playhead_x = GUTTER + self.seconds * PIXELS_PER_SECOND;\n cx.quad(\n Rect::new(\n Point::new(playhead_x - 1.0, 10.0),\n Size::new(2.0, cx.bounds.size.height - 10.0),\n ),\n QuadStyle::solid(self.playhead),\n );\n cx.quad(\n Rect::new(Point::new(playhead_x - 6.0, 4.0), Size::new(12.0, 12.0)),\n QuadStyle::solid(self.playhead).radius(CornerRadii::all(3.0)),\n );\n }\n}\n\npub struct Example {\n clips: [Clip; 5],\n selected_clip: usize,\n clip_drag_start: Clip,\n playhead: f32,\n playhead_drag_start: f32,\n}\n\nimpl Default for Example {\n fn default() -> Self {\n Self {\n clips: CLIPS,\n selected_clip: 0,\n clip_drag_start: CLIPS[0],\n playhead: 7.5,\n playhead_drag_start: 7.5,\n }\n }\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let text = |value: String, size: f32, weight: u16, color: Color| {\n Element::text(value).text_style(TextStyle {\n color,\n font_size: size,\n weight,\n ..TextStyle::default()\n })\n };\n let mut children: Vec = (0..TIME_LABELS)\n .map(|index| {\n text(\n format!("0:{:02}", index * 5),\n 11.0,\n 600,\n theme.muted_foreground,\n )\n })\n .collect();\n children.extend(\n ["VIDEO 1", "VIDEO 2", "AUDIO"]\n .into_iter()\n .map(|label| text(label.to_owned(), 11.0, 700, theme.muted_foreground)),\n );\n let clip_colors = [\n Color::from_srgb8(37, 99, 235),\n Color::from_srgb8(124, 58, 237),\n Color::from_srgb8(8, 145, 178),\n Color::from_srgb8(219, 39, 119),\n Color::from_srgb8(5, 150, 105),\n ];\n children.extend(self.clips.into_iter().zip(clip_colors).enumerate().map(\n |(index, (clip, color))| {\n let selected = self.selected_clip == index;\n Element::column([\n Element::text(clip.label).text_style(TextStyle {\n color: Color::WHITE,\n font_size: 13.0,\n line_height: 16.0,\n weight: 700,\n wrap: TextWrap::None,\n ..TextStyle::default()\n }),\n Element::text(format!("{:.1}s", clip.duration)).text_style(TextStyle {\n color: Color::WHITE.with_alpha(0.82),\n font_size: 10.0,\n line_height: 12.0,\n weight: 500,\n wrap: TextWrap::None,\n ..TextStyle::default()\n }),\n ])\n .keyed(format!("editor-clip-{index}"))\n .gap(2.0)\n .padding(sides(8.0, 4.0))\n .background(color)\n .border(Border::all(\n if selected { 2.0 } else { 1.0 },\n if selected {\n theme.ring\n } else {\n Color::WHITE.with_alpha(0.25)\n },\n ))\n .radius(CornerRadii::all(6.0))\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Hidden,\n })\n .z_index(i32::from(selected))\n .interaction(\n Interaction::default()\n .focus_policy(FocusPolicy::TabStop)\n .cursor(CursorIcon::Grab)\n .gestures(\n GestureSet::EMPTY.pan(\n PanGesture::default()\n .immediate()\n .capture(GestureCapture::OnPress),\n ),\n ),\n )\n .semantics(\n Semantics::new(Role::Button)\n .label(format!(\n "{} clip at {:.1}s on track {}",\n clip.label,\n clip.start,\n clip.track + 1\n ))\n .description(\n "Drag horizontally or between tracks; arrow keys move the clip",\n )\n .action(SemanticAction::Focus),\n )\n .user_select(UserSelect::None)\n .on(cx.listener(EventType::Gesture, move |app, event, cx| {\n let UiEventKind::Gesture(gesture) = event.kind else {\n return;\n };\n let GestureKind::Pan { total, .. } = gesture.kind else {\n return;\n };\n if gesture.phase == GesturePhase::Started {\n app.selected_clip = index;\n app.clip_drag_start = app.clips[index];\n }\n app.clips[index] = if gesture.phase == GesturePhase::Cancelled {\n app.clip_drag_start\n } else {\n let mut next = app.clip_drag_start;\n next.start = (next.start + total.x / PIXELS_PER_SECOND)\n .clamp(0.0, DURATION - next.duration);\n next.track = (next.track as f32 + total.y / TRACK_HEIGHT)\n .round()\n .clamp(0.0, (TRACK_LABELS - 1) as f32)\n as usize;\n next\n };\n event.stop_propagation();\n cx.notify();\n }))\n .on(cx.listener(EventType::Key, move |app, event, cx| {\n let UiEventKind::KeyInput(input) = &event.kind else {\n return;\n };\n if input.state != KeyState::Pressed {\n return;\n }\n let clip = &mut app.clips[index];\n match input.key {\n Key::ArrowLeft => clip.start = (clip.start - 0.5).max(0.0),\n Key::ArrowRight => {\n clip.start = (clip.start + 0.5).min(DURATION - clip.duration)\n }\n Key::ArrowUp => clip.track = clip.track.saturating_sub(1),\n Key::ArrowDown => clip.track = (clip.track + 1).min(TRACK_LABELS - 1),\n _ => return,\n }\n app.selected_clip = index;\n event.stop_propagation();\n cx.notify();\n }))\n },\n ));\n let playhead = Element::custom_region(\n "editor-playhead",\n Interaction::default()\n .focus_policy(FocusPolicy::TabStop)\n .cursor(CursorIcon::EwResize)\n .gestures(\n GestureSet::EMPTY.pan(\n PanGesture::default()\n .immediate()\n .capture(GestureCapture::OnPress),\n ),\n ),\n Semantics::new(Role::Slider)\n .label("Timeline playhead")\n .description("Drag horizontally or use Left and Right to scrub")\n .value(SemanticValue::Number {\n value: f64::from(self.playhead),\n minimum: Some(0.0),\n maximum: Some(f64::from(DURATION)),\n step: Some(0.5),\n })\n .action(SemanticAction::Focus)\n .action(SemanticAction::Increment)\n .action(SemanticAction::Decrement)\n .action(SemanticAction::SetValue),\n )\n .hit_test(HitTestStyle::default().slop(Sides::length(8.0)))\n .user_select(UserSelect::None)\n .on(cx.listener(EventType::Gesture, |app, event, cx| {\n let UiEventKind::Gesture(gesture) = event.kind else {\n return;\n };\n let GestureKind::Pan { total, .. } = gesture.kind else {\n return;\n };\n if gesture.phase == GesturePhase::Started {\n app.playhead_drag_start = app.playhead;\n }\n app.playhead = if gesture.phase == GesturePhase::Cancelled {\n app.playhead_drag_start\n } else {\n (app.playhead_drag_start + total.x / PIXELS_PER_SECOND).clamp(0.0, DURATION)\n };\n event.stop_propagation();\n cx.notify();\n }))\n .on(cx.listener(EventType::Key, |app, event, cx| {\n let UiEventKind::KeyInput(input) = &event.kind else {\n return;\n };\n if input.state != KeyState::Pressed {\n return;\n }\n let delta = match input.key {\n Key::ArrowLeft => -0.5,\n Key::ArrowRight => 0.5,\n _ => return,\n };\n app.playhead = (app.playhead + delta).clamp(0.0, DURATION);\n event.stop_propagation();\n cx.notify();\n }))\n .on(cx.listener(EventType::SemanticAction, |app, event, cx| {\n let UiEventKind::SemanticAction { action, value } = &event.kind else {\n return;\n };\n app.playhead = match (action, value) {\n (SemanticAction::Increment, _) => app.playhead + 0.5,\n (SemanticAction::Decrement, _) => app.playhead - 0.5,\n (SemanticAction::SetValue, Some(SemanticValue::Number { value, .. })) => {\n *value as f32\n }\n _ => return,\n }\n .clamp(0.0, DURATION);\n event.stop_propagation();\n cx.notify();\n }));\n children.push(playhead);\n\n let back = Button::new("playhead-back", "Back 1s", theme.outline_button())\n .on_click(cx.callback(|app| app.playhead = (app.playhead - 1.0).max(0.0)))\n .build();\n let forward = Button::new("playhead-forward", "Forward 1s", theme.outline_button())\n .on_click(cx.callback(|app| {\n app.playhead = (app.playhead + 1.0).min(DURATION);\n }))\n .build();\n let timeline = Element::custom_container(\n EditorTimeline {\n background: theme.card,\n track: theme.muted,\n grid: theme.border,\n playhead: theme.destructive,\n seconds: self.playhead,\n clips: self.clips,\n },\n children,\n )\n .keyed("video-editor-timeline")\n .width(percent(1.0))\n .height(length(RULER_HEIGHT + TRACK_LABELS as f32 * TRACK_HEIGHT));\n\n Element::column([\n Element::row([\n Element::column([\n text(\n "Product launch edit".to_owned(),\n 22.0,\n 750,\n theme.foreground,\n ),\n text(\n "Drag clips to retime them or move tracks; drag the red playhead to scrub."\n .to_owned(),\n 13.0,\n 400,\n theme.muted_foreground,\n ),\n ])\n .gap(3.0)\n .grow(1.0),\n text(\n format!("00:{:04.1} / 00:20.0", self.playhead),\n 13.0,\n 700,\n theme.foreground,\n ),\n ]),\n Element::row([back, forward]).gap(8.0),\n text(\n format!(\n "Selected: {} · {:.1}s · track {}",\n self.clips[self.selected_clip].label,\n self.clips[self.selected_clip].start,\n self.clips[self.selected_clip].track + 1\n ),\n 12.0,\n 600,\n theme.muted_foreground,\n ),\n timeline,\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(24.0))\n .gap(14.0)\n .background(theme.background)\n }\n}\n', data: 'use argui::{\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, EventType, Sides, UiEventKind, VirtualList, length, percent},\n widgets::default_theme,\n};\n\npub struct Example {\n list: VirtualList,\n offset: f32,\n}\n\nimpl Default for Example {\n fn default() -> Self {\n Self {\n list: VirtualList::fixed(10_000, 42.0, 300.0).overscan(6),\n offset: 0.0,\n }\n }\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n self.list\n .build("records", self.offset, |index| {\n Element::text(format!("Record #{:05}", index + 1))\n .text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n })\n .height(length(42.0))\n .padding(Sides::length(10.0))\n })\n .width(percent(1.0))\n .height(length(300.0))\n .background(theme.background)\n .on(cx.listener(EventType::Scroll, |app, event, cx| {\n if let UiEventKind::Scrolled { offset, .. } = event.kind {\n app.offset = offset.y;\n cx.notify();\n }\n }))\n }\n}\n', elements: 'use argui::{\n paint::{Border, CornerRadii},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{AlignItems, Element, FlexWrap, JustifyContent, Sides, length, percent},\n widgets::default_theme,\n};\n\npub struct Example;\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let item = |label: &\'static str| {\n Element::text(label)\n .text_style(TextStyle {\n color: theme.primary_foreground,\n weight: 700,\n ..TextStyle::default()\n })\n .width(length(56.0))\n .height(length(40.0))\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::CENTER)\n .background(theme.primary)\n .radius(CornerRadii::all(8.0))\n };\n let panel = |title: &\'static str, detail: &\'static str, composition: Element| {\n Element::column([\n Element::text(title).text_style(TextStyle {\n color: theme.foreground,\n weight: 700,\n ..TextStyle::default()\n }),\n Element::text(detail).text_style(TextStyle {\n color: theme.muted_foreground,\n font_size: 13.0,\n ..TextStyle::default()\n }),\n Element::container([composition])\n .height(length(150.0))\n .padding(Sides::length(14.0))\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::CENTER)\n .background(theme.muted)\n .radius(CornerRadii::all(9.0)),\n ])\n .width(length(280.0))\n .grow(1.0)\n .padding(Sides::length(18.0))\n .gap(10.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(12.0))\n };\n\n let horizontal = Element::row([item("A"), item("B"), item("C")]).gap(10.0);\n let vertical = Element::column([item("A"), item("B"), item("C")]).gap(8.0);\n\n Element::row([\n panel(\n "Element::row",\n "Children flow from left to right.",\n horizontal,\n ),\n panel(\n "Element::column",\n "Children flow from top to bottom.",\n vertical,\n ),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(24.0))\n .gap(12.0)\n .flex_wrap(FlexWrap::Wrap)\n .background(theme.background)\n }\n}\n', @@ -17,19 +17,27 @@ export const docExampleSources = { 'use argui::{\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, EventType, FlexWrap, Sides, UiEventKind, percent},\n widgets::{Button, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n last_action: Option,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let status = self.last_action.as_deref().unwrap_or("Choose an action");\n Element::column([\n Element::row([\n Button::new("save", "Save", theme.button()).build(),\n Button::new("preview", "Preview", theme.outline_button()).build(),\n ])\n .gap(10.0)\n .flex_wrap(FlexWrap::Wrap),\n Element::text(status).text_style(TextStyle {\n color: theme.foreground,\n weight: 600,\n ..TextStyle::default()\n }),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(20.0)\n .background(theme.background)\n .on(cx.listener(EventType::Click, |app, event, cx| {\n if matches!(event.kind, UiEventKind::Click(_)) {\n app.last_action = event\n .target_key()\n .map(|key| format!("Received Click({key})"));\n cx.notify();\n }\n }))\n }\n}\n', first_window: 'use argui::{\n paint::{Border, CornerRadii},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{AlignItems, Element, JustifyContent, Sides, length, percent},\n widgets::default_theme,\n};\n\npub struct Example;\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let heading = Element::text("Hello from Argui").text_style(TextStyle {\n font_size: 34.0,\n line_height: 42.0,\n color: theme.foreground,\n weight: 720,\n ..TextStyle::default()\n });\n let copy = Element::text(\n "The same retained Rust model runs in this browser and on native desktop.",\n )\n .text_style(TextStyle {\n color: theme.muted_foreground,\n ..TextStyle::default()\n });\n let card = Element::column([heading, copy])\n .width(length(560.0))\n .max_width(percent(1.0))\n .padding(Sides::length(30.0))\n .gap(14.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(16.0));\n Element::container([card])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(20.0))\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::CENTER)\n .background(theme.background)\n }\n}\n', - i18n: 'use argui::{\n i18n::{Catalog, FluentArgs, Localizer, langid},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, EventType, FlexWrap, Sides, UiEventKind, WritingDirection, percent},\n widgets::{Button, default_theme},\n};\n\nconst ENGLISH: &str = "hello = Hello, { $name }!\\nitems = { $count ->\\n [one] One message\\n*[other] { $count } messages\\n}";\nconst FRENCH: &str = "hello = Bonjour, { $name } !\\nitems = { $count ->\\n [one] Un message\\n*[other] { $count } messages\\n}";\nconst ARABIC: &str = "hello = مرحبًا، { $name }!\\nitems = { $count ->\\n [one] رسالة واحدة\\n*[other] { $count } رسائل\\n}";\n\npub struct Example {\n localizer: Localizer,\n count: i32,\n}\n\nimpl Default for Example {\n fn default() -> Self {\n let catalogs = [\n (langid!("en-US"), ENGLISH),\n (langid!("fr"), FRENCH),\n (langid!("ar"), ARABIC),\n ]\n .map(|(locale, source)| Catalog::parse(locale, source).expect("valid embedded Fluent"));\n Self {\n localizer: Localizer::new(langid!("en-US"), catalogs).expect("English fallback exists"),\n count: 1,\n }\n }\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let mut args = FluentArgs::new();\n args.set("name", "Ada");\n args.set("count", self.count);\n let direction = if self.localizer.is_rtl() {\n WritingDirection::Rtl\n } else {\n WritingDirection::Ltr\n };\n let localized_text = |value| {\n Element::text(value).text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n })\n };\n let localized = Element::column([\n localized_text(\n self.localizer\n .format("hello", &args)\n .expect("known message"),\n ),\n localized_text(self.localizer.format("items", &args).expect("known plural")),\n ])\n .gap(8.0)\n .direction_scope(direction);\n Element::column([\n Element::row([\n Button::new("en", "English", theme.outline_button()).build(),\n Button::new("fr", "Français", theme.outline_button()).build(),\n Button::new("ar", "العربية", theme.outline_button()).build(),\n ])\n .gap(8.0)\n .flex_wrap(FlexWrap::Wrap),\n localized,\n Button::new("more", "Add message", theme.button()).build(),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(18.0)\n .background(theme.background)\n .on(cx.listener(EventType::Click, |app, event, cx| {\n if !matches!(event.kind, UiEventKind::Click(_)) {\n return;\n }\n match event.target_key() {\n Some("en") => {\n app.localizer.select([langid!("en-US")]);\n }\n Some("fr") => {\n app.localizer.select([langid!("fr")]);\n }\n Some("ar") => {\n app.localizer.select([langid!("ar")]);\n }\n Some("more") => app.count += 1,\n _ => return,\n }\n cx.notify();\n }))\n }\n}\n', + i18n: 'use argui::{\n i18n::{Catalog, FluentArgs, Localizer, langid},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, FlexWrap, Sides, WritingDirection, percent},\n widgets::{Button, default_theme},\n};\n\nconst ENGLISH: &str = "hello = Hello, { $name }!\\nitems = { $count ->\\n [one] One message\\n*[other] { $count } messages\\n}";\nconst FRENCH: &str = "hello = Bonjour, { $name } !\\nitems = { $count ->\\n [one] Un message\\n*[other] { $count } messages\\n}";\nconst ARABIC: &str = "hello = مرحبًا، { $name }!\\nitems = { $count ->\\n [one] رسالة واحدة\\n*[other] { $count } رسائل\\n}";\n\npub struct Example {\n localizer: Localizer,\n count: i32,\n}\n\nimpl Default for Example {\n fn default() -> Self {\n let catalogs = [\n (langid!("en-US"), ENGLISH),\n (langid!("fr"), FRENCH),\n (langid!("ar"), ARABIC),\n ]\n .map(|(locale, source)| Catalog::parse(locale, source).expect("valid embedded Fluent"));\n Self {\n localizer: Localizer::new(langid!("en-US"), catalogs).expect("English fallback exists"),\n count: 1,\n }\n }\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let mut args = FluentArgs::new();\n args.set("name", "Ada");\n args.set("count", self.count);\n let direction = if self.localizer.is_rtl() {\n WritingDirection::Rtl\n } else {\n WritingDirection::Ltr\n };\n let localized_text = |value| {\n Element::text(value).text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n })\n };\n let localized = Element::column([\n localized_text(\n self.localizer\n .format("hello", &args)\n .expect("known message"),\n ),\n localized_text(self.localizer.format("items", &args).expect("known plural")),\n ])\n .gap(8.0)\n .direction_scope(direction);\n Element::column([\n Element::row([\n Button::new("en", "English", theme.outline_button())\n .on_click(cx.callback(|app| {\n app.localizer.select([langid!("en-US")]);\n }))\n .build(),\n Button::new("fr", "Français", theme.outline_button())\n .on_click(cx.callback(|app| {\n app.localizer.select([langid!("fr")]);\n }))\n .build(),\n Button::new("ar", "العربية", theme.outline_button())\n .on_click(cx.callback(|app| {\n app.localizer.select([langid!("ar")]);\n }))\n .build(),\n ])\n .gap(8.0)\n .flex_wrap(FlexWrap::Wrap),\n localized,\n Button::new("more", "Add message", theme.button())\n .on_click(cx.callback(|app| app.count += 1))\n .build(),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(18.0)\n .background(theme.background)\n }\n}\n', installation: 'use argui::{\n paint::{Border, CornerRadii},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{AlignItems, Element, JustifyContent, Sides, length, percent},\n widgets::default_theme,\n};\n\npub struct Example;\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let text = |value, size, weight| {\n Element::text(value).text_style(TextStyle {\n font_size: size,\n line_height: size * 1.35,\n color: theme.foreground,\n weight,\n ..TextStyle::default()\n })\n };\n let card = Element::column([\n text("Argui is ready", 30.0, 700),\n text("Rust + WebAssembly + WebGPU", 16.0, 450),\n ])\n .width(length(460.0))\n .max_width(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(10.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(14.0));\n Element::container([card])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(20.0))\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::CENTER)\n .background(theme.background)\n }\n}\n', + interaction_api: + 'use argui::{\n paint::{Border, CornerRadii, PaintStyle, QuadStyle},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{\n AlignItems, Axes, ContinuousValuePhase, Element, EventType, JustifyContent, Overflow,\n RangeHandlerValue, Sides, StateSelector, StylePatch, StyleTransition, TextEdit,\n ValueHandler, VisualState, auto, length, percent, property,\n },\n widgets::{\n Button, RANGE_SCOPE, RangeAxis, RangeBehavior, RangeConfig, RangeDirection, RangePart,\n TextArea, WidgetTheme, default_theme,\n },\n};\n\nstruct BigSlider {\n behavior: RangeBehavior,\n change_handlers: Vec>,\n commit_handlers: Vec>,\n}\n\nimpl BigSlider {\n /// Creates the large custom slider used by this interaction example.\n fn new(key: &str, label: &str, value: f32, config: RangeConfig) -> Self {\n Self {\n behavior: RangeBehavior::new(key, label, value, config),\n change_handlers: Vec::new(),\n commit_handlers: Vec::new(),\n }\n }\n\n /// Adds a callback for each intermediate value produced while interacting.\n fn on_change(mut self, handler: ValueHandler) -> Self {\n self.change_handlers.push(handler);\n self\n }\n\n /// Adds a callback for the final value produced by an interaction.\n fn on_commit(mut self, handler: ValueHandler) -> Self {\n self.commit_handlers.push(handler);\n self\n }\n\n /// Builds a fully custom visual while preserving the standard range contract.\n fn build(self, theme: &WidgetTheme) -> Element {\n let ratio = self.behavior.ratio();\n let ticks = Element::row((0..9).map(|_| {\n Element::container([])\n .width(length(1.0))\n .height(length(12.0))\n .paint_style(PaintStyle::new(\n QuadStyle::solid(theme.foreground).opacity(0.12),\n ))\n .when(\n StateSelector::scope(RANGE_SCOPE, VisualState::Hovered),\n StylePatch::new().set(property::Opacity, 0.42),\n )\n .when(\n StateSelector::scope(RANGE_SCOPE, VisualState::Pressed),\n StylePatch::new().set(property::Opacity, 0.7),\n )\n .transition(StyleTransition::default())\n }))\n .absolute(Sides {\n left: length(18.0),\n right: length(18.0),\n top: auto(),\n bottom: auto(),\n })\n .height(percent(1.0))\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::SPACE_BETWEEN);\n let fill = Element::container([])\n .absolute(Sides::length(0.0))\n .width(percent(1.0))\n .height(percent(1.0))\n .background(theme.primary.with_alpha(0.26));\n let thumb = Element::container([])\n .absolute(Sides {\n left: auto(),\n right: length(0.0),\n top: length(7.0),\n bottom: length(7.0),\n })\n .width(length(3.0))\n .background(theme.primary)\n .radius(CornerRadii::all(2.0));\n let progress = Element::container([fill, thumb])\n .absolute(Sides {\n left: length(0.0),\n right: auto(),\n top: length(0.0),\n bottom: length(0.0),\n })\n .width(percent(ratio));\n let track = self.behavior.decorate(\n RangePart::Track,\n Element::container([progress, ticks])\n .width(percent(1.0))\n .height(percent(1.0)),\n );\n let mut control = self.behavior.decorate(\n RangePart::Control,\n Element::container([track])\n .absolute(Sides::length(0.0))\n .width(percent(1.0))\n .height(percent(1.0)),\n );\n let config = self.behavior.config();\n for (phase, handlers) in [\n (ContinuousValuePhase::Change, &self.change_handlers),\n (ContinuousValuePhase::Commit, &self.commit_handlers),\n ] {\n let source = RangeHandlerValue::new(\n self.behavior.value(),\n config.minimum,\n config.maximum,\n config.step,\n config.axis == RangeAxis::Vertical,\n config.direction == RangeDirection::Reverse,\n phase,\n );\n for handler in handlers {\n for event in [\n EventType::Key,\n EventType::Gesture,\n EventType::SemanticAction,\n ] {\n control =\n control.on(handler.direct_listener(event).range_handler_value(source));\n }\n }\n }\n self.behavior.decorate(\n RangePart::Root,\n Element::container([\n control,\n Element::text(format!("{:.0}%", self.behavior.value()))\n .absolute(Sides {\n left: auto(),\n right: length(14.0),\n top: length(18.0),\n bottom: auto(),\n })\n .text_style(TextStyle {\n color: theme.foreground,\n weight: 700,\n ..TextStyle::default()\n })\n .semantic_hidden(true),\n ])\n .width(percent(1.0))\n .height(length(58.0))\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(12.0))\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Hidden,\n }),\n )\n }\n}\n\npub struct Example {\n live_volume: f32,\n committed_volume: f32,\n saves: u32,\n source: String,\n edit_count: u32,\n last_edit: String,\n}\n\nimpl Default for Example {\n fn default() -> Self {\n Self {\n live_volume: 35.0,\n committed_volume: 35.0,\n saves: 0,\n source: "fn main() {\\n println!(\\"fast edits\\");\\n}".into(),\n edit_count: 0,\n last_edit: "No edits delivered yet".into(),\n }\n }\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let label = |value: String, color, weight| {\n Element::text(value).text_style(TextStyle {\n color,\n weight,\n ..TextStyle::default()\n })\n };\n\n let live_value = cx.value_callback(|app, value| app.live_volume = value);\n let committed_value = cx.value_callback(|app, value| {\n app.live_volume = value;\n app.committed_volume = value;\n });\n let save = cx.callback(|app| app.saves = app.saves.saturating_add(1));\n let edit_source = cx.edit_callback(|app, edit: TextEdit| {\n let summary = format!(\n "bytes {}..{} → {} byte(s)",\n edit.range.start,\n edit.range.end,\n edit.replacement.len()\n );\n if edit.apply_to(&mut app.source).is_ok() {\n app.edit_count = app.edit_count.saturating_add(1);\n app.last_edit = summary;\n } else {\n app.last_edit = "Rejected stale edit".into();\n }\n });\n\n Element::column([\n label("Choose callbacks by intent".into(), theme.foreground, 700),\n label(\n "on_change previews continuously; on_commit stores the final value.".into(),\n theme.muted_foreground,\n 450,\n ),\n Element::column([\n BigSlider::new(\n "volume",\n "Preview volume",\n self.live_volume,\n RangeConfig::new(0.0, 100.0, 1.0),\n )\n .on_change(live_value)\n .on_commit(committed_value)\n .build(theme),\n label(\n format!("Live value: {:.0}%", self.live_volume),\n theme.foreground,\n 600,\n ),\n label(\n format!("Committed value: {:.0}%", self.committed_volume),\n theme.muted_foreground,\n 500,\n ),\n ])\n .padding(Sides::length(18.0))\n .gap(10.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(10.0)),\n Element::column([\n label(\n "Use deltas for document-sized text".into(),\n theme.foreground,\n 650,\n ),\n label(\n "on_edit sends one UTF-8 range replacement instead of cloning the whole value."\n .into(),\n theme.muted_foreground,\n 450,\n ),\n TextArea::new(\n "incremental-source",\n &self.source,\n "Paste or type Rust…",\n theme.input(),\n )\n .on_edit(edit_source)\n .build()\n .height(length(118.0)),\n label(\n format!(\n "Incremental edits: {} · {}",\n self.edit_count, self.last_edit\n ),\n theme.muted_foreground,\n 500,\n ),\n ])\n .padding(Sides::length(18.0))\n .gap(10.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(10.0)),\n Button::new("save", "Save preset", theme.button())\n .on_click(save)\n .build(),\n label(\n format!("Saved {} time(s)", self.saves),\n theme.muted_foreground,\n 500,\n ),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(16.0)\n .background(theme.background)\n }\n}\n', layout: 'use argui::{\n paint::CornerRadii,\n runtime::{Context, LayoutSnapshot, Render},\n text::TextStyle,\n ui::{Element, FlexWrap, Sides, length, percent},\n widgets::default_theme,\n};\n\n#[derive(Default)]\npub struct Example {\n compact: bool,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let width = if self.compact {\n percent(1.0)\n } else {\n length(180.0)\n };\n let card = |label| {\n Element::text(label)\n .text_style(TextStyle {\n color: theme.primary_foreground,\n weight: 650,\n ..TextStyle::default()\n })\n .width(width)\n .grow(1.0)\n .padding(Sides::length(22.0))\n .background(theme.primary)\n .radius(CornerRadii::all(12.0))\n };\n Element::row([card("Flexible"), card("Responsive"), card("Retained")])\n .width(percent(1.0))\n .padding(Sides::length(24.0))\n .gap(12.0)\n .flex_wrap(FlexWrap::Wrap)\n .background(theme.background)\n }\n\n fn layout_changed(&mut self, layout: &LayoutSnapshot, cx: &mut Context) {\n let compact = layout.viewport_size().width < 540.0;\n if self.compact != compact {\n self.compact = compact;\n cx.notify();\n }\n }\n}\n', mental_model: 'use argui::{\n paint::{Border, CornerRadii},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, FlexWrap, Sides, length, percent},\n widgets::default_theme,\n};\n\npub struct Example;\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let stage = |label| {\n Element::text(label)\n .text_style(TextStyle {\n color: theme.primary_foreground,\n weight: 650,\n ..TextStyle::default()\n })\n .width(length(150.0))\n .grow(1.0)\n .padding(Sides::length(18.0))\n .background(theme.primary)\n .border(Border::all(1.0, theme.ring))\n .radius(CornerRadii::all(10.0))\n };\n Element::row([\n stage("Model state"),\n stage("Element tree"),\n stage("Layout + text"),\n stage("WGPU paint"),\n ])\n .width(percent(1.0))\n .padding(Sides::length(24.0))\n .gap(10.0)\n .flex_wrap(FlexWrap::Wrap)\n .background(theme.background)\n }\n}\n', overlays: - 'use argui::{\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, EventType, Sides, UiEventKind, percent},\n widgets::{Button, Dialog, DialogBehavior, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n open: bool,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let close_key =\n DialogBehavior::new("example-dialog", "Example dialog", self.open).close_key();\n let content = Element::column([\n Element::text("A real modal portal").text_style(TextStyle {\n color: theme.foreground,\n font_size: 24.0,\n weight: 700,\n ..TextStyle::default()\n }),\n Element::text("Focus stays inside until the dialog closes.").text_style(TextStyle {\n color: theme.muted_foreground,\n ..TextStyle::default()\n }),\n Button::new(close_key, "Close dialog", theme.outline_button()).build(),\n ])\n .gap(14.0);\n Dialog::new(\n "example-dialog",\n "Example dialog",\n self.open,\n Button::new("open-dialog", "Open dialog", theme.button()).build(),\n content,\n )\n .build(theme)\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .background(theme.background)\n .on(cx.listener(EventType::Click, |app, event, cx| {\n if !matches!(event.kind, UiEventKind::Click(_)) {\n return;\n }\n match event.target_key() {\n Some("open-dialog") => app.open = true,\n Some(key) if key.ends_with("::close") || key.ends_with("::backdrop") => {\n app.open = false\n }\n _ => return,\n }\n cx.notify();\n }))\n }\n}\n', + 'use argui::{\n core::Color,\n paint::{Border, CornerRadii, PaintStyle, QuadStyle},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Axes, Element, FlexWrap, Overflow, ScrollConfig, Sides, auto, length, percent},\n widgets::{Button, Dialog, DialogBehavior, Popover, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n dialog_open: bool,\n solid_open: bool,\n blurred_open: bool,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let text = |value: &str, size: f32, weight: u16, color: Color| {\n Element::text(value).text_style(TextStyle {\n color,\n font_size: size,\n weight,\n ..TextStyle::default()\n })\n };\n let preview = |overlay: Element| {\n let backdrop = Element::column([\n Element::container([])\n .height(length(30.0))\n .background(Color::from_srgb8(91, 115, 241)),\n Element::container([])\n .height(length(30.0))\n .background(Color::from_srgb8(216, 96, 165)),\n Element::container([])\n .height(length(30.0))\n .background(Color::from_srgb8(32, 168, 133)),\n ])\n .gap(8.0)\n .padding(Sides {\n top: length(68.0),\n ..Sides::length(16.0)\n });\n Element::container([\n backdrop,\n overlay.absolute(Sides {\n top: length(18.0),\n left: length(16.0),\n right: auto(),\n bottom: auto(),\n }),\n ])\n .height(length(198.0))\n .width(percent(1.0))\n .background(theme.muted)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(10.0))\n };\n let card = |title: &str, description: &str, overlay: Element| {\n Element::column([\n text(title, 16.0, 650, theme.foreground),\n text(description, 13.0, 400, theme.muted_foreground).min_height(length(38.0)),\n preview(overlay),\n ])\n .width(length(232.0))\n .max_width(percent(1.0))\n .grow(1.0)\n .gap(10.0)\n };\n\n let solid_content = Element::column([\n text("Solid surface", 18.0, 700, theme.foreground),\n text(\n "An opaque panel with backdrop blur disabled.",\n 13.0,\n 400,\n theme.muted_foreground,\n ),\n Button::new("close-solid", "Close solid popover", theme.outline_button())\n .on_click(cx.callback(|app| app.solid_open = false))\n .build(),\n ])\n .gap(12.0);\n let solid = Popover::new(\n "solid-popover",\n "Solid popover",\n self.solid_open,\n Button::new("solid-popover", "Open solid", theme.outline_button()).build(),\n solid_content,\n )\n .on_open_change(cx.value_callback(|app, open| app.solid_open = open))\n .backdrop_blur(0.0)\n .size(238.0, 220.0)\n .build(theme);\n\n let blurred_content = Element::column([\n text("Backdrop blur", 18.0, 700, theme.foreground),\n text(\n "Translucent paint keeps the colored backdrop visible through the blur.",\n 13.0,\n 400,\n theme.muted_foreground,\n ),\n Button::new(\n "close-blurred",\n "Close blurred popover",\n theme.outline_button(),\n )\n .on_click(cx.callback(|app| app.blurred_open = false))\n .build(),\n ])\n .gap(12.0);\n let blurred = Popover::new(\n "blurred-popover",\n "Blurred popover",\n self.blurred_open,\n Button::new("blurred-popover", "Open with blur", theme.outline_button()).build(),\n blurred_content,\n )\n .on_open_change(cx.value_callback(|app, open| app.blurred_open = open))\n .paint(PaintStyle::new(\n QuadStyle::solid(theme.popover.with_alpha(0.78))\n .border(Border::all(1.0, theme.popover_border))\n .radius(CornerRadii::all(8.0)),\n ))\n .backdrop_blur(12.0)\n .size(258.0, 240.0)\n .build(theme);\n\n let behavior = DialogBehavior::new("example-dialog", "Example dialog", self.dialog_open);\n let close_key = behavior.close_key();\n let trigger_key = behavior.trigger_key();\n let dialog_content = Element::column([\n text("A real modal portal", 24.0, 700, theme.foreground),\n text(\n "Focus stays inside until the dialog closes.",\n 14.0,\n 400,\n theme.muted_foreground,\n ),\n Button::new(close_key, "Close dialog", theme.outline_button()).build(),\n ])\n .gap(14.0);\n let dialog = Dialog::new(\n "example-dialog",\n "Example dialog",\n self.dialog_open,\n Button::new(trigger_key, "Open dialog", theme.button()).build(),\n dialog_content,\n )\n .on_open_change(cx.value_callback(|app, open| app.dialog_open = open))\n .build(theme);\n\n Element::column([\n text("Overlay surfaces", 28.0, 760, theme.foreground),\n text(\n "Compare an opaque popover, a translucent blurred popover, and a focus-trapping dialog.",\n 14.0,\n 400,\n theme.muted_foreground,\n ),\n Element::row([\n card(\n "No blur",\n "Opaque surface over the same colored stage.",\n solid,\n ),\n card(\n "With blur",\n "Translucent surface using a 12 px backdrop blur.",\n blurred,\n ),\n card(\n "Modal dialog",\n "Backdrop, focus trap, Escape, and focus restoration.",\n dialog,\n ),\n ])\n .flex_wrap(FlexWrap::Wrap)\n .gap(16.0),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(18.0)\n .background(theme.background)\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Auto,\n })\n .scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone()))\n }\n}\n', + performance: + 'use argui::{\n accessibility::{Role, Semantics},\n core::{Color, Point, Transform2D},\n paint::{Border, CornerRadii},\n runtime::{Context, Render},\n text::TextStyle,\n ui::{\n AlignItems, CursorIcon, Element, EventType, FocusPolicy, GestureCapture, GestureDelivery,\n GestureKind, GesturePhase, GestureSet, Interaction, JustifyContent, PanGesture, Sides,\n UiEventKind, UserSelect, length, percent,\n },\n widgets::default_theme,\n};\n\n#[derive(Default)]\npub struct Example {\n offset: Point,\n velocity: Point,\n delivered_updates: u64,\n dragging: bool,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let orb = Element::row([\n Element::text(if self.dragging { "HOLDING" } else { "DRAG" }).text_style(TextStyle {\n color: Color::WHITE,\n font_size: 11.0,\n line_height: 15.0,\n weight: 760,\n ..TextStyle::default()\n }),\n ])\n .absolute(Sides {\n left: length(220.0),\n right: argui::ui::auto(),\n top: length(72.0),\n bottom: argui::ui::auto(),\n })\n .width(length(88.0))\n .height(length(56.0))\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::CENTER)\n .background(theme.primary)\n .radius(CornerRadii::all(14.0))\n .transform(Transform2D::IDENTITY.translate(self.offset.x, self.offset.y));\n let pad = Element::container([orb])\n .keyed("frame-coalesced-pad")\n .width(percent(1.0))\n .height(length(200.0))\n .background(theme.muted)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(14.0))\n .user_select(UserSelect::None)\n .interaction(\n Interaction::default()\n .focus_policy(FocusPolicy::TabStop)\n .cursor(if self.dragging {\n CursorIcon::Grabbing\n } else {\n CursorIcon::Grab\n })\n .gestures(\n GestureSet::EMPTY.pan(\n PanGesture::default()\n .immediate()\n .capture(GestureCapture::OnPress)\n .delivery(GestureDelivery::FrameCoalesced),\n ),\n ),\n )\n .semantics(\n Semantics::new(Role::Group)\n .label("Frame-coalesced drag pad")\n .description("Drag to observe one visual update per available frame"),\n )\n .on(cx.listener(EventType::Gesture, |example, event, cx| {\n let UiEventKind::Gesture(gesture) = event.kind else {\n return;\n };\n let GestureKind::Pan {\n total, velocity, ..\n } = gesture.kind\n else {\n return;\n };\n if gesture.phase == GesturePhase::Started {\n example.delivered_updates = 0;\n example.dragging = true;\n }\n if gesture.phase == GesturePhase::Changed {\n example.delivered_updates = example.delivered_updates.saturating_add(1);\n }\n example.offset =\n Point::new(total.x.clamp(-210.0, 210.0), total.y.clamp(-68.0, 68.0));\n example.velocity = velocity;\n if matches!(gesture.phase, GesturePhase::Ended | GesturePhase::Cancelled) {\n example.dragging = false;\n }\n event.stop_propagation();\n cx.notify();\n }));\n let status = format!(\n "Delivered frame updates: {} · velocity {:.0}, {:.0} px/s",\n self.delivered_updates, self.velocity.x, self.velocity.y\n );\n\n Element::column([\n Element::text("Frame-coalesced continuous input").text_style(TextStyle {\n color: theme.foreground,\n font_size: 18.0,\n line_height: 24.0,\n weight: 700,\n ..TextStyle::default()\n }),\n Element::text(\n "Move quickly: position, velocity and this status are delivered together at most once per available display frame.",\n )\n .text_style(TextStyle {\n color: theme.muted_foreground,\n ..TextStyle::default()\n }),\n pad,\n Element::text(status.clone())\n .text_style(TextStyle {\n color: theme.muted_foreground,\n font_size: 13.0,\n line_height: 18.0,\n weight: 550,\n ..TextStyle::default()\n })\n .semantics(Semantics::new(Role::Status).label(status)),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(14.0)\n .background(theme.background)\n }\n}\n', + platform_roadmap: + 'use argui::{\n runtime::{Context, Render},\n text::TextStyle,\n ui::{AlignItems, Axes, Element, JustifyContent, Overflow, ScrollConfig, Sides, percent},\n widgets::{Button, default_theme},\n};\n\nconst SHIPPING: [(&str, &str); 4] = [\n ("Text input / IME", "Shipping"),\n ("Safe areas, keyboard, and system bars", "Shipping"),\n ("Live activity progress", "Shipping"),\n ("Background activity foundation", "Shipping"),\n];\n\nconst PLANNED: [(&str, &str); 29] = [\n ("Accessibility device validation", "Highest priority"),\n ("Camera", "Medium priority"),\n ("Clipboard", "High priority"),\n ("Drag and drop", "High priority"),\n ("Haptics", "High priority"),\n ("File picker", "High priority"),\n ("Photo picker", "High priority"),\n ("Share sheet", "High priority"),\n ("Biometrics", "High priority"),\n ("Passkeys and credentials", "High priority"),\n ("Secure storage", "High priority"),\n ("Notifications", "High priority"),\n ("Home-screen widgets", "High priority"),\n ("Location", "Medium priority"),\n ("Motion and sensors", "Medium priority"),\n ("Bluetooth LE", "Medium priority"),\n ("NFC", "Medium priority"),\n ("UWB and ranging", "High priority"),\n ("Audio input and output", "High priority"),\n ("Video encode and decode", "Medium priority"),\n ("Mobile WebView", "High priority"),\n ("Deep links", "High priority"),\n ("Network status", "Medium priority"),\n ("Gamepads", "Medium priority"),\n ("Mouse and stylus validation", "High priority"),\n ("Store and in-app purchases", "Medium priority"),\n ("Speech and text to speech", "Later"),\n ("Contacts and calendar", "Later"),\n ("Health integrations", "Later"),\n];\n\n#[derive(Default)]\npub struct Example {\n expanded: bool,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let rows = SHIPPING\n .into_iter()\n .map(|(capability, status)| (capability, status, true))\n .chain(\n self.expanded\n .then_some(PLANNED)\n .into_iter()\n .flatten()\n .map(|(capability, priority)| (capability, priority, false)),\n )\n .map(|(capability, status, shipping)| {\n Element::row([\n Element::text(capability).text_style(TextStyle {\n color: theme.foreground,\n weight: 600,\n ..TextStyle::default()\n }),\n Element::text(status).text_style(TextStyle {\n color: if shipping {\n theme.primary\n } else {\n theme.muted_foreground\n },\n font_size: 12.0,\n weight: 650,\n ..TextStyle::default()\n }),\n ])\n .align_items(AlignItems::CENTER)\n .justify_content(JustifyContent::SPACE_BETWEEN)\n .gap(12.0)\n .padding(Sides::length(12.0))\n .background(theme.card)\n .border(argui::paint::Border::all(1.0, theme.border))\n .radius(argui::paint::CornerRadii::all(8.0))\n });\n Element::column([\n Element::text("Mobile integration roadmap").text_style(TextStyle {\n color: theme.foreground,\n font_size: 28.0,\n weight: 760,\n ..TextStyle::default()\n }),\n Element::text(\n "Shipping means implemented now. Every other row is planned, not promised as available.",\n )\n .text_style(TextStyle {\n color: theme.muted_foreground,\n font_size: 14.0,\n ..TextStyle::default()\n }),\n Button::new(\n "toggle-roadmap",\n if self.expanded {\n "Show shipping only"\n } else {\n "Show the full planned roadmap"\n },\n theme.outline_button(),\n )\n .on_click(cx.callback(|app| app.expanded = !app.expanded))\n .build(),\n Element::column(rows).gap(8.0),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(24.0))\n .gap(14.0)\n .background(theme.background)\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Auto,\n })\n .scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone()))\n }\n}\n', + platform_support: + 'use argui::{\n runtime::{Context, Render},\n text::TextStyle,\n ui::{Axes, Element, Overflow, ScrollConfig, Sides, length, percent},\n widgets::default_theme,\n};\n\nconst PLATFORMS: [(&str, &str, &str); 6] = [\n (\n "Linux",\n "Supported · runtime-tested",\n "Native WGPU, input, accessibility, windows, and the complete gallery.",\n ),\n (\n "Windows",\n "Supported · CI-compiled",\n "Native WGPU with DirectX 12 and Vulkan fallback paths.",\n ),\n (\n "macOS",\n "Supported · CI-compiled",\n "Native AppKit windowing, Metal rendering, and desktop integration.",\n ),\n (\n "WebAssembly",\n "Supported · browser-tested",\n "WebGPU rendering, browser semantics, and the complete live gallery.",\n ),\n (\n "Android",\n "Preview",\n "Cross-compilation, APK/AAB packaging, IME, safe areas, and activity progress.",\n ),\n (\n "iOS",\n "Preview",\n "XCFramework/Simulator packaging, safe areas, IME, and ActivityKit progress.",\n ),\n];\n\npub struct Example;\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let cards = PLATFORMS.into_iter().map(|(platform, status, detail)| {\n Element::column([\n Element::text(platform).text_style(TextStyle {\n color: theme.foreground,\n font_size: 18.0,\n weight: 700,\n ..TextStyle::default()\n }),\n Element::text(status).text_style(TextStyle {\n color: theme.primary,\n font_size: 13.0,\n weight: 650,\n ..TextStyle::default()\n }),\n Element::text(detail).text_style(TextStyle {\n color: theme.muted_foreground,\n font_size: 13.0,\n ..TextStyle::default()\n }),\n ])\n .padding(Sides::length(16.0))\n .gap(7.0)\n .background(theme.card)\n .border(argui::paint::Border::all(1.0, theme.border))\n .radius(argui::paint::CornerRadii::all(10.0))\n });\n Element::column([\n Element::text("Argui platform support").text_style(TextStyle {\n color: theme.foreground,\n font_size: 28.0,\n weight: 760,\n ..TextStyle::default()\n }),\n Element::text(\n "Desktop and Web are supported today. Android and iOS are explicit preview targets.",\n )\n .text_style(TextStyle {\n color: theme.muted_foreground,\n font_size: 14.0,\n ..TextStyle::default()\n }),\n Element::column(cards).gap(10.0),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .min_height(length(0.0))\n .padding(Sides::length(24.0))\n .gap(16.0)\n .background(theme.background)\n .overflow(Axes {\n x: Overflow::Hidden,\n y: Overflow::Auto,\n })\n .scroll_config(ScrollConfig::default().scrollbar(theme.scrollbar.clone()))\n }\n}\n', project_structure: 'use argui::{\n paint::{Border, CornerRadii},\n runtime::{Context, Render},\n text::{FontFamily, TextStyle},\n ui::{Element, Sides, percent},\n widgets::default_theme,\n};\n\npub struct Example;\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let file = |name, purpose| {\n Element::row([\n Element::text(name).text_style(TextStyle {\n family: FontFamily::Monospace,\n color: theme.primary,\n weight: 650,\n ..TextStyle::default()\n }),\n Element::text(purpose)\n .text_style(TextStyle {\n color: theme.muted_foreground,\n ..TextStyle::default()\n })\n .grow(1.0),\n ])\n .gap(16.0)\n .padding(Sides::length(14.0))\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(8.0))\n };\n Element::column([\n file("main.rs", "target entry point"),\n file("app.rs", "state and event routing"),\n file("app/view.rs", "Element composition"),\n file("services/", "domain I/O"),\n ])\n .width(percent(1.0))\n .padding(Sides::length(24.0))\n .gap(9.0)\n .background(theme.background)\n }\n}\n', styling: - 'use std::sync::Arc;\n\nuse argui::{\n core::{Color, ColorScheme},\n paint::{Border, CornerRadii},\n runtime::{Context, Render},\n text::TextStyle,\n theme::{ThemeOverrides, ThemeValue},\n ui::{Element, EventType, FlexWrap, Sides, UiEventKind, percent},\n widgets::{Button, WidgetTheme, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n scheme: Option,\n accent: usize,\n custom_tokens: bool,\n}\n\nfn accent(index: usize, fallback: Color) -> Color {\n match index {\n 1 => Color::from_srgb8(124, 58, 237),\n 2 => Color::from_srgb8(217, 119, 6),\n _ => fallback,\n }\n}\n\nfn custom_overrides(scheme: ColorScheme) -> Arc {\n let mut tokens = ThemeOverrides::default();\n let (background, card, muted, border) = match scheme {\n ColorScheme::Light => (\n Color::from_srgb8(245, 243, 255),\n Color::from_srgb8(255, 255, 255),\n Color::from_srgb8(237, 233, 254),\n Color::from_srgb8(196, 181, 253),\n ),\n ColorScheme::Dark => (\n Color::from_srgb8(24, 20, 38),\n Color::from_srgb8(34, 28, 53),\n Color::from_srgb8(49, 40, 74),\n Color::from_srgb8(109, 88, 164),\n ),\n };\n for (name, color) in [\n ("background", background),\n ("card", card),\n ("muted", muted),\n ("secondary", muted),\n ("border", border),\n ("input-border", border),\n ] {\n tokens.set(name, ThemeValue::Color(color));\n }\n tokens.set("overlay-blur", ThemeValue::Number(10.0));\n Arc::new(tokens)\n}\n\nfn choice(key: &\'static str, label: &\'static str, selected: bool, theme: &WidgetTheme) -> Element {\n let style = if selected {\n theme.button()\n } else {\n theme.outline_button()\n };\n Button::new(key, label, style).build()\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let mut source = cx.environment().clone();\n source.color_scheme = self.scheme.unwrap_or(source.color_scheme);\n source.primary = accent(self.accent, source.primary);\n if self.custom_tokens {\n source.theme_overrides = Some(custom_overrides(source.color_scheme));\n }\n let themes = default_theme(&source);\n let theme = themes.resolve(source.color_scheme);\n let text = |value, size, weight, color| {\n Element::text(value).text_style(TextStyle {\n color,\n font_size: size,\n line_height: size * 1.35,\n weight,\n ..TextStyle::default()\n })\n };\n\n let schemes = Element::row([\n choice("system", "System", self.scheme.is_none(), theme),\n choice(\n "light",\n "Light",\n self.scheme == Some(ColorScheme::Light),\n theme,\n ),\n choice(\n "dark",\n "Dark",\n self.scheme == Some(ColorScheme::Dark),\n theme,\n ),\n ])\n .gap(8.0)\n .flex_wrap(FlexWrap::Wrap);\n let accents = Element::row([\n choice("blue", "Blue", self.accent == 0, theme),\n choice("violet", "Violet", self.accent == 1, theme),\n choice("amber", "Amber", self.accent == 2, theme),\n choice(\n "tokens",\n if self.custom_tokens {\n "Reset tokens"\n } else {\n "Override tokens"\n },\n self.custom_tokens,\n theme,\n ),\n ])\n .gap(8.0)\n .flex_wrap(FlexWrap::Wrap);\n let preview = Element::column([\n text("Live theme preview", 24.0, 720, theme.foreground),\n text(\n if self.custom_tokens {\n "Background, card, muted, borders, inputs and blur are overridden."\n } else {\n "Every widget derives its states from the resolved default theme."\n },\n 14.0,\n 450,\n theme.muted_foreground,\n ),\n Element::row([\n Button::new("preview-primary", "Primary", theme.button()).build(),\n Button::new("preview-secondary", "Secondary", theme.secondary_button()).build(),\n Button::new("preview-delete", "Delete", theme.destructive_button()).build(),\n ])\n .gap(8.0)\n .flex_wrap(FlexWrap::Wrap),\n ])\n .padding(Sides::length(18.0))\n .gap(12.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(if self.custom_tokens {\n 24.0\n } else {\n 12.0\n }));\n\n Element::column([\n text("Theme configurator", 28.0, 760, theme.foreground),\n schemes,\n accents,\n preview,\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(20.0))\n .gap(14.0)\n .background(theme.background)\n .on(cx.listener(EventType::Click, |app, event, cx| {\n if !matches!(event.kind, UiEventKind::Click(_)) {\n return;\n }\n match event.target_key() {\n Some("system") => app.scheme = None,\n Some("light") => app.scheme = Some(ColorScheme::Light),\n Some("dark") => app.scheme = Some(ColorScheme::Dark),\n Some("blue") => app.accent = 0,\n Some("violet") => app.accent = 1,\n Some("amber") => app.accent = 2,\n Some("tokens") => app.custom_tokens = !app.custom_tokens,\n _ => return,\n }\n cx.notify();\n }))\n }\n}\n', + 'use std::sync::Arc;\n\nuse argui::{\n core::{Color, ColorScheme},\n paint::{Border, CornerRadii},\n runtime::{Context, Render},\n text::TextStyle,\n theme::{ThemeOverrides, ThemeValue},\n ui::{Element, EventHandler, FlexWrap, Sides, percent},\n widgets::{Button, WidgetTheme, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n scheme: Option,\n accent: usize,\n custom_tokens: bool,\n}\n\nfn accent(index: usize, fallback: Color) -> Color {\n match index {\n 1 => Color::from_srgb8(124, 58, 237),\n 2 => Color::from_srgb8(217, 119, 6),\n _ => fallback,\n }\n}\n\nfn custom_overrides(scheme: ColorScheme) -> Arc {\n let mut tokens = ThemeOverrides::default();\n let (background, card, muted, border) = match scheme {\n ColorScheme::Light => (\n Color::from_srgb8(245, 243, 255),\n Color::from_srgb8(255, 255, 255),\n Color::from_srgb8(237, 233, 254),\n Color::from_srgb8(196, 181, 253),\n ),\n ColorScheme::Dark => (\n Color::from_srgb8(24, 20, 38),\n Color::from_srgb8(34, 28, 53),\n Color::from_srgb8(49, 40, 74),\n Color::from_srgb8(109, 88, 164),\n ),\n };\n for (name, color) in [\n ("background", background),\n ("card", card),\n ("muted", muted),\n ("secondary", muted),\n ("border", border),\n ("input-border", border),\n ] {\n tokens.set(name, ThemeValue::Color(color));\n }\n tokens.set("overlay-blur", ThemeValue::Number(10.0));\n Arc::new(tokens)\n}\n\nfn choice(\n key: &\'static str,\n label: &\'static str,\n selected: bool,\n theme: &WidgetTheme,\n handler: EventHandler,\n) -> Element {\n let style = if selected {\n theme.button()\n } else {\n theme.outline_button()\n };\n Button::new(key, label, style).on_click(handler).build()\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let mut source = cx.environment().clone();\n source.color_scheme = self.scheme.unwrap_or(source.color_scheme);\n source.primary = accent(self.accent, source.primary);\n if self.custom_tokens {\n source.theme_overrides = Some(custom_overrides(source.color_scheme));\n }\n let themes = default_theme(&source);\n let theme = themes.resolve(source.color_scheme);\n let text = |value, size, weight, color| {\n Element::text(value).text_style(TextStyle {\n color,\n font_size: size,\n line_height: size * 1.35,\n weight,\n ..TextStyle::default()\n })\n };\n\n let schemes = Element::row([\n choice(\n "system",\n "System",\n self.scheme.is_none(),\n theme,\n cx.callback(|app| app.scheme = None),\n ),\n choice(\n "light",\n "Light",\n self.scheme == Some(ColorScheme::Light),\n theme,\n cx.callback(|app| app.scheme = Some(ColorScheme::Light)),\n ),\n choice(\n "dark",\n "Dark",\n self.scheme == Some(ColorScheme::Dark),\n theme,\n cx.callback(|app| app.scheme = Some(ColorScheme::Dark)),\n ),\n ])\n .gap(8.0)\n .flex_wrap(FlexWrap::Wrap);\n let accents = Element::row([\n choice(\n "blue",\n "Blue",\n self.accent == 0,\n theme,\n cx.callback(|app| app.accent = 0),\n ),\n choice(\n "violet",\n "Violet",\n self.accent == 1,\n theme,\n cx.callback(|app| app.accent = 1),\n ),\n choice(\n "amber",\n "Amber",\n self.accent == 2,\n theme,\n cx.callback(|app| app.accent = 2),\n ),\n choice(\n "tokens",\n if self.custom_tokens {\n "Reset tokens"\n } else {\n "Override tokens"\n },\n self.custom_tokens,\n theme,\n cx.callback(|app| app.custom_tokens = !app.custom_tokens),\n ),\n ])\n .gap(8.0)\n .flex_wrap(FlexWrap::Wrap);\n let preview = Element::column([\n text("Live theme preview", 24.0, 720, theme.foreground),\n text(\n if self.custom_tokens {\n "Background, card, muted, borders, inputs and blur are overridden."\n } else {\n "Every widget derives its states from the resolved default theme."\n },\n 14.0,\n 450,\n theme.muted_foreground,\n ),\n Element::row([\n Button::new("preview-primary", "Primary", theme.button()).build(),\n Button::new("preview-secondary", "Secondary", theme.secondary_button()).build(),\n Button::new("preview-delete", "Delete", theme.destructive_button()).build(),\n ])\n .gap(8.0)\n .flex_wrap(FlexWrap::Wrap),\n ])\n .padding(Sides::length(18.0))\n .gap(12.0)\n .background(theme.card)\n .border(Border::all(1.0, theme.border))\n .radius(CornerRadii::all(if self.custom_tokens {\n 24.0\n } else {\n 12.0\n }));\n\n Element::column([\n text("Theme configurator", 28.0, 760, theme.foreground),\n schemes,\n accents,\n preview,\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(20.0))\n .gap(14.0)\n .background(theme.background)\n }\n}\n', tasks: - 'use std::time::Duration;\n\nuse argui::{\n runtime::{\n Context, Render,\n tasks::{TaskHandle, sleep},\n },\n text::TextStyle,\n ui::{Element, EventType, Sides, UiEventKind, percent},\n widgets::{Button, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n result: Option,\n task: Option,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let status = self.result.as_deref().unwrap_or("No task running");\n Element::column([\n Button::new("load", "Load asynchronously", theme.button())\n .enabled(self.task.is_none())\n .build(),\n Element::text(status).text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n }),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(16.0)\n .background(theme.background)\n .on(cx.listener(EventType::Click, |app, event, cx| {\n if event.target_key() != Some("load") || !matches!(event.kind, UiEventKind::Click(_)) {\n return;\n }\n app.result = Some("Loading…".into());\n app.task = cx\n .spawn(\n async { sleep(Duration::from_millis(650)).await },\n |app, result, cx| {\n result.expect("the local timer completes");\n app.result = Some("Loaded without blocking the UI".into());\n app.task = None;\n cx.notify();\n },\n )\n .ok();\n cx.notify();\n }))\n }\n}\n', + 'use std::time::Duration;\n\nuse argui::{\n runtime::{\n Context, Render,\n tasks::{TaskHandle, sleep},\n },\n text::TextStyle,\n ui::{Element, Sides, percent},\n widgets::{Button, default_theme},\n};\n\n#[derive(Default)]\npub struct Example {\n result: Option,\n task: Option,\n}\n\nimpl Render for Example {\n fn render(&mut self, cx: &mut Context) -> Element {\n let themes = default_theme(cx.environment());\n let theme = themes.resolve(cx.environment().color_scheme);\n let status = self.result.as_deref().unwrap_or("No task running");\n Element::column([\n Button::new("load", "Load asynchronously", theme.button())\n .enabled(self.task.is_none())\n .on_click(cx.event_handler(|app, _, cx| {\n app.result = Some("Loading…".into());\n app.task = cx\n .spawn(\n async { sleep(Duration::from_millis(650)).await },\n |app, result, cx| {\n result.expect("the local timer completes");\n app.result = Some("Loaded without blocking the UI".into());\n app.task = None;\n cx.notify();\n },\n )\n .ok();\n cx.notify();\n }))\n .build(),\n Element::text(status).text_style(TextStyle {\n color: theme.foreground,\n ..TextStyle::default()\n }),\n ])\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .gap(16.0)\n .background(theme.background)\n }\n}\n', } as const diff --git a/website/app/data/doc-routes.ts b/website/app/data/doc-routes.ts index bdc8b9c5..911324ea 100644 --- a/website/app/data/doc-routes.ts +++ b/website/app/data/doc-routes.ts @@ -5,6 +5,7 @@ export const docRoutes = [ 'start/counter', 'essentials/layout', 'essentials/events', + 'essentials/interaction-api', 'essentials/styling', 'essentials/accessibility', 'essentials/animation', @@ -16,4 +17,7 @@ export const docRoutes = [ 'architecture/project-structure', 'architecture/clean-code', 'architecture/custom-elements', + 'technicalities/performance', + 'platforms/support', + 'platforms/roadmap', ] as const diff --git a/website/app/data/docs.ts b/website/app/data/docs.ts index 3eb5edfd..934b8de5 100644 --- a/website/app/data/docs.ts +++ b/website/app/data/docs.ts @@ -2,18 +2,20 @@ import { docExampleSources } from './doc-example-sources.generated' export type DocCode = { filename: string; code: string } export type DocExample = { id: string; path: string; source: string } +export type DocTable = { headers: string[]; rows: string[][] } export type DocSection = { id: string title: string paragraphs: string[] bullets?: string[] code?: DocCode + table?: DocTable note?: string } export type DocGuide = { slug: string - category: 'Start here' | 'Essentials' | 'Advanced' | 'Architecture' + category: 'Start here' | 'Essentials' | 'Advanced' | 'Technicalities' | 'Platforms' level: 'Beginner' | 'Intermediate' | 'Advanced' minutes: number title: string @@ -31,7 +33,7 @@ const example = (id: string, filename: keyof typeof docExampleSources): DocExamp }) const dependency = `[dependencies] -argui = { version = "0.2.1", features = ["widget-button"] }` +argui = { version = "0.3.0", features = ["widget-button"] }` const launch = `use argui::{ platform::{ApplicationConfig, ApplicationId, ApplicationIdentity, IconSet, WindowConfig}, @@ -77,15 +79,12 @@ impl Render for Counter { Element::column([ Element::text(format!("Count: {}", self.count)), Button::new("increment", "Increment", theme.button()) + .on_click(cx.callback(|app| { + app.count = app.count.saturating_add(1); + })) .build(), ]) .gap(12.0) - .on(cx.listener(EventType::Click, |app, event, cx| { - if event.target_key() == Some("increment") { - app.count = app.count.saturating_add(1); - cx.notify(); - } - })) } }` @@ -107,12 +106,29 @@ Element::column([ .gap(20.0)` const listener = `let save = Button::new("save", "Save", theme.button()) - .build() - .on(cx.listener(EventType::Click, |model, event, cx| { - debug_assert_eq!(event.target_key(), Some("save")); + .on_click(cx.callback(|model| { model.saved = true; - cx.notify(); - }));` + })) + .build();` + +const interactionCallbacks = `Slider::new("volume", "Preview volume", self.volume, range) + .on_change(cx.value_callback(|app, value| { + app.volume = value; + })) + .on_commit(cx.value_callback(|app, value| { + app.volume = value; + app.saved_volume = value; + })) + .build(theme)` + +const frameCoalesced = `Interaction::default().gestures( + GestureSet::EMPTY.pan( + PanGesture::default() + .immediate() + .capture(GestureCapture::OnPress) + .delivery(GestureDelivery::FrameCoalesced), + ), +)` const tasks = `fn search(&mut self, cx: &mut Context) -> Result<(), TaskError> { let query = self.query.clone(); @@ -128,44 +144,67 @@ const tasks = `fn search(&mut self, cx: &mut Context) -> Result<(), TaskEr }` const custom = `#[derive(Debug)] -struct Ruler { - zoom: f32, - color: Color, +struct EditorTimeline { + background: Color, + track: Color, + seconds: f32, + grid: Color, + playhead: Color, + clips: [Clip; 5], } -impl CustomElement for Ruler { +impl CustomElement for EditorTimeline { type State = Vec; fn create_state(&self) -> Self::State { Vec::new() } - fn layout_revision(&self) -> u64 { u64::from(self.zoom.to_bits()) } + fn layout_revision(&self) -> u64 { u64::from(self.seconds.to_bits()) } fn paint_revision(&self) -> u64 { - u64::from(u32::from_le_bytes(self.color.to_srgba8())) + u64::from(u32::from_le_bytes(self.playhead.to_srgba8())) } fn layout( &self, _state: &mut Self::State, - _cx: &mut dyn CustomLayoutContext, + cx: &mut dyn CustomLayoutContext, ) -> Result { + for (index, clip) in self.clips.into_iter().enumerate() { + cx.place_child( + TIME_LABELS + TRACK_LABELS + index, + Rect::new( + Point::new( + GUTTER + clip.start * PIXELS_PER_SECOND, + RULER_HEIGHT + clip.track as f32 * TRACK_HEIGHT + 6.0, + ), + Size::new(clip.duration * PIXELS_PER_SECOND, TRACK_HEIGHT - 12.0), + ), + )?; + } Ok(CustomMeasurement { - size: Size::new(800.0 * self.zoom, 80.0), + size: Size::new(764.0, 206.0), baseline: None, }) } - fn prepare(&self, ticks: &mut Self::State, size: Size) { - let spacing = 40.0 * self.zoom; + fn prepare(&self, ticks: &mut Self::State, _size: Size) { ticks.clear(); - ticks.extend((0..(size.width / spacing).ceil() as usize).map(|tick| tick as f32 * spacing)); + ticks.extend((0..=20).map(|second| 76.0 + second as f32 * 32.0)); } fn paint(&self, ticks: &mut Self::State, cx: &mut CustomPaintContext<'_>) { - for &x in ticks.iter() { + for (second, x) in ticks.iter().copied().enumerate() { cx.quad( - Rect::new(Point::new(x, 0.0), Size::new(1.0, cx.bounds.size.height)), - QuadStyle::solid(self.color), + Rect::new( + Point::new(x, if second.is_multiple_of(5) { 24.0 } else { 32.0 }), + Size::new(1.0, 174.0), + ), + QuadStyle::solid(self.grid), ); } + let playhead_x = GUTTER + self.seconds * PIXELS_PER_SECOND; + cx.quad( + Rect::new(Point::new(playhead_x - 1.0, 10.0), Size::new(2.0, 196.0)), + QuadStyle::solid(self.playhead), + ); } }` @@ -186,7 +225,7 @@ export const docs: DocGuide[] = [ id: 'requirements', title: 'What you need', paragraphs: [ - 'Argui 0.2.1 requires Rust 1.89 or newer. The repository itself currently recommends a newer toolchain for contributors, while the workspace manifest remains the source of truth for the minimum supported Rust version.', + 'Argui 0.3.0 requires Rust 1.89 or newer. The repository itself currently recommends a newer toolchain for contributors, while the workspace manifest remains the source of truth for the minimum supported Rust version.', ], bullets: [ 'Rust and Cargo', @@ -321,18 +360,18 @@ export const docs: DocGuide[] = [ code: { filename: 'src/counter.rs', code: counter }, }, { - id: 'notify', - title: 'Notify after a visible change', + id: 'callback', + title: 'Local callbacks invalidate automatically', paragraphs: [ - 'Mutating the struct is immediate. cx.notify() marks dependent presentations dirty and wakes the host. Multiple notifications in one transaction coalesce; an idle application does not continuously request frames.', + 'Context::callback registers state-only work and invalidates this presentation after it returns. The button owns the activation binding, so pointer, touch, Enter, Space and accessibility click all reach the same callback.', ], - note: 'If you forget cx.notify(), the value changes in memory but the view is not scheduled to rebuild.', + note: 'Use Context::event_handler when you need the raw UiEvent, propagation control, focus or commands. That lower-level callback does not invalidate implicitly.', }, { - id: 'events', - title: 'Match stable targets', + id: 'controlled', + title: 'The model stays in control', paragraphs: [ - 'Events expose both stable keys and typed payloads. Match the control key, mutate state, then notify. Keyboard and accessibility activation synthesize the same click path as a pointer.', + 'The handler changes ordinary Rust state; the next render rebuilds the controlled widget from that state. Handler identities remain opaque and closures stay in the runtime rather than inside cloneable Element values.', ], }, ], @@ -399,17 +438,17 @@ export const docs: DocGuide[] = [ sections: [ { id: 'listeners', - title: 'Attach typed listeners', + title: 'Start with a direct widget callback', paragraphs: [ - 'Context::listener creates a handler owned by the current presentation. Element::on attaches it to any element. Dispatch follows capture, target, and bubbling phases.', + 'Ordinary buttons do not require bubbling, target-key comparisons or event-kind matching. Attach Context::callback to Button::on_click; typed widgets similarly expose on_input, on_change, on_select and on_open_change with useful payloads.', ], code: { filename: 'src/view.rs', code: listener }, }, { - id: 'payloads', - title: 'Read the typed payload', + id: 'delegation', + title: 'Use listeners for deliberate delegation', paragraphs: [ - 'UiEventKind distinguishes clicks, key input, text changes, gestures, scroll, dismiss, and selection changes. Check the kind before reading its payload and use target_key for stable application routing.', + 'Context::listener and Element::on remain the advanced layer for capture, bubbling, application-wide shortcuts and routers that intentionally handle many descendants. The runnable Events example demonstrates that pattern rather than presenting it as the basic button API.', ], }, { @@ -421,6 +460,92 @@ export const docs: DocGuide[] = [ }, ], }, + { + slug: 'essentials/interaction-api', + category: 'Essentials', + level: 'Beginner', + minutes: 10, + title: 'Choose the right interaction API', + description: + 'Match clicks, edits, continuous changes, commits, selections, and overlay state to the smallest typed callback.', + example: example('interaction-api', 'interaction_api'), + demoTitle: 'See continuous change, final commit, and click callbacks separately', + sources: [ + 'docs/widgets/interaction-api.md', + 'docs/simplified-api.md', + 'crates/argui-runtime/src/model/handler.rs', + 'crates/argui-widgets/src/slider.rs', + ], + sections: [ + { + id: 'decision-table', + title: 'Pick the callback that names the intent', + paragraphs: [ + 'Start with the widget method that describes the domain event. Direct handlers already unify pointer, touch, keyboard, and accessibility activation, so application code should not decode UiEvent for ordinary controls.', + ], + table: { + headers: ['Widget API', 'Use it for', 'Context helper'], + rows: [ + ['on_click', 'A button or one-shot action was activated', 'callback'], + ['on_input', 'Each new controlled text value while editing', 'input_callback'], + [ + 'on_submit', + 'A text value or form was deliberately submitted', + 'submit_callback or callback', + ], + [ + 'on_change', + 'Each next checkbox, switch, collection, color, or range value', + 'value_callback', + ], + [ + 'on_commit', + 'The final value after a continuous slider interaction', + 'value_callback', + ], + [ + 'on_select', + 'A stable option, index, date, row, page, or node was chosen', + 'value_callback', + ], + [ + 'on_open_change', + 'An overlay or disclosure requests open or closed state', + 'value_callback', + ], + [ + 'on_action / on_activate / on_navigate', + 'A stable command, item, cell, or destination was invoked', + 'value_callback', + ], + [ + 'listener + Element.on', + 'Capture, bubbling, delegation, shortcuts, or default prevention is intentional', + 'listener or event_handler', + ], + ], + }, + note: 'on_commit is not a replacement for on_change. Use on_change for live visual feedback and on_commit for expensive or durable work after the interaction ends.', + }, + { + id: 'controlled-values', + title: 'Keep values controlled', + paragraphs: [ + 'A handler reports intent or a next value. Store it in the model and pass it back on the next render. callback, value_callback, input_callback, and submit_callback invalidate the current presentation automatically.', + 'The live example uses on_change to update its preview and on_commit to record the final slider value. The button uses on_click because saving is a discrete action.', + ], + code: { filename: 'src/view.rs', code: interactionCallbacks }, + }, + { + id: 'advanced-handlers', + title: 'Reach for the event layer only when you need it', + paragraphs: [ + 'Use event_handler or value_event_handler when a callback must inspect the routed event, stop propagation, request focus, or issue commands. These advanced forms do not invalidate automatically, so call cx.notify() after changing visible state.', + 'Use Context::listener with Element::on for deliberate ancestor delegation, capture, passive or one-shot listeners, application-wide shortcuts, and custom controls. Typed widget callbacks remain additive and still travel through the same retained event pipeline.', + ], + }, + ], + }, { slug: 'essentials/styling', category: 'Essentials', @@ -581,7 +706,7 @@ export const docs: DocGuide[] = [ ], code: { filename: 'Cargo.toml', - code: '[dependencies]\nargui = { version = "0.2.1", features = ["tasks", "widget-input", "widget-button", "widget-vlist"] }', + code: '[dependencies]\nargui = { version = "0.3.0", features = ["tasks", "widget-input", "widget-button", "widget-vlist"] }', }, }, { @@ -653,7 +778,7 @@ export const docs: DocGuide[] = [ description: 'Build focus-safe overlay surfaces and optionally host them outside the native window bounds.', example: example('overlays', 'overlays'), - demoTitle: 'A modal dialog with focus management', + demoTitle: 'Solid and blurred popovers with a modal dialog', sources: [ 'docs/widgets/overlays.md', 'docs/platform/native-popovers.md', @@ -667,6 +792,13 @@ export const docs: DocGuide[] = [ 'Dialogs, popovers, tooltips, menus, sheets, and drawers keep their open state in the model. Their elements participate in normal layout, paint, event dispatch, and semantics.', ], }, + { + id: 'surfaces', + title: 'Choose an opaque or blurred surface', + paragraphs: [ + 'A solid popover disables backdrop blur and uses an opaque panel. A frosted popover combines translucent paint with backdrop blur so the content behind it remains visible without competing with the foreground text.', + ], + }, { id: 'focus', title: 'Select the correct focus scope', @@ -729,7 +861,7 @@ export const docs: DocGuide[] = [ }, { slug: 'architecture/mental-model', - category: 'Architecture', + category: 'Technicalities', level: 'Intermediate', minutes: 18, title: 'The Argui mental model', @@ -739,6 +871,57 @@ export const docs: DocGuide[] = [ demoTitle: 'The retained pipeline in a running application', sources: ['docs/architecture.md', 'docs/runtime/models.md', 'docs/rendering/primitives.md'], sections: [ + { + id: 'what-it-is', + title: 'What Argui is', + paragraphs: [ + 'Argui is a retained, GPU-rendered application UI runtime written in Rust. Application models own state and render cloneable Element descriptions; the runtime reconciles those descriptions into persistent presentation nodes, computes layout, paints through WGPU, and publishes an accessibility tree.', + 'It is designed for product interfaces that need explicit state, native input, deterministic updates, portable rendering, and opt-in platform integrations without a browser DOM as the primary runtime.', + ], + }, + { + id: 'retained-vs-immediate', + title: 'Retained versus immediate mode', + paragraphs: [ + 'Immediate-mode UI code describes and processes the interface afresh for each frame. Argui view code also returns a description, but the runtime retains the resulting nodes between renders and reconciles only what changed. Stable identity therefore matters: it preserves focus, scrolling, handlers, accessibility state, animation, and cached layout or paint work.', + ], + table: { + headers: ['Concern', 'Immediate mode', 'Argui retained mode'], + rows: [ + [ + 'Lifetime', + 'Recreated as part of each frame', + 'Nodes persist until reconciliation removes them', + ], + ['State', 'Often coupled to the frame loop', 'Owned explicitly by application models'], + ['Updates', 'Frame-oriented', 'Classified as semantic, paint, scroll, or layout work'], + [ + 'Identity', + 'Usually positional or call-site based', + 'Stable keys and model-owned handler slots', + ], + [ + 'Idle cost', + 'Commonly redraws continuously', + 'Requests frames only when work is pending', + ], + ], + }, + }, + { + id: 'what-it-is-not', + title: 'What Argui is not', + paragraphs: [ + 'Argui does not hide application state inside widgets, generate a web DOM for native targets, or make every operating-system service portable by pretending platform differences do not exist.', + ], + bullets: [ + 'Not an immediate-mode frame loop: view descriptions reconcile into retained nodes.', + 'Not an HTML/CSS wrapper: layout, text, paint, interaction, and semantics are Rust-native layers.', + 'Not a business-state store: widgets remain controlled by the owning application model.', + 'Not a universal native-services abstraction: shared contracts stay small and platform adapters remain explicit.', + 'Not a replacement for device and renderer testing: headless tests cover behavior, while browser and native checks cover integration.', + ], + }, { id: 'pipeline', title: 'Data flows through explicit layers', @@ -768,7 +951,7 @@ export const docs: DocGuide[] = [ }, { slug: 'architecture/project-structure', - category: 'Architecture', + category: 'Technicalities', level: 'Intermediate', minutes: 13, title: 'Structure a real application', @@ -811,7 +994,7 @@ export const docs: DocGuide[] = [ }, { slug: 'architecture/clean-code', - category: 'Architecture', + category: 'Technicalities', level: 'Advanced', minutes: 16, title: 'Write clean Argui code', @@ -857,14 +1040,14 @@ export const docs: DocGuide[] = [ }, { slug: 'architecture/custom-elements', - category: 'Architecture', + category: 'Technicalities', level: 'Advanced', minutes: 22, title: 'Create a custom element', description: 'Own custom measurement and paint while keeping children, interaction, semantics, and invalidation in Argui.', example: example('custom-elements', 'custom_elements'), - demoTitle: 'A draggable custom timeline rendered in WebAssembly', + demoTitle: 'A draggable video-editor timeline rendered in WebAssembly', sources: [ 'docs/ui/custom-elements.md', 'crates/argui-widget-gallery/src/pages/timeline.rs', @@ -884,7 +1067,7 @@ export const docs: DocGuide[] = [ paragraphs: [ 'State persists beside the retained node. layout_revision changes when measurement or child placement changes. paint_revision changes when prepared or recorded pixels change. Accurate revisions let Argui skip work safely.', ], - code: { filename: 'src/ruler.rs', code: custom }, + code: { filename: 'src/editor_timeline.rs', code: custom }, }, { id: 'integrate', @@ -892,13 +1075,328 @@ export const docs: DocGuide[] = [ paragraphs: [ 'Attach gestures and listeners through normal Element interaction, and attach Semantics for assistive technology. Paint does not implicitly define hit geometry. Use a stable key and explicit HitTestStyle when the custom shape needs non-rectangular or enlarged targets.', ], - note: 'The complete timeline source places retained button children, caches ruler ticks, supports pan and keyboard edits, and shares clip data across two mounted views.', + note: 'The exact example below places retained track labels and clips inside the custom layout, clips every label to its block, lets clips move in time or between tracks, and gives the playhead a forgiving draggable hit target plus keyboard and accessibility controls.', + }, + ], + }, + { + slug: 'technicalities/performance', + category: 'Technicalities', + level: 'Intermediate', + minutes: 12, + title: 'Continuous input and frame pacing', + description: + 'Keep drag, scrub, resize, and other high-frequency work aligned with the display refresh cadence.', + example: example('performance', 'performance'), + demoTitle: 'Drag with position, velocity, and status delivered once per available frame', + sources: [ + 'docs/performance/optimizations.md', + 'crates/argui-ui/src/tree/pointer.rs', + 'crates/argui-ui/src/gesture/config.rs', + 'crates/argui-widget-gallery/src/pages/drag_drop.rs', + ], + sections: [ + { + id: 'frame-coalescing', + title: 'FrameCoalesced is Argui’s requestAnimationFrame-shaped input mode', + paragraphs: [ + 'Browsers may produce pointer events faster than they can present frames. GestureDelivery::FrameCoalesced keeps the newest visual state for each gesture stream and delivers at most one Changed event on each available Argui display frame. This has the same scheduling intent as requestAnimationFrame, while remaining portable across browser and native hosts.', + 'The callback cadence is capped by the active display refresh rate—commonly 60, 120, or 144 Hz—and can be lower when the host is busy or backgrounded. It is not a timer and does not promise a fixed frequency.', + ], + code: { filename: 'src/view.rs', code: frameCoalesced }, + note: 'Started, Ended, and Cancelled are delivered immediately. Only repeated Changed samples are coalesced, so interaction boundaries are never delayed or lost.', + }, + { + id: 'preserved-data', + title: 'Coalescing preserves the data a visual interaction needs', + paragraphs: [ + 'For a pan, the delivered event uses the latest pointer position, total displacement, and velocity. Its delta is the sum of every raw delta received since the previous frame, so controlled split panes and other incremental consumers remain exactly under the pointer even when several input samples arrive between frames.', + 'The live example updates the card transform, velocity label, and delivered-update counter from the same frame-coalesced callback. No intermediate text layout or model invalidation runs between two presentable frames.', + ], + }, + { + id: 'choose-delivery', + title: 'Choose delivery from the workload', + paragraphs: [ + 'Use FrameCoalesced when each sample triggers rendering, layout, text shaping, hit-test changes, or substantial model work. Use Immediate only when every raw sample is itself application data and dropping intermediate samples would change the result.', + ], + table: { + headers: ['Delivery', 'Best for', 'Cost model'], + rows: [ + [ + 'FrameCoalesced', + 'Dragging, split resizing, scrubbing, visual sliders, hover previews', + 'At most one Changed callback per gesture stream and display frame', + ], + [ + 'Immediate', + 'Raw telemetry, input recording, or algorithms that consume every sample', + 'One callback per input event; application code owns batching', + ], + ], + }, + }, + { + id: 'keep-frame-work-bounded', + title: 'Keep each delivered frame bounded', + paragraphs: [ + 'Frame coalescing prevents redundant callbacks, but it cannot make an expensive callback cheap. Prefer transform or paint changes over layout, retain stable keys, avoid rebuilding unrelated ownership graphs, and move durable work to the final commit or release event.', + ], + bullets: [ + 'Use total displacement for absolute visual position and accumulated delta for incremental state.', + 'Keep velocity smoothing frame-rate independent by scaling it from elapsed frame time.', + 'Update dynamic labels in the same coalesced callback so their shaping is also capped to frame cadence.', + 'Measure input-to-display latency in Firefox and Chromium rather than counting raw pointer events.', + ], + }, + ], + }, + { + slug: 'platforms/support', + category: 'Platforms', + level: 'Beginner', + minutes: 10, + title: 'Supported platforms', + description: + 'See which targets are supported today, what CI proves, and where preview status still applies.', + example: example('platform-support', 'platform_support'), + demoTitle: 'The current support matrix rendered by Argui', + sources: ['README.md', 'docs/native-mobile.md', '.github/workflows/ci.yml'], + sections: [ + { + id: 'matrix', + title: 'Current support matrix', + paragraphs: [ + 'Desktop and WebAssembly are the supported product targets. Android and iOS use the same models, widgets, layout, text, and WGPU renderer, but remain explicit preview targets until physical-device, assistive-technology, lifecycle, and owner-signing validation is complete.', + ], + table: { + headers: ['Target', 'Status', 'Validated today', 'Important boundary'], + rows: [ + [ + 'Linux', + 'Supported · runtime-tested', + 'Native gallery, hidden-display interaction, WGPU, accessibility, all features', + 'Some integrations require Wayland/GTK system packages', + ], + [ + 'Windows', + 'Supported · CI-compiled', + 'Complete workspace, native implementation, DirectX 12 and Vulkan fallback paths', + 'Platform behavior still receives focused release validation', + ], + [ + 'macOS', + 'Supported · CI-compiled', + 'Complete workspace, AppKit integration, Metal surface, bundle-oriented updater path', + 'Signing and notarization belong to the application owner', + ], + [ + 'WebAssembly', + 'Supported · browser-tested', + 'WebGPU, browser semantics, responsive live gallery, documentation examples', + 'Requires a WebGPU-capable secure browser context', + ], + [ + 'Android', + 'Preview', + 'Cross-compile, debug APK, release AAB, IME, safe areas, activity progress', + 'Physical devices, TalkBack, lifecycle breadth, and Play signing need validation', + ], + [ + 'iOS', + 'Preview', + 'Cross-compile, XCFramework, Simulator app, safe areas, IME, ActivityKit bridge', + 'Physical devices, VoiceOver, provisioning, archive signing, and TestFlight need validation', + ], + ], + }, + }, + { + id: 'shared-core', + title: 'Share the core; keep native edges explicit', + paragraphs: [ + 'Application models, views, fonts, themes, tasks, and renderer configuration belong in a shared Rust crate. Desktop, WebAssembly, Android, and iOS launch that same application through target-specific entry points.', + 'A platform is not considered supported merely because Rust can compile for its target triple. The matrix distinguishes runtime tests, browser tests, compile checks, packaging checks, and preview-only native shells so the claim remains auditable.', + ], + }, + { + id: 'selection', + title: 'Select platform integrations deliberately', + paragraphs: [ + 'Core UI capabilities are selected by Cargo target. Native entry points and integrations such as trays, system dialogs, WebViews, native popovers, desktop backdrops, Android services, and iOS ActivityKit stay opt-in because their dependencies and lifecycle rules differ.', + ], + note: 'Preview means usable for development and cross-platform work, not a promise that every production device, store workflow, or native service has already been validated.', + }, + ], + }, + { + slug: 'platforms/roadmap', + category: 'Platforms', + level: 'Intermediate', + minutes: 14, + title: 'Native capability roadmap', + description: + 'Separate mobile capabilities that ship today from explicit future Android and iOS integrations.', + example: example('platform-roadmap', 'platform_roadmap'), + demoTitle: 'A scrollable shipping-versus-planned capability list', + sources: [ + 'docs/native-mobile.md', + 'crates/argui-platform/src/mobile.rs', + 'crates/argui-platform/src/mobile/android.rs', + 'crates/argui-platform/src/mobile/ios.rs', + ], + sections: [ + { + id: 'status', + title: 'Shipping foundations and planned adapters', + paragraphs: [ + 'Shipping identifies code present in the repository now. Planned identifies direction only: it is not available API, not a release promise, and not a schedule. Priority describes the intended order when mobile integration work resumes.', + ], + table: { + headers: ['Capability', 'Android foundation', 'iOS foundation', 'Roadmap state'], + rows: [ + [ + 'Text input / IME', + 'InputConnection and InputMethodManager path', + 'UIKit first-responder text input path', + 'Shipping', + ], + [ + 'Accessibility', + 'AccessibilityNodeInfo / provider validation', + 'UIAccessibilityElement validation', + 'Planned validation · Highest', + ], + [ + 'Safe areas, keyboard, system bars', + 'WindowInsets', + 'safeAreaInsets and keyboard lifecycle', + 'Shipping', + ], + [ + 'Live activity progress', + 'Foreground-service ongoing notification', + 'ActivityKit Live Activity', + 'Shipping', + ], + [ + 'Background activity foundation', + 'Foreground service rules', + 'Finite UIKit fallback and ActivityKit status', + 'Shipping foundation; durable scheduling remains planned', + ], + ['Camera', 'Camera2', 'AVFoundation / AVCaptureSession', 'Planned · Medium'], + ['Clipboard', 'ClipboardManager', 'UIPasteboard', 'Planned · High'], + [ + 'Drag and drop', + 'startDragAndDrop / DragEvent', + 'UIDragInteraction / UIDropInteraction', + 'Planned · High', + ], + [ + 'Haptics', + 'HapticFeedbackConstants / VibrationEffect', + 'UIFeedbackGenerator / CoreHaptics', + 'Planned · High', + ], + [ + 'File picker', + 'Storage Access Framework', + 'UIDocumentPickerViewController', + 'Planned · High', + ], + ['Photo picker', 'Android Photo Picker', 'PhotosUI', 'Planned · High'], + [ + 'Share sheet', + 'ACTION_SEND / Sharesheet', + 'UIActivityViewController', + 'Planned · High', + ], + ['Biometrics', 'BiometricPrompt', 'LocalAuthentication', 'Planned · High'], + [ + 'Passkeys and credentials', + 'Credential Manager', + 'AuthenticationServices', + 'Planned · High', + ], + ['Secure storage', 'Android Keystore', 'Keychain / Secure Enclave', 'Planned · High'], + ['Notifications', 'NotificationManager', 'UserNotifications', 'Planned · High'], + ['Home-screen widgets', 'App Widgets / Glance', 'WidgetKit', 'Planned · High'], + ['Location', 'LocationManager', 'CoreLocation', 'Planned · Medium'], + ['Motion and sensors', 'SensorManager', 'CoreMotion', 'Planned · Medium'], + ['Bluetooth LE', 'android.bluetooth', 'CoreBluetooth', 'Planned · Medium'], + ['NFC', 'NfcAdapter', 'CoreNFC', 'Planned · Medium'], + ['UWB and ranging', 'RangingManager', 'NearbyInteraction', 'Planned · High'], + [ + 'Audio input and output', + 'AudioTrack / AudioRecord / AAudio', + 'AVAudioEngine / AVAudioSession', + 'Planned · High', + ], + [ + 'Video encode and decode', + 'MediaCodec', + 'VideoToolbox / AVFoundation', + 'Planned · Medium', + ], + ['Mobile WebView', 'WebView', 'WKWebView', 'Planned · High'], + ['Deep links', 'Intents / App Links', 'Universal Links', 'Planned · High'], + ['Network status', 'ConnectivityManager', 'NWPathMonitor', 'Planned · Medium'], + [ + 'Gamepads', + 'InputDevice / KeyEvent / MotionEvent', + 'GameController', + 'Planned · Medium', + ], + [ + 'Mouse and stylus', + 'MotionEvent validation', + 'Pointer / Pencil interactions', + 'Planned validation · High', + ], + ['Store and in-app purchases', 'Play Billing', 'StoreKit', 'Planned · Medium'], + [ + 'Speech and text to speech', + 'SpeechRecognizer / TextToSpeech', + 'Speech / AVSpeechSynthesizer', + 'Planned · Later', + ], + [ + 'Contacts and calendar', + 'ContactsContract / Calendar Provider', + 'Contacts / EventKit', + 'Planned · Later', + ], + ['Health', 'Health Connect', 'HealthKit', 'Planned · Later'], + ], + }, + }, + { + id: 'contract', + title: 'Portable contracts stay smaller than native APIs', + paragraphs: [ + 'A future shared API should represent portable application intent and data, then map that contract independently to Android and iOS. Platform permissions, manifests, entitlements, store policy, native presentation, and lifecycle remain the responsibility of each adapter and application shell.', + 'This avoids false parity. Camera capture, credentials, health data, background execution, and store purchases have materially different authorization and lifecycle rules even when a Rust-facing operation can share a name.', + ], + }, + { + id: 'delivery', + title: 'When a Planned row becomes Shipping', + paragraphs: [ + 'A capability moves to Shipping only after the shared contract, both relevant native adapters, error and permission behavior, documentation, focused tests, packaging changes, and device validation are present. One-platform prototypes remain explicitly partial.', + ], }, ], }, ] -export const docCategories = ['Start here', 'Essentials', 'Advanced', 'Architecture'] as const +export const docCategories = [ + 'Start here', + 'Essentials', + 'Advanced', + 'Technicalities', + 'Platforms', +] as const export const docsByCategory = docCategories.map((category) => ({ category, guides: docs.filter((guide) => guide.category === category), diff --git a/website/app/pages/docs/[...slug].vue b/website/app/pages/docs/[...slug].vue index 197115d2..d62d7227 100644 --- a/website/app/pages/docs/[...slug].vue +++ b/website/app/pages/docs/[...slug].vue @@ -67,6 +67,22 @@ usePageSeo( +
+ + + + + + + + + + + +
+ {{ header }} +
{{ cell }}
+