From 7a17adff4f44a2ce1dd008c2a31fb328e87c360e Mon Sep 17 00:00:00 2001 From: albi Date: Wed, 16 Sep 2026 10:28:59 +0200 Subject: [PATCH 1/7] docs: plan custom WGPU canvas support --- docs/todo_gpu_canvas.md | 1017 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 1017 insertions(+) create mode 100644 docs/todo_gpu_canvas.md diff --git a/docs/todo_gpu_canvas.md b/docs/todo_gpu_canvas.md new file mode 100644 index 00000000..05b20158 --- /dev/null +++ b/docs/todo_gpu_canvas.md @@ -0,0 +1,1017 @@ +# Custom WGPU canvas for Argui 0.3 + +Status: planned + +Target release: `0.3.0` + +This document is an implementation plan, not a list of optional ideas. Its goal +is to let an Argui application place a retained UI element whose pixels are +produced by application-owned WGPU render, compute and copy commands, while +Argui continues to own the window surface, frame submission and presentation. + +An agent implementing this plan must continue through every phase and satisfy +the complete definition of done. A triangle on screen, a native-only prototype, +or a public `Device` getter is not completion. If an external dependency or +platform is unavailable, finish every unblocked item, record the exact blocker +and the command or external action required, and leave this plan in place. + +When every checkbox is genuinely complete, migrate all enduring information to +the public documentation and changelog, then delete this file. The final commit +for this feature must not contain `docs/todo_gpu_canvas.md`. + +## Required reading + +Read these files completely before changing code: + +- [`contributing/code-quality.md`](contributing/code-quality.md) +- [`contributing/linux-testing.md`](contributing/linux-testing.md) +- [`architecture.md`](architecture.md) +- [`rendering/primitives.md`](rendering/primitives.md) +- [`rendering/effects.md`](rendering/effects.md) +- [`ui/custom-elements.md`](ui/custom-elements.md) +- [`runtime/models.md`](runtime/models.md) +- [`contributing/devtools.md`](contributing/devtools.md) +- [`contributing/releases.md`](contributing/releases.md) + +Also inspect the current implementations rather than relying on line numbers in +this plan. At minimum, inspect: + +- `crates/argui-paint/src/display_list.rs` and the image/vector primitives; +- `crates/argui-ui/src/custom.rs` and `crates/argui-ui/src/element/`; +- `crates/argui-layout/src/paint.rs`, `custom.rs` and `surface.rs`; +- `crates/argui-render/src/config.rs`, `batch.rs`, `effect_graph.rs`, + `offscreen.rs`, `surface.rs` and `surface/effects.rs`; +- `crates/argui-runtime/src/app/renderer.rs`, `app/popups.rs` and `event.rs`; +- `app_examples/Cargo.toml` and `app_examples/README.md`. + +The repository may contain unrelated or concurrent 0.3 work. Preserve it. Do +not reset, replace or silently reformat files merely because this plan names +them. + +## Problem statement + +`CustomElement` currently supports retained layout, paint-command caching, +normal hit testing and accessibility. Its `CustomPaintContext` can emit +renderer-neutral primitives such as quads, but it intentionally has no active +GPU frame and therefore cannot expose: + +- `wgpu::Device`; +- `wgpu::Queue`; +- `wgpu::CommandEncoder`; +- a target `wgpu::TextureView`; +- application-owned render or compute pipelines. + +This prevents an application from building an editor-shaped interface with +Argui toolbars, panels, menus and overlays around a high-performance WGPU +document canvas. It also prevents scientific views, games, maps, node editors, +3D previews and data visualizations from sharing Argui's renderer without +creating a second native window or surface. + +Putting raw WGPU handles directly in `CustomPaintContext` would be the wrong +fix. That context runs while the retained display list is being built and +cached; there is no acquired surface texture or command encoder at that point. +It also belongs to `argui-ui`, which must remain independent from WGPU. + +## Scope and non-goals + +### Required in 0.3.0 + +- A renderer-neutral GPU-canvas element and display command. +- A public registration API for application-owned canvas renderers. +- Read-only access to Argui's selected `wgpu::Device` and `wgpu::Queue` at the + appropriate renderer lifecycle points. +- A borrowed `wgpu::CommandEncoder` and Argui-owned target + `wgpu::TextureView` while a dirty canvas is rendered. +- Application-owned render, compute, copy, buffer, texture, bind-group and + pipeline resources. +- Retained canvas textures that rerender only when their content revision or + physical extent changes. +- Correct composition with normal Argui ordering, transforms, opacity, rounded + corners, clips, effect layers and backdrop effects. +- Clear capability, registration, creation and render diagnostics. +- Correct multi-window, native-popup, renderer-fallback and WebAssembly + behavior. +- A complete product-shaped example under `app_examples/gpu-canvas`. + +### Explicitly out of scope + +- Accepting an externally created `wgpu::Instance`, `Adapter`, `Device`, + `Queue`, `Surface` or `SurfaceTexture` in the high-level runtime. +- Letting application code acquire, reconfigure, submit or present Argui's + window surface. +- Replacing the renderer's normal quad, text, image or vector paths. +- A scene graph, shader DSL, material system, ECS or game engine. +- Automatically deriving content revisions by observing arbitrary user GPU + resources. +- A permanent animation loop for every canvas. +- Direct rendering into the swapchain as the primary integration path. + +External WGPU ownership can be planned separately. It requires a distinct +contract for surface lifetime, device-feature negotiation, fallback backends, +desktop backdrop compatibility and presentation. It must not be smuggled into +this feature through a public raw-surface escape hatch. + +## Locked architectural decisions + +These decisions are part of the required behavior. Private names and file +splits may change when the current code demands it, but an implementation must +not change these contracts without first updating this plan and explaining why. + +### 1. Keep UI and paint renderer-independent + +`argui-ui`, `argui-layout` and `argui-paint` must not depend on `wgpu` or +`argui-render`. They carry only opaque identities, bounds, revisions, sampling +options, transforms and clips. + +`CustomPaintContext` may gain a helper that emits a GPU-canvas display command, +but it must not receive a `Device`, `Queue`, encoder, render pass, texture or +surface. Raw WGPU access belongs to new public contexts in `argui-render`. + +### 2. Render offscreen, then compose + +Each visible canvas renders into a retained Argui-owned offscreen texture. Argui +then samples that texture at the canvas command's location just as it composes +another textured primitive. + +This is required because direct swapchain rendering cannot reliably preserve: + +- drawing before and after the canvas; +- ancestor transforms and clip chains; +- rounded corners and opacity; +- nested filters, shadows, blend modes and backdrop filters; +- native popup localization; +- cached effect layers; +- a single owner for surface acquisition and presentation. + +The target texture uses the active renderer target format and at least +`RENDER_ATTACHMENT | TEXTURE_BINDING | COPY_SRC | COPY_DST`. It is not promised +to be a storage texture. Compute work may write application-owned buffers or +textures and then render or copy its result into the supplied target. + +### 3. Argui owns submission and presentation + +The canvas receives a mutable borrowed command encoder. Successful canvas +encoders are finished by Argui and submitted, in dependency order, in the same +queue submission as the compositor frame. The application never receives the +surface or `SurfaceTexture` and never calls `present`. + +The raw queue is exposed so an application can use `write_buffer`, +`write_texture` and ordinary WGPU resource APIs. The public safety contract must +state that calling `Queue::submit`, blocking `Device::poll`, or retaining the +borrowed encoder/target view is unsupported. All render, compute and copy work +for the canvas frame must be encoded through the supplied encoder. + +Use a dedicated encoder per dirty canvas. If a canvas explicitly returns an +error, discard that encoder so partially recorded commands do not contaminate +the main Argui encoder. + +### 4. Registration is explicit and known before device creation + +Canvas factories are registered through an immutable `GpuCanvasRegistry` on +`RendererConfig`, following the existing custom-effect registry pattern. The +runtime does not discover WGPU callbacks from an `Element` or store closures in +the retained UI tree. + +Knowing registrations before `request_device` lets Argui aggregate required +features and limits, gives every Windows fallback attempt the same requirements, +and lets `new_with_device` reject an incompatible shared device with a clear +error. + +The registry is fixed for a renderer device in 0.3. Applications may change +scene data and content revisions at runtime, but adding a factory that requires +new device capabilities requires creating a new renderer. This also avoids +keeping a hot-reloaded trait-object vtable alive past the code that created it. + +### 5. Retained revisions control GPU work + +Every canvas display primitive carries an application-authored `content_revision`. +The renderer reruns the application callback only when at least one of these +inputs changes: + +- registration or retained instance identity; +- content revision; +- exact physical viewport extent; +- output format or device generation; +- an option that changes the rendered texture itself. + +Moving the element, changing an ancestor clip, opacity or rounded corners must +update composition without rerendering identical canvas pixels. A resize or DPI +change must recreate the target and rerender. A zero-sized or fully invalid +extent must safely skip work. + +Application code is responsible for incrementing the revision after changing +shared scene data. Animation uses the existing `request_animation_frame` / +`wants_animation_frame` mechanisms and stops requesting frames when paused. +There must be no hidden polling thread or permanent redraw loop. + +### 6. Canvas instances follow retained node identity + +A registration identity selects the factory. A separate instance key, derived +from the retained UI node plus an optional local slot, selects the cached output +texture. This permits the same registered renderer to appear more than once +without cache collisions and lets a custom element emit more than one canvas. + +Do not allocate a fresh instance identity every time `Render::render` rebuilds +an `Element`; that would defeat retained caching. Remounting a node may create a +new identity and must release the old cache entry through normal LRU cleanup. + +### 7. Fail locally when possible + +Unsupported required device capabilities are renderer-initialization errors, +because the requested device cannot satisfy the declared contract. Missing +registrations, factory creation errors and explicit per-frame callback errors +must instead produce: + +- a deterministic visible placeholder for the affected canvas; +- a structured renderer diagnostic; +- a window-scoped runtime event with the canvas label, instance and message; +- one console-readable English message, deduplicated until the error changes or + the canvas recovers. + +The rest of the Argui UI must continue rendering when safe. Actual device loss, +surface validation failure or an uncaptured WGPU error may still be fatal under +the renderer's existing policy. Do not promise that arbitrary invalid WGPU API +usage can be recovered. + +### 8. WebAssembly is a first-class target + +The API must exist without `cfg` differences on native and +`wasm32-unknown-unknown`. Use WGPU/WebGPU capabilities and WGSL; do not expose +native handles. The example must compile and run in a WebGPU browser. + +If a particular optional compute feature is unavailable, the registry's +requirements must report that clearly or the example must take a documented +baseline render-only path. A failed local browser run is not justification for +making the public feature native-only. + +## Target architecture + +```text +Render::render + | + v +Element::gpu_canvas(GpuCanvasSpec) + | + v +renderer-neutral DisplayCommand::GpuCanvas + | + +--> unchanged texture? ------> reuse retained texture + | + +--> dirty texture + | + v + GpuCanvasRenderer::render + Device + Queue + CommandEncoder + TextureView + | + v + Argui finishes encoder + | + v +textured-quad composition in normal display-list order + | + v +clips / rounded corners / effects / backdrop / surface present +``` + +The registry/factory boundary is separate from the display list: + +```text +RendererConfig + `-- GpuCanvasRegistry + `-- GpuCanvasRegistration { id, label, factory, requirements } + +Display list + `-- GpuCanvasPrimitive { id, retained instance, revision, geometry } +``` + +## Proposed public API shape + +The final names may be adjusted once all existing public naming conventions are +checked, but the responsibilities and ownership in this section are required. + +### Renderer-neutral types + +Add a small module in `argui-paint` with types equivalent to: + +```rust,ignore +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct GpuCanvasId(u64); + +#[derive(Clone, Debug, PartialEq)] +pub struct GpuCanvasPrimitive { + pub canvas: GpuCanvasId, + pub object: RenderObjectId, + pub slot: u32, + pub bounds: Rect, + pub content_revision: u64, + pub resolution_scale: f32, + pub sampling: ImageSampling, + pub opacity: f32, + pub radii: CornerRadii, + pub transform: Affine2D, + pub clips: ClipChain, +} +``` + +`GpuCanvasId` is process-local and opaque, like image/vector identities. The +registration constructor normally allocates it. All floating-point options +must reject or normalize non-finite, negative and unreasonable values before +they reach texture allocation. + +Add `DisplayCommand::GpuCanvas`, `DisplayList::push_gpu_canvas`, validation, +storage accounting and exhaustive-match handling. Display-list equality must +include every field that affects texture generation or composition. + +### UI types + +Provide a concise common path equivalent to: + +```rust,ignore +Element::gpu_canvas( + GpuCanvasSpec::new(self.canvas_id) + .content_revision(self.scene_revision) + .sampling(ImageSampling::Linear) + .resolution_scale(1.0), +) +``` + +`GpuCanvasSpec` is WGPU-independent. The element behaves as a normal leaf in +layout and can use all existing style, transform, effect, semantics, key, +interaction, gesture and event APIs. Its normal paint opacity and corner radii +apply during composition. + +Also add a renderer-neutral `CustomPaintContext::gpu_canvas` helper. It takes a +local slot, local bounds and a spec, then emits the same primitive with the +custom node's retained object identity, ancestor transform and clips. This is +the advanced path for custom elements that combine several display primitives. +It still exposes no WGPU handle. + +### Registry and callbacks + +Expose the exact WGPU version used by Argui as `argui::render::wgpu`. Public +examples and documentation must use this re-export so user code cannot +accidentally compile descriptors against an incompatible WGPU version. + +Provide public types equivalent to: + +```rust,ignore +pub trait GpuCanvasFactory: Send + Sync + 'static { + fn requirements(&self) -> GpuCanvasRequirements { + GpuCanvasRequirements::default() + } + + fn create( + &self, + context: &GpuCanvasDeviceContext<'_>, + ) -> Result, GpuCanvasError>; +} + +pub trait GpuCanvasRenderer: Send + 'static { + fn render( + &mut self, + context: &mut GpuCanvasRenderContext<'_>, + ) -> Result<(), GpuCanvasError>; +} +``` + +Preserve the useful `Send`/`Sync` auto-traits of `RendererConfig` and +`SurfaceRenderer`. If current platform constraints require different bounds, +prove that with native and Wasm compile assertions before weakening them; do not +add or remove thread bounds accidentally. + +`GpuCanvasDeviceContext` provides getters for: + +- `&wgpu::Device`; +- `&wgpu::Queue`; +- the enabled feature set and effective limits; +- the target texture format; +- a stable opaque device generation/identity for diagnostics. + +`GpuCanvasRenderContext` provides getters for: + +- `&wgpu::Device` and `&wgpu::Queue`; +- `&mut wgpu::CommandEncoder`; +- `&wgpu::TextureView` for the canvas target; +- target format and exact physical extent; +- logical bounds, scale factor and requested resolution scale; +- canvas registration ID, retained object/slot and frame number. + +The contexts have private fields so application code cannot construct an +invalid one. Their Rustdoc must say which references may be retained (device +resources) and which must not be retained (encoder and target view). + +`GpuCanvasRegistration::new(label, factory)` allocates an ID and stores the +factory behind an `Arc`. Cloning a registration preserves its ID and factory. +`GpuCanvasRegistry::new` validates registrations, deduplicates repeated clones +and reports inconsistent duplicates. Labels are stable, non-empty diagnostic +names but are not cache identities. + +The intended launch shape is: + +```rust,ignore +let scene = Arc::new(RwLock::new(Scene::default())); +let canvas = GpuCanvasRegistration::new( + "example.document-canvas", + DocumentCanvasFactory::new(Arc::clone(&scene)), +); +let canvas_id = canvas.id(); + +let renderer = RendererConfig::default().gpu_canvases( + GpuCanvasRegistry::new([canvas])?, +); +let model = Editor::new(canvas_id, scene); + +run_application(config, renderer, model, on_event)?; +``` + +Do not add a Cargo feature for this API: Argui already has an unconditional +renderer/WGPU dependency, and a feature flag would only multiply build variants +without removing that dependency. + +### Requirements and device negotiation + +`GpuCanvasRequirements` must support: + +- required WGPU features; +- optional WGPU features, enabled only when the chosen adapter supports them; +- required WGPU limits; +- a human-readable reason attached to non-baseline requirements. + +Aggregate all registrations before requesting the device. Required features +are unioned, optional features are intersected with adapter support, and limits +are merged using WGPU's own resolution/alignment helpers rather than assuming +that every field uses `max`. Validate requirements against the adapter first so +errors name the canvas registration, missing feature or insufficient limit. + +For `SurfaceRenderer::new_with_device`, compare the registry requirements with +the already enabled device features and limits. Never silently drop a required +capability. Optional features must not by themselves force a Windows backend +fallback; required features may, and every failed attempt must retain its clear +canvas-specific reason in `RendererAttemptFailure`. + +The example must require only WebGPU-baseline capabilities. Its compute pass +should use storage buffers and ordinary compute dispatch rather than an exotic +native-only feature. + +## Texture, cache and color contract + +Add a dedicated canvas cache/manager in `argui-render`; do not overload the +transient effect `TexturePool` with persistent canvas ownership. + +Each cache entry records at least: + +- registration and retained instance/slot key; +- target texture, view and compositor bind groups; +- exact physical extent and byte size; +- last rendered content revision; +- output format/device generation; +- last-used frame for LRU eviction; +- creation/render failure state and diagnostic deduplication state. + +Required rules: + +1. The texture covers the visible local canvas viewport, not the logical size + of an infinite document. +2. Physical extent is `ceil(logical_size * scale_factor * resolution_scale)`, + checked for finite values, overflow, the adapter's + `max_texture_dimension_2d` and the configured byte budget. +3. Default `resolution_scale` is `1.0`. Ancestor transforms do not silently + allocate giant textures; an app that needs supersampling opts in explicitly. +4. A dirty target is cleared deterministically before the callback. The + initial 0.3 contract is a transparent clear followed by user commands; do + not expose an uninitialized `Load` path. +5. Canvas output uses the renderer target's sRGB view and straight-alpha + composition, matching Argui's current image pipeline. Document this with a + shader example and an alpha-edge regression test. +6. Cache entries are exact-sized or correctly limit UVs to the requested + extent. Never display uninitialized padding. +7. The budget is configurable through `RendererConfig` and has a documented + bounded default. A single oversized request fails before allocation. +8. Pre-scan canvases needed by the frame. Never evict a texture that is still + required later in that same frame. If all visible canvases cannot fit, choose + a deterministic subset and show placeholders for the rest. +9. Entries not referenced by the current frame become LRU candidates. Dropped + registrations, remounted nodes and resized targets release GPU resources. +10. A surface resize does not flush unrelated canvas pipelines. A new device or + output format does recreate the relevant renderer and textures. + +Add public profile data for canvas entries, bytes, dirty renders, cache hits, +failed canvases and encoded CPU time. Include it in runtime inspector records +and DevTools without pretending to measure custom GPU pass duration. Application +code can create its own timestamp queries when it needs pass-level GPU timings. + +## Frame integration details + +The implementation order inside one UI frame must be: + +1. Acquire a presentable surface frame. If it is occluded, timed out or skipped, + do not run custom canvas work. +2. Prepare normal Argui primitives and scan canvas commands. +3. Validate registrations, extents and visible-frame memory demand. +4. Create factories lazily for canvases first used by this `SurfaceRenderer`. +5. For each dirty canvas, create a dedicated labeled encoder, clear its target, + invoke the callback and keep its command buffer only on success. +6. Prepare the canvas compositor instances/bind groups. +7. Build ordinary draw batches and the effect graph, including canvas draws. +8. Encode the main/effect compositor after all canvas producers. +9. Submit successful canvas command buffers followed by the Argui command + buffer in one ordered queue submission. +10. Poll and present using the existing renderer policy. + +`DrawKind` must gain a canvas variant that preserves display-list order. Do not +merge batches that sample different retained textures. Both the direct surface +path and `draw_offscreen` effect path must support the new kind. + +Canvas texture and compositor changes must advance the renderer's content +revision so cached effect layers invalidate correctly. A canvas nested inside a +blur, opacity layer, blend mode or backdrop filter must never leave the parent +layer stale after its revision changes. + +The compositor must apply: + +- display-list bounds and affine transform; +- clip chains; +- element corner radii; +- element opacity; +- nearest or linear sampling; +- normal alpha blending; +- the correct target-region offset when drawing inside an offscreen effect + layer. + +Prefer extracting a small shared textured-quad compositor from the image path +if that removes meaningful duplication. Do not couple canvas texture ownership +to CPU `ImageAsset` upload or regress existing image caching. + +## Errors and diagnostics + +Add structured errors for at least: + +- duplicate or invalid registration; +- missing canvas registration referenced by a display list; +- unsupported required feature or limit, including the registration label; +- incompatible shared `RendererDevice`; +- invalid/non-finite resolution scale or extent; +- extent beyond adapter limits; +- canvas cache budget exhaustion; +- factory creation failure; +- explicit canvas render failure; +- conflicting descriptors for the same retained key in one frame. + +Expose renderer diagnostics through a draining method such as +`SurfaceRenderer::take_gpu_canvas_diagnostics`. The runtime drains it after the +main surface and native popup render paths and emits a window-scoped event such +as `RuntimeEvent::GpuCanvasFailed` / `WindowRuntimeEvent::GpuCanvasFailed`. +Recovery after a later successful revision should emit one corresponding +recovery event, not a failure every frame. + +All user-facing messages are concise English and include the canvas label, +stage and actionable cause, for example: + +```text +GPU canvas 'example.document-canvas' could not be created: required feature +TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES is unavailable on adapter '...'. +``` + +The default placeholder must be deterministic and visible in release builds, +without requiring text shaping. Document how an application can place its own +Argui error overlay using the runtime event. Do not print the same diagnostic +on every animation frame. + +## Multi-window, popups and fallback behavior + +- Every `SurfaceRenderer` gets its own canvas renderer instances and retained + target textures, even when windows share a `RendererDevice`. +- The immutable registry and requirements are cloned with `RendererConfig` for + secondary windows and native popups. +- `NativeSurfacePaint::localize` must translate canvas transforms and clips in + the same way as images/vectors/text. +- A canvas inside a native popup must render or produce a scoped popup + diagnostic; it must not silently disappear. +- `recreate_surface` retains canvas resources when the device/format remains + valid. Creating a renderer with a new device invokes factories again. +- Canvas support must not disable Windows DirectComposition or desktop + backdrops. Baseline registrations should work on the preferred DX12 path. +- Renderer fallback remains controlled by the existing default-on + `renderer_fallback` setting. Required canvas capabilities are evaluated on + every attempted backend and included in the existing clear fallback report. +- Do not expose the selected backend as behavior that application rendering + must branch on. Branch only on declared WGPU capabilities. + +## Interaction and accessibility + +A GPU canvas does not introduce a second input system. It uses normal Argui: + +- hit testing, gestures and pointer capture; +- direct handlers and advanced event bubbling; +- focus and keyboard shortcuts; +- semantics, labels and accessible actions; +- overlays rendered before or after it in the normal tree. + +Canvas pixels have no automatic semantic meaning. The public guide and example +must show a labelled/focusable canvas plus accessible Argui controls or a +semantic overlay for important actions. Keyboard alternatives must exist for +pan/zoom/reset and animation controls shown in the example. + +`argui-testing` remains GPU-free. It should be able to lay out, hit-test, +interact with and inspect a canvas element as an opaque leaf, but it does not +execute WGPU callbacks. Pixel correctness belongs in `argui-render` tests and +the native/browser integration checks. + +## Required example: GPU Canvas Lab + +Add a new non-publishable crate at `app_examples/gpu-canvas` and register it in +the separate `app_examples` workspace. It must be a product-shaped example, +not a single full-window triangle. + +The application contains: + +- an Argui top toolbar with play/pause, reset and zoom controls; +- an Argui side inspector showing canvas state and renderer capability/status; +- a central `Element::gpu_canvas` that fills the remaining space; +- an Argui overlay above the canvas proving ordering and clipping; +- pointer drag to pan, wheel/pinch or controls to zoom, and keyboard equivalents; +- a WGPU compute pass that updates a bounded set of particles or scene objects; +- a WGPU render pass that draws a grid/background and the computed objects; +- resize and HiDPI correctness; +- pause behavior that stops animation requests and custom GPU rerenders; +- an in-app diagnostic panel driven by GPU-canvas runtime events. + +Keep the workload bounded and laptop-friendly. The example should visibly prove +that Argui remains responsive around the WGPU view, not serve as a benchmark +that pins the GPU. + +Use shared application data with explicit synchronization between the model and +factory, and explain the pattern in the example README. Every scene mutation +increments a revision that is passed to `GpuCanvasSpec`. The renderer reads a +short-lived snapshot; it must not hold a lock while WGPU submits or presents. + +The crate must support: + +```sh +cargo run --release --manifest-path app_examples/Cargo.toml \ + -p argui-example-gpu-canvas + +cargo check --manifest-path app_examples/Cargo.toml \ + -p argui-example-gpu-canvas --target wasm32-unknown-unknown +``` + +Provide a `cdylib` entry point and minimal Web page/build instructions so the +same example can be exercised in a WebGPU browser. Use the repository's pinned +Wasm tooling and do not invent a second JavaScript framework. + +## Documentation deliverables + +Create a permanent `docs/rendering/gpu-canvas.md` guide containing: + +1. when to use normal Argui primitives, a custom element, a custom effect or a + GPU canvas; +2. the offscreen/composition mental model; +3. complete registration, factory and element code; +4. device/queue/encoder/target lifetimes and the no-submit rule; +5. revisions, caching, resizing, DPI and animation scheduling; +6. color/alpha, texture format and compute-intermediate rules; +7. requirements, limits, renderer fallback and Wasm capability handling; +8. input/accessibility integration; +9. errors and runtime diagnostics; +10. multi-window and native-popup behavior; +11. the distinction from externally owned WGPU instances/surfaces. + +Update `docs/rendering/primitives.md`, `docs/architecture.md`, `docs/README.md`, +the repository README feature list, the roadmap and `CHANGELOG.md`. Add any +0.3-specific API note to `docs/migrations/0.3.md` only if users must change +existing code; do not manufacture a migration for an additive feature. + +Do not add this temporary plan to the published documentation navigation. + +### Required Unreleased changelog entry + +The changelog is not a one-line checkbox. Before this plan is deleted, add a +reviewed entry under `## [Unreleased]` that tells a 0.3 user exactly what was +added. It must cover, in plain release-note language: + +- the new retained GPU-canvas element and its intended editor, visualization, + game/map and scientific-view use cases; +- the new public registration/factory/render-context API and access to Argui's + selected WGPU device, queue, command encoder and offscreen target view; +- the ownership boundary: Argui still owns backend selection, surface + acquisition, command submission and presentation; +- retained revision/resize caching, bounded texture memory and how paused or + unchanged canvases avoid custom GPU work; +- correct composition with Argui primitives, transforms, clipping, rounded + corners, opacity, effects, overlays, multi-window surfaces and native popups; +- required/optional GPU capability negotiation, Windows renderer fallback and + clear recoverable runtime diagnostics/placeholders; +- native and WebAssembly/WebGPU support; +- the new **GPU Canvas Lab** in `app_examples/gpu-canvas`, including that it + demonstrates an Argui application shell, a custom WGPU compute pass, a custom + render pass, pan/zoom, pause/resume, overlays, resize/HiDPI and in-app errors; +- the deliberate limitation that externally owned WGPU instances, devices and + surfaces are still not accepted by the high-level runtime; +- any narrower limitation discovered during implementation, with no vague + promise that unsupported behavior works. + +Use the normal changelog categories accurately. New API/example work belongs +under `### Added`; behavior changed for existing public APIs belongs under +`### Changed`; actual constraints belong under `### Known limitations`. Do not +describe implementation details as user-facing features, and do not claim Wasm, +compute, effects, native popups or fallback support until their acceptance tests +are present. + +Before deleting this plan, compare the changelog entry against the final public +API, the GPU Canvas Lab README and `docs/rendering/gpu-canvas.md`. Every public +name and command must match the shipped code. The implementation commit must +contain the changelog update and the example; deleting the plan without them is +explicitly incomplete. + +## File and dependency boundaries + +The expected implementation areas are: + +| Area | Expected responsibility | +| --- | --- | +| `argui-paint` | Opaque ID, primitive and display command only | +| `argui-ui` | `GpuCanvasSpec`, leaf constructor and custom-paint helper | +| `argui-layout` | Retained instance identity, paint lowering and popup localization | +| `argui-render` | WGPU traits/contexts, registry, requirements, cache, callback execution and composition | +| `argui-runtime` | Diagnostic draining and window-scoped events | +| `argui-inspect` / DevTools | Canvas memory, cache and failure metrics | +| `argui-testing` | GPU-free opaque-leaf interaction/inspection coverage | +| `argui` facade | Existing module re-exports; no large prelude expansion | +| `app_examples/gpu-canvas` | Native and Wasm reference application | + +Suggested renderer files are `gpu_canvas/mod.rs`, `registry.rs`, `cache.rs` and +`pipeline.rs`, split by responsibility. Follow the repository rule that every +Rust file stays at or below 600 physical lines. Do not create an independent +public crate unless implementation proves a real dependency cycle that cannot +be solved inside these boundaries. + +`wgpu` remains a dependency only of `argui-render` in this path. User code uses +the renderer's re-export. Add no third-party synchronization, shader or math +dependency unless the directly affected crate genuinely needs it and existing +workspace facilities cannot solve the problem. + +## Test matrix + +### Pure and retained tests + +- ID allocation and registration clone identity. +- Empty/duplicate/invalid registry labels and duplicate handling. +- Required/optional feature aggregation and limit resolution, including + alignment limits whose direction differs from capacity limits. +- `new_with_device` compatibility checks. +- Display-list append, clear, extend, equality, validation and storage metrics. +- UI reconciliation: same node/revision retains identity; revision changes are + paint-only; remount creates a new instance. +- Custom-paint local slot behavior and duplicate-slot diagnostics. +- Layout bounds, transforms, clips, opacity, radii and popup localization. +- Zero, non-finite, overflow, excessive-dimension and over-budget extents. +- Cache hit, dirty revision, resize, DPI change, LRU eviction and same-frame + retention decision logic without requiring a physical GPU. +- Diagnostic deduplication and recovery. +- `argui-testing` hit testing, focus and handlers on an opaque canvas leaf. + +### GPU renderer tests + +- A headless offscreen test renders a known solid/gradient through the custom + callback and reads pixels back. +- A compute-to-buffer then render-to-target test proves arbitrary compute and + render commands share the supplied encoder correctly. +- Quads before and after a canvas preserve exact ordering. +- Linear and nearest sampling differ as expected. +- Straight-alpha edges composite without dark or light fringes. +- Ancestor clipping, rounded corners, opacity and affine transforms match + normal image behavior. +- A canvas inside a cached effect layer updates when its content revision + changes and remains cached when it does not. +- Explicit callback failure discards its command buffer, shows the placeholder + and leaves surrounding UI valid. +- Multiple canvases, repeated registrations, multiple instances and conflicting + same-instance descriptors behave deterministically. +- Resize, surface recreation and a second `SurfaceRenderer` sharing a device do + not reuse invalid target resources. +- Profiling/cache counters report hits, renders, bytes and failures accurately. + +GPU-unavailable test environments may skip only tests that truly require an +adapter. Keep cache, negotiation and display-graph logic separately testable so +coverage does not depend on a desktop GPU. + +### Native integration + +On Linux, run GUI checks only through the private display wrapper and inspect +saved captures; a blank capture is a failure. Validate: + +- the GPU Canvas Lab initial frame; +- pan, zoom, pause/resume and resize; +- a clipped/effected canvas and Argui overlay ordering; +- idle behavior after pause, including a stable callback counter and no + continuous redraw; +- the diagnostic placeholder path; +- a second window or native popup containing the canvas when that feature is + available. + +Windows CI must cover DX12 with fallback enabled and disabled. The canvas must +not change desktop-backdrop behavior unless its declared required capabilities +force another backend, in which case the existing fallback event explains it. + +### WebAssembly/browser integration + +- `cargo check --workspace --all-targets --target wasm32-unknown-unknown` passes. +- The `app_examples/gpu-canvas` crate checks for Wasm. +- The release Wasm build completes with the repository's pinned tooling. +- A browser test waits for `RendererReady`, captures a non-blank canvas, pauses + animation, verifies the render callback count stops increasing, resizes the + browser and resumes. +- Missing WebGPU or missing required capabilities produces the documented + English diagnostic rather than a blank page or panic. + +### Performance evidence + +Record before/after measurements for: + +- idle paused example CPU/GPU activity; +- callback count across unchanged Argui redraws; +- first canvas creation time; +- steady animated frame CPU encode time; +- canvas texture bytes and cache hits; +- native and Wasm release artifact size. + +The acceptance requirement is behavioral, not a fabricated percentage: an +unchanged canvas callback is not invoked, a paused example does not request +continuous frames, memory stays within its configured budget, and normal UI +interaction remains smooth at the example's bounded workload. + +## Implementation checklist + +A box is checked only after the implementation, tests and relevant public +documentation exist. Do not check an item because a stub compiles. + +### Phase 0 — baseline and contract + +- [ ] Read every required document and current implementation named above. +- [ ] Record the current branch, dirty files and protected-branch CI status; + preserve unrelated 0.3 work. +- [ ] Record targeted baseline test counts and renderer/UI/runtime coverage. +- [ ] Confirm the current WGPU target formats and feature/limit APIs on native + and Wasm with a throwaway experiment that is not committed. +- [ ] Confirm and document the public auto-traits that `RendererConfig` and + `SurfaceRenderer` currently provide. +- [ ] Freeze the final public names and add one compile-only API example that + matches the ownership contract in this plan. + +### Phase 1 — renderer-neutral canvas primitive + +- [ ] Add and document `GpuCanvasId` and `GpuCanvasPrimitive` in `argui-paint`. +- [ ] Add `DisplayCommand::GpuCanvas` and all `DisplayList` operations/metrics. +- [ ] Add and document `GpuCanvasSpec` and `Element::gpu_canvas` in `argui-ui`. +- [ ] Add the renderer-neutral `CustomPaintContext::gpu_canvas` advanced helper. +- [ ] Derive a stable instance identity from the retained node plus local slot. +- [ ] Lower the element/custom helper through `argui-layout` with normal + transform, clip, opacity, radii and sampling fields. +- [ ] Localize canvas commands for native popup surfaces. +- [ ] Audit every exhaustive `ElementKind` and `DisplayCommand` match with `rg`. +- [ ] Add pure paint, UI, layout, surface-localization and reconciliation tests. +- [ ] Re-export the neutral types from the expected modules/facade. + +### Phase 2 — registry and device negotiation + +- [ ] Re-export Argui's exact WGPU version from `argui-render`. +- [ ] Add and document factory/renderer traits and error/result types. +- [ ] Add private-field device and render contexts with every required getter. +- [ ] Add cloneable registrations with stable IDs and diagnostic labels. +- [ ] Add immutable validated `GpuCanvasRegistry`. +- [ ] Add required/optional feature and required-limit declarations. +- [ ] Aggregate and validate requirements before every device request. +- [ ] Validate registry requirements in `new_with_device`. +- [ ] Preserve Windows fallback attempts and clear per-attempt errors. +- [ ] Add `RendererConfig` registry and canvas-cache-budget builders/defaults. +- [ ] Preserve required `Clone`, `Debug`, `Send` and `Sync` behavior with tests. +- [ ] Add registry, requirement, fallback-policy and shared-device tests. +- [ ] Confirm the public API compiles unchanged on Wasm. + +### Phase 3 — retained targets and callback execution + +- [ ] Add a persistent, bounded canvas texture cache separate from effect pools. +- [ ] Pre-scan visible canvases and reserve a deterministic within-budget set. +- [ ] Implement exact physical extent calculation and every validation guard. +- [ ] Create factories lazily per `SurfaceRenderer` and output format. +- [ ] Clear new/dirty targets to a defined transparent value. +- [ ] Invoke callbacks only for dirty revision/size/format/device inputs. +- [ ] Give each dirty callback a separate labelled command encoder. +- [ ] Discard failed callback encoders and retain successful command buffers. +- [ ] Keep user locks out of queue submission/presentation in the example/API docs. +- [ ] Implement LRU cleanup without evicting textures needed in the same frame. +- [ ] Implement deterministic placeholders and structured diagnostics. +- [ ] Test zero size, invalid size, resize, DPI, revision, errors, recovery, + eviction and budget exhaustion. + +### Phase 4 — composition and effects + +- [ ] Add a canvas textured-quad compositor or safely share image compositor code. +- [ ] Add canvas draw kinds/batches without merging different textures. +- [ ] Prepare compositor geometry, clips and bind groups incrementally. +- [ ] Draw canvases in direct surface order with normal primitives. +- [ ] Draw canvases inside offscreen effect nodes. +- [ ] Advance content revisions for texture and composition changes. +- [ ] Preserve cached effect correctness across canvas revision changes. +- [ ] Implement straight-alpha, sRGB, sampling and target-offset contracts. +- [ ] Submit canvas producers before the compositor in one Argui-owned submit. +- [ ] Add pixel/readback, ordering, clipping, transform, alpha and effect tests. +- [ ] Verify existing image/vector/text/quad tests remain unchanged and green. + +### Phase 5 — runtime, windows and observability + +- [ ] Add renderer diagnostic draining and deduplication/recovery semantics. +- [ ] Add main/window-scoped runtime canvas failure and recovery events. +- [ ] Drain events after both main-window and native-popup renders. +- [ ] Keep registry/factory behavior correct across multi-window shared devices. +- [ ] Verify surface recreation retains valid canvas resources. +- [ ] Verify a new device recreates factory and texture resources. +- [ ] Add canvas statistics to `RenderProfile`, `argui-inspect` and DevTools. +- [ ] Keep runtime failures actionable without exiting for recoverable canvas errors. +- [ ] Test main window, secondary window, popup, fallback and profiling paths. +- [ ] Audit hot-reload lifetime safety; keep the 0.3 registry startup-only. + +### Phase 6 — native and Wasm GPU Canvas Lab + +- [ ] Create `app_examples/gpu-canvas` with native binary and Wasm library entry. +- [ ] Add the crate to `app_examples/Cargo.toml` and its README catalogue. +- [ ] Build the Argui toolbar, inspector, central canvas and overlay. +- [ ] Implement bounded WGSL compute and render pipelines using the WGPU re-export. +- [ ] Implement pan, zoom, reset, pause/resume and keyboard alternatives. +- [ ] Wire shared scene snapshots and explicit content revisions correctly. +- [ ] Stop all animation requests and callback invocations while paused. +- [ ] Display capability and failure events in-app and in clear English logs. +- [ ] Handle resize, HiDPI, clipping and an Argui overlay above the canvas. +- [ ] Document native and browser run commands in the example README. +- [ ] Add behavior tests that do not require pixel execution where possible. +- [ ] Add native hidden-display and browser capture tests for real rendering. + +### Phase 7 — public documentation + +- [ ] Write `docs/rendering/gpu-canvas.md` with every listed topic. +- [ ] Document why `CustomPaintContext` remains WGPU-independent. +- [ ] Document queue/encoder/view lifetime and submission restrictions. +- [ ] Document revision, cache, memory and animation behavior. +- [ ] Document feature/limit negotiation, fallback and WebAssembly behavior. +- [ ] Document interaction/accessibility and `argui-testing` boundaries. +- [ ] Link the GPU Canvas Lab from the guide and application catalogue. +- [ ] Update rendering primitives, architecture, docs index, README and roadmap. +- [ ] Add the full `CHANGELOG.md` Unreleased entry required above, including + the API, retained behavior, WGPU ownership boundary, native/Wasm support, + diagnostics, GPU Canvas Lab and known external-WGPU limitation. +- [ ] Cross-check every changelog name/claim/command against the final code, + permanent guide, example README and acceptance-test evidence. +- [ ] Compile every Rust snippet intended to compile. + +### Phase 8 — verification and 0.3 release readiness + +- [ ] Run targeted `cargo nextest run --all-features` for every affected crate, + keeping feature flags identical between repeated targeted runs. +- [ ] Run targeted Clippy, rustdoc and native/Wasm checks. +- [ ] Run the renderer GPU integration tests on the hidden Linux display. +- [ ] Inspect every saved native/browser capture and reject blank captures. +- [ ] Run the GPU Canvas Lab in native release mode and a WebGPU browser. +- [ ] Verify paused idle behavior, retained callback counts and memory budgets. +- [ ] Verify Windows/macOS/mobile compile jobs and the Windows fallback matrix. +- [ ] Verify package archives contain required shaders/docs and no example-only files. +- [ ] Confirm every affected crate remains above 85% for lines, functions, + regions and branches. +- [ ] Search for stale exhaustive matches, `TODO`, `FIXME`, `todo!()`, + `unimplemented!()` and compatibility shims introduced by this work. +- [ ] Confirm every new/changed public function and method has accurate Rustdoc. +- [ ] Confirm every Rust file is at most 600 physical lines and every test is + under a mirrored `tests/` path rather than `src/`. +- [ ] Confirm all checkboxes in this plan are complete and all lasting decisions + have moved to public docs/tests. +- [ ] Confirm the reviewed Unreleased changelog entry and GPU Canvas Lab are in + the final diff before removing any implementation-plan evidence. +- [ ] Delete `docs/todo_gpu_canvas.md` only now, as the last documentation edit + in the final implementation patch. +- [ ] Run `./scripts/quality.sh` exactly once after implementation and plan + deletion are complete, immediately before the final commit. +- [ ] Commit the 0.3 feature, public docs, example, changelog and deletion of this + plan together; confirm the protected branch CI is green. + +## Definition of done + +The work is complete only when all of the following are true: + +1. An application can create custom WGPU pipelines and resources using Argui's + device/queue, encode compute/render/copy work into a supplied encoder, and + render into a supplied texture view. +2. Argui alone acquires, submits and presents the window surface. +3. UI/paint/layout crates remain WGPU-independent. +4. The canvas is a normal retained element with correct layout, input, + accessibility, ordering, transforms, clips, opacity, radii and effects. +5. Unchanged canvas content is reused; resize/revision changes rerender; paused + applications do not spin. +6. Resource usage is bounded and visible in profiling/DevTools. +7. Recoverable canvas errors leave the surrounding UI alive and produce one + actionable English diagnostic plus a visible placeholder. +8. Multi-window, native popup, fallback, native desktop and WebAssembly paths + have direct evidence. +9. The GPU Canvas Lab demonstrates a real Argui application shell around custom + compute/render work on native and WebAssembly. +10. Permanent docs, release notes, tests, package checks and the final quality + gate are complete. +11. This temporary plan has been deleted from the final feature commit. From f5979863790302e80581a002766f6a440861edd3 Mon Sep 17 00:00:00 2001 From: albi Date: Wed, 16 Sep 2026 11:52:43 +0200 Subject: [PATCH 2/7] feat: simplify the public application API --- .github/main-ruleset.json | 8 + .github/workflows/ci.yml | 52 +- CHANGELOG.md | 42 +- Cargo.lock | 84 +- Cargo.toml | 50 +- README.md | 40 +- app_examples/Cargo.lock | 78 +- app_examples/Cargo.toml | 3 +- app_examples/docs-examples/Cargo.toml | 3 + .../src/examples/accessibility.rs | 23 +- .../docs-examples/src/examples/animation.rs | 13 +- .../docs-examples/src/examples/clean_code.rs | 9 +- .../docs-examples/src/examples/counter.rs | 14 +- .../docs-examples/src/examples/i18n.rs | 43 +- .../docs-examples/src/examples/overlays.rs | 23 +- .../docs-examples/src/examples/styling.rs | 63 +- .../docs-examples/src/examples/tasks.rs | 35 +- app_examples/docs-examples/src/lib.rs | 2 +- app_examples/docs-examples/tests/behavior.rs | 93 +++ app_examples/fake-ai-harness/src/app.rs | 31 - app_examples/fake-ai-harness/src/app/view.rs | 33 +- .../argui/android/ForegroundTaskService.java | 136 ++++ .../dev/argui/android/MobileActivityHost.java | 55 ++ crates/argui-core/src/input.rs | 23 + crates/argui-core/tests/input.rs | 14 + crates/argui-devtools/src/app/rendering.rs | 3 +- crates/argui-devtools/src/host.rs | 8 +- crates/argui-devtools/src/host/tree.rs | 10 +- crates/argui-devtools/src/view.rs | 24 +- crates/argui-devtools/tests/view.rs | 60 +- .../app/ArguiActivityBridge.swift | 117 +++ .../extension/ArguiActivityWidget.swift | 162 ++++ .../shared/ArguiActivityAttributes.swift | 11 + crates/argui-layout/src/lib.rs | 2 +- crates/argui-layout/src/selection.rs | 214 +++++- crates/argui-layout/tests/selection.rs | 60 +- crates/argui-platform/Cargo.toml | 10 + crates/argui-platform/src/lib.rs | 1 + crates/argui-platform/src/mobile.rs | 274 +++++++ crates/argui-platform/src/mobile/android.rs | 570 ++++++++++++++ crates/argui-platform/src/mobile/ios.rs | 303 ++++++++ crates/argui-platform/tests/mobile.rs | 72 ++ crates/argui-runtime/Cargo.toml | 2 +- crates/argui-runtime/src/app.rs | 10 +- crates/argui-runtime/src/app/inertia.rs | 204 +++++ crates/argui-runtime/src/app/lifecycle.rs | 1 + crates/argui-runtime/src/app/pointer.rs | 81 +- crates/argui-runtime/src/app/scroll.rs | 177 +---- .../argui-runtime/src/app/text_selection.rs | 53 ++ crates/argui-runtime/src/host.rs | 24 +- crates/argui-runtime/src/launch.rs | 2 + crates/argui-runtime/src/model/events.rs | 7 +- crates/argui-runtime/src/model/handler.rs | 188 ++++- crates/argui-runtime/src/tasks/dispatcher.rs | 89 ++- crates/argui-runtime/tests/app.rs | 1 + crates/argui-runtime/tests/app/inertia.rs | 103 +++ crates/argui-runtime/tests/model/handler.rs | 71 +- crates/argui-runtime/tests/tasks.rs | 23 + crates/argui-testing/Cargo.toml | 32 + crates/argui-testing/src/actions.rs | 556 ++++++++++++++ crates/argui-testing/src/app.rs | 482 ++++++++++++ crates/argui-testing/src/error.rs | 47 ++ crates/argui-testing/src/inspect.rs | 373 +++++++++ crates/argui-testing/src/lib.rs | 31 + crates/argui-testing/src/node.rs | 76 ++ crates/argui-testing/src/query.rs | 90 +++ crates/argui-testing/src/windows.rs | 110 +++ crates/argui-testing/tests/actions.rs | 305 ++++++++ crates/argui-testing/tests/app.rs | 486 ++++++++++++ crates/argui-testing/tests/inspect.rs | 64 ++ crates/argui-testing/tests/node.rs | 171 +++++ crates/argui-testing/tests/query.rs | 106 +++ crates/argui-testing/tests/windows.rs | 37 + crates/argui-ui/src/event.rs | 178 +---- crates/argui-ui/src/event/listener.rs | 414 ++++++++++ crates/argui-ui/src/event/listener/color.rs | 251 ++++++ crates/argui-ui/src/event/listener/value.rs | 334 ++++++++ crates/argui-ui/src/interaction.rs | 33 +- crates/argui-ui/src/interaction/pointer.rs | 63 +- crates/argui-ui/src/lib.rs | 8 +- crates/argui-ui/src/scroll_config.rs | 13 + crates/argui-ui/src/text_selection.rs | 197 +---- .../argui-ui/src/text_selection/document.rs | 178 +++++ crates/argui-ui/src/text_selection/handles.rs | 32 + crates/argui-ui/src/tree.rs | 4 + crates/argui-ui/src/tree/action.rs | 22 +- crates/argui-ui/src/tree/event.rs | 64 +- crates/argui-ui/src/tree/focus.rs | 6 +- crates/argui-ui/src/tree/pointer.rs | 6 +- crates/argui-ui/tests/event.rs | 30 + crates/argui-ui/tests/interaction/pointer.rs | 157 ++++ crates/argui-ui/tests/scroll.rs | 1 + crates/argui-ui/tests/scroll_config.rs | 11 + crates/argui-ui/tests/text_selection.rs | 50 +- crates/argui-widget-gallery/src/app.rs | 144 +--- .../src/app/interaction.rs | 30 +- crates/argui-widget-gallery/src/app/layout.rs | 27 +- .../src/app/navigation.rs | 62 +- crates/argui-widget-gallery/src/navigation.rs | 23 +- crates/argui-widget-gallery/src/pages.rs | 84 +- .../src/pages/animated_text.rs | 79 +- .../src/pages/async_tasks.rs | 37 +- .../src/pages/breadcrumb.rs | 49 +- .../argui-widget-gallery/src/pages/buttons.rs | 54 +- crates/argui-widget-gallery/src/pages/card.rs | 9 +- .../src/pages/collapsible.rs | 36 +- .../argui-widget-gallery/src/pages/empty.rs | 9 +- .../src/pages/hot_reload.rs | 22 +- crates/argui-widget-gallery/src/pages/i18n.rs | 47 +- .../argui-widget-gallery/src/pages/inputs.rs | 9 +- .../argui-widget-gallery/src/pages/label.rs | 8 +- .../argui-widget-gallery/src/pages/menus.rs | 24 +- .../src/pages/mobile_activity.rs | 162 ++++ .../src/pages/pagination.rs | 19 +- .../src/pages/progress.rs | 93 ++- .../src/pages/skeleton.rs | 9 +- .../src/pages/text_selection.rs | 18 +- .../argui-widget-gallery/src/pages/toast.rs | 70 +- crates/argui-widget-gallery/tests/app.rs | 67 +- crates/argui-widgets/src/accordion.rs | 44 +- crates/argui-widgets/src/alert_dialog.rs | 78 +- crates/argui-widgets/src/breadcrumb.rs | 17 +- crates/argui-widgets/src/button.rs | 21 +- crates/argui-widgets/src/calendar.rs | 21 +- crates/argui-widgets/src/carousel.rs | 82 +- crates/argui-widgets/src/chart.rs | 16 +- crates/argui-widgets/src/collapsible.rs | 18 +- crates/argui-widgets/src/color_picker.rs | 13 + crates/argui-widgets/src/color_picker/view.rs | 68 +- crates/argui-widgets/src/combobox.rs | 74 +- crates/argui-widgets/src/command_palette.rs | 46 +- crates/argui-widgets/src/context_menu.rs | 16 +- crates/argui-widgets/src/data_table.rs | 40 +- crates/argui-widgets/src/date_picker.rs | 55 +- crates/argui-widgets/src/dialog.rs | 58 +- crates/argui-widgets/src/drawer.rs | 18 +- crates/argui-widgets/src/hover_card.rs | 19 +- crates/argui-widgets/src/input.rs | 64 +- crates/argui-widgets/src/input_otp.rs | 23 +- crates/argui-widgets/src/list.rs | 117 ++- crates/argui-widgets/src/menu.rs | 38 +- crates/argui-widgets/src/menubar.rs | 56 +- crates/argui-widgets/src/message_scroller.rs | 42 +- crates/argui-widgets/src/native_select.rs | 32 +- crates/argui-widgets/src/navigation_menu.rs | 52 +- crates/argui-widgets/src/pagination.rs | 48 +- crates/argui-widgets/src/popover.rs | 46 +- crates/argui-widgets/src/questionnaire.rs | 151 +++- crates/argui-widgets/src/range.rs | 12 + crates/argui-widgets/src/select.rs | 49 +- crates/argui-widgets/src/selection.rs | 59 +- crates/argui-widgets/src/sheet.rs | 32 +- crates/argui-widgets/src/sidebar.rs | 29 +- crates/argui-widgets/src/slider.rs | 53 +- crates/argui-widgets/src/split_pane.rs | 42 +- crates/argui-widgets/src/table.rs | 16 +- crates/argui-widgets/src/tabs.rs | 25 +- crates/argui-widgets/src/toast.rs | 55 +- crates/argui-widgets/src/toggle.rs | 16 +- crates/argui-widgets/src/toggle_group.rs | 40 +- crates/argui-widgets/src/tree_view.rs | 105 ++- crates/argui-widgets/src/updater.rs | 49 +- crates/argui-widgets/src/vlist.rs | 28 +- crates/argui-widgets/tests/button.rs | 39 +- crates/argui-widgets/tests/menubar.rs | 18 +- crates/argui-widgets/tests/toast.rs | 12 +- crates/argui-widgets/tests/tree_view.rs | 8 +- crates/argui-widgets/tests/tree_view/cache.rs | 47 +- crates/argui/Cargo.toml | 24 + crates/argui/src/lib.rs | 14 + docs/README.md | 14 +- docs/contributing/releases.md | 21 +- docs/hot-reload.md | 2 +- docs/i18n.md | 2 +- docs/migrations/0.3.md | 89 +++ docs/native-mobile.md | 95 ++- docs/performance/feature-profiles.md | 48 ++ docs/platform/application.md | 2 +- docs/platform/desktop-backdrops.md | 2 +- docs/platform/file-picker.md | 4 +- docs/platform/native-popovers.md | 2 +- docs/rendering/effects.md | 4 +- docs/repo/structure.md | 3 +- docs/simplified-api.md | 186 +++++ docs/todo_simplify_api.md | 719 ++++++++++++++++++ docs/todo_simplify_api_checklist.md | 152 ++++ docs/ui/interaction.md | 9 + docs/widgets/builder-inventory.md | 40 + docs/widgets/interaction-api.md | 74 ++ docs/widgets/overlays.md | 2 +- docs/widgets/shadcn.md | 13 +- mobile/android/README.md | 38 +- mobile/android/app/build.gradle | 7 + .../android/app/src/main/AndroidManifest.xml | 13 +- .../res/drawable/ic_notification_activity.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3059 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 18399 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3059 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1691 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 8771 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 1691 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4762 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 31565 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4762 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 9190 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 66797 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 9190 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 15085 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 114870 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 15085 bytes .../app/src/main/res/values/colors.xml | 4 + .../app/src/main/res/values/styles.xml | 15 + mobile/ios/ActivityExtension-Info.plist | 27 + .../project.pbxproj | 155 +++- .../AppIcon.appiconset/AppIcon-1024.png | Bin 0 -> 204694 bytes .../AppIcon.appiconset/AppIcon-20.png | Bin 0 -> 633 bytes .../AppIcon.appiconset/AppIcon-20@2x-ipad.png | Bin 0 -> 1327 bytes .../AppIcon.appiconset/AppIcon-20@2x.png | Bin 0 -> 1327 bytes .../AppIcon.appiconset/AppIcon-20@3x.png | Bin 0 -> 2324 bytes .../AppIcon.appiconset/AppIcon-29.png | Bin 0 -> 901 bytes .../AppIcon.appiconset/AppIcon-29@2x-ipad.png | Bin 0 -> 2198 bytes .../AppIcon.appiconset/AppIcon-29@2x.png | Bin 0 -> 2198 bytes .../AppIcon.appiconset/AppIcon-29@3x.png | Bin 0 -> 4077 bytes .../AppIcon.appiconset/AppIcon-40.png | Bin 0 -> 1327 bytes .../AppIcon.appiconset/AppIcon-40@2x-ipad.png | Bin 0 -> 3595 bytes .../AppIcon.appiconset/AppIcon-40@2x.png | Bin 0 -> 3595 bytes .../AppIcon.appiconset/AppIcon-40@3x.png | Bin 0 -> 6833 bytes .../AppIcon.appiconset/AppIcon-60@2x.png | Bin 0 -> 6833 bytes .../AppIcon.appiconset/AppIcon-60@3x.png | Bin 0 -> 13485 bytes .../AppIcon.appiconset/AppIcon-76.png | Bin 0 -> 3310 bytes .../AppIcon.appiconset/AppIcon-76@2x.png | Bin 0 -> 10060 bytes .../AppIcon.appiconset/AppIcon-83.5@2x.png | Bin 0 -> 11828 bytes .../AppIcon.appiconset/Contents.json | 26 + mobile/ios/Assets.xcassets/Contents.json | 6 + mobile/ios/Info.plist | 2 + mobile/ios/README.md | 77 +- mobile/ios/Sources/main.m | 1 + mobile/ios/include/ArguiWidgetGallery.h | 1 + scripts/android-gallery.sh | 6 +- scripts/check-quality-static.sh | 22 + scripts/coverage-gate.py | 2 + scripts/quality.sh | 18 +- scripts/release.py | 52 +- tests/scripts/test_coverage_gate.py | 21 + tests/scripts/test_release.py | 16 +- web/browser-shortcuts.js | 24 +- website/app/components/DocsExampleFrame.vue | 1 - website/app/components/GalleryFrame.vue | 1 - website/app/components/OnboardingTooltip.vue | 1 + website/app/data/catalogue.json | 13 +- .../app/data/doc-example-sources.generated.ts | 16 +- website/app/data/docs.ts | 47 +- website/scripts/catalogue.mjs | 12 +- website/tests/browser.mjs | 11 +- 256 files changed, 12572 insertions(+), 1829 deletions(-) create mode 100644 app_examples/docs-examples/tests/behavior.rs create mode 100644 crates/argui-android/android/src/main/java/dev/argui/android/ForegroundTaskService.java create mode 100644 crates/argui-android/android/src/main/java/dev/argui/android/MobileActivityHost.java create mode 100644 crates/argui-ios/swift/activity-kit/app/ArguiActivityBridge.swift create mode 100644 crates/argui-ios/swift/activity-kit/extension/ArguiActivityWidget.swift create mode 100644 crates/argui-ios/swift/activity-kit/shared/ArguiActivityAttributes.swift create mode 100644 crates/argui-platform/src/mobile.rs create mode 100644 crates/argui-platform/src/mobile/android.rs create mode 100644 crates/argui-platform/src/mobile/ios.rs create mode 100644 crates/argui-platform/tests/mobile.rs create mode 100644 crates/argui-runtime/src/app/inertia.rs create mode 100644 crates/argui-runtime/tests/app/inertia.rs create mode 100644 crates/argui-testing/Cargo.toml create mode 100644 crates/argui-testing/src/actions.rs create mode 100644 crates/argui-testing/src/app.rs create mode 100644 crates/argui-testing/src/error.rs create mode 100644 crates/argui-testing/src/inspect.rs create mode 100644 crates/argui-testing/src/lib.rs create mode 100644 crates/argui-testing/src/node.rs create mode 100644 crates/argui-testing/src/query.rs create mode 100644 crates/argui-testing/src/windows.rs create mode 100644 crates/argui-testing/tests/actions.rs create mode 100644 crates/argui-testing/tests/app.rs create mode 100644 crates/argui-testing/tests/inspect.rs create mode 100644 crates/argui-testing/tests/node.rs create mode 100644 crates/argui-testing/tests/query.rs create mode 100644 crates/argui-testing/tests/windows.rs create mode 100644 crates/argui-ui/src/event/listener.rs create mode 100644 crates/argui-ui/src/event/listener/color.rs create mode 100644 crates/argui-ui/src/event/listener/value.rs create mode 100644 crates/argui-ui/src/text_selection/document.rs create mode 100644 crates/argui-ui/src/text_selection/handles.rs create mode 100644 crates/argui-ui/tests/scroll_config.rs create mode 100644 crates/argui-widget-gallery/src/pages/mobile_activity.rs create mode 100644 docs/migrations/0.3.md create mode 100644 docs/performance/feature-profiles.md create mode 100644 docs/simplified-api.md create mode 100644 docs/todo_simplify_api.md create mode 100644 docs/todo_simplify_api_checklist.md create mode 100644 docs/widgets/builder-inventory.md create mode 100644 docs/widgets/interaction-api.md create mode 100644 mobile/android/app/src/main/res/drawable/ic_notification_activity.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 mobile/android/app/src/main/res/values/colors.xml create mode 100644 mobile/android/app/src/main/res/values/styles.xml create mode 100644 mobile/ios/ActivityExtension-Info.plist create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x-ipad.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x-ipad.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x-ipad.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-76@2x.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x.png create mode 100644 mobile/ios/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 mobile/ios/Assets.xcassets/Contents.json create mode 100755 scripts/check-quality-static.sh 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 fe9da6c1..5f4ba3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ that do not use them. ### Added +- 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 inherited text-selection highlight styling with solid or gradient fills, per-corner radii and an interactive Widget Gallery page. - Added default-on Windows renderer fallback from DirectX 12 DirectComposition @@ -21,6 +39,15 @@ that do not use them. ### Fixed +- 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. - Prevented Web canvases from taking focus and moving an embedding page while they load; full-page apps can opt in through `WindowConfig::focus_on_launch`. - Kept Winit's AppKit content view attached when enabling the macOS desktop @@ -34,9 +61,22 @@ 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 the base release gallery the recommended local command. Optional native integrations can still be enabled individually or together when needed. +### 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. + ## [0.2.1] - 2026-09-14 ### Fixed @@ -89,4 +129,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 735748b9..a4960e7f 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,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`, @@ -103,6 +104,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 | @@ -118,7 +122,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", ] } ``` @@ -127,12 +131,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`, @@ -208,25 +212,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 a1741403..87a29a04 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]] @@ -205,7 +205,7 @@ dependencies = [ [[package]] name = "argui" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-accessibility", "argui-animation", @@ -225,7 +225,7 @@ dependencies = [ [[package]] name = "argui-accessibility" -version = "0.2.1" +version = "0.3.0" dependencies = [ "accesskit", "argui-core", @@ -235,14 +235,14 @@ 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-example-ai-harness" @@ -258,12 +258,13 @@ name = "argui-example-docs" version = "0.1.0" dependencies = [ "argui", + "argui-testing", "wasm-bindgen", ] [[package]] name = "argui-i18n" -version = "0.2.1" +version = "0.3.0" dependencies = [ "fluent-bundle", "fluent-langneg", @@ -273,7 +274,7 @@ dependencies = [ [[package]] name = "argui-inspect" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "serde", @@ -282,7 +283,7 @@ dependencies = [ [[package]] name = "argui-layout" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "argui-paint", @@ -293,22 +294,28 @@ 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-util", "image", + "jni", + "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-ui-kit 0.3.2", "pollster", "wasm-bindgen", "wasm-bindgen-futures", @@ -319,7 +326,7 @@ dependencies = [ [[package]] name = "argui-render" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "argui-paint", @@ -333,7 +340,7 @@ dependencies = [ [[package]] name = "argui-runtime" -version = "0.2.1" +version = "0.3.0" dependencies = [ "accesskit", "accesskit_winit", @@ -358,9 +365,25 @@ dependencies = [ "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", @@ -369,14 +392,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", @@ -390,7 +413,7 @@ dependencies = [ [[package]] name = "argui-vector" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-core", "argui-paint", @@ -400,7 +423,7 @@ dependencies = [ [[package]] name = "argui-widgets" -version = "0.2.1" +version = "0.3.0" dependencies = [ "argui-animation", "argui-core", @@ -946,6 +969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", "objc2 0.6.4", ] @@ -1017,6 +1041,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" @@ -2156,6 +2186,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" @@ -4070,7 +4112,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 0536d1f5..a16ca8c1 100644 --- a/app_examples/Cargo.toml +++ b/app_examples/Cargo.toml @@ -10,7 +10,8 @@ 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" } web-time = "=1.1.0" wasm-bindgen = "=0.2.127" diff --git a/app_examples/docs-examples/Cargo.toml b/app_examples/docs-examples/Cargo.toml index e6cafaf3..74d9b077 100644 --- a/app_examples/docs-examples/Cargo.toml +++ b/app_examples/docs-examples/Cargo.toml @@ -22,5 +22,8 @@ argui = { workspace = true, features = [ [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..f595a460 100644 --- a/app_examples/docs-examples/src/examples/clean_code.rs +++ b/app_examples/docs-examples/src/examples/clean_code.rs @@ -1,7 +1,7 @@ use argui::{ runtime::{Context, Render}, text::TextStyle, - ui::{Element, EventType, Sides, UiEventKind, percent}, + ui::{Element, Sides, percent}, widgets::{Button, default_theme}, }; @@ -29,6 +29,7 @@ impl Render for Example { Element::column( rows.chain([Button::new("next", "Complete next", theme.button()) .enabled(self.completed < checks.len()) + .on_click(cx.callback(|app| app.completed = (app.completed + 1).min(3))) .build()]), ) .width(percent(1.0)) @@ -36,11 +37,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/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/overlays.rs b/app_examples/docs-examples/src/examples/overlays.rs index 96f4e6c5..db015982 100644 --- a/app_examples/docs-examples/src/examples/overlays.rs +++ b/app_examples/docs-examples/src/examples/overlays.rs @@ -1,7 +1,7 @@ use argui::{ runtime::{Context, Render}, text::TextStyle, - ui::{Element, EventType, Sides, UiEventKind, percent}, + ui::{Element, Sides, percent}, widgets::{Button, Dialog, DialogBehavior, default_theme}, }; @@ -14,8 +14,9 @@ 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 behavior = DialogBehavior::new("example-dialog", "Example dialog", self.open); + let close_key = behavior.close_key(); + let trigger_key = behavior.trigger_key(); let content = Element::column([ Element::text("A real modal portal").text_style(TextStyle { color: theme.foreground, @@ -34,26 +35,14 @@ impl Render for Example { "example-dialog", "Example dialog", self.open, - Button::new("open-dialog", "Open dialog", theme.button()).build(), + Button::new(trigger_key, "Open dialog", theme.button()).build(), content, ) + .on_open_change(cx.value_callback(|app, open| app.open = open)) .build(theme) .width(percent(1.0)) .height(percent(1.0)) .padding(Sides::length(28.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(); - })) } } 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..dc372e2f 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}, diff --git a/app_examples/docs-examples/tests/behavior.rs b/app_examples/docs-examples/tests/behavior.rs new file mode 100644 index 00000000..f7dae788 --- /dev/null +++ b/app_examples/docs-examples/tests/behavior.rs @@ -0,0 +1,93 @@ +use argui::{ + accessibility::Role, + 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.click("next").unwrap(); + app.assert_text("✓ State is explicit"); +} + +#[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 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 dialog_opens_and_closes_through_its_typed_state_handler() { + let mut app = TestApp::new(examples::overlays::Example::default()); + 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 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/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-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/lib.rs b/crates/argui-layout/src/lib.rs index 8d4d2a82..446389f9 100644 --- a/crates/argui-layout/src/lib.rs +++ b/crates/argui-layout/src/lib.rs @@ -23,5 +23,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/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/tests/selection.rs b/crates/argui-layout/tests/selection.rs index ac04d2e5..37de2c4c 100644 --- a/crates/argui-layout/tests/selection.rs +++ b/crates/argui-layout/tests/selection.rs @@ -5,8 +5,8 @@ use argui_paint::{ }; use argui_text::TextEngine; use argui_ui::{ - DocumentTextPoint, Element, SelectionGranularity, TextSelectionHighlight, TextSelectionStyle, - UiTree, UserSelect, percent, + DocumentSelectionEndpoint, DocumentTextPoint, Element, SelectionGranularity, + TextSelectionHighlight, TextSelectionStyle, UiTree, UserSelect, percent, }; const NOTO_SANS: &[u8] = include_bytes!("../../argui-web-demo/assets/fonts/NotoSans-Regular.ttf"); @@ -290,6 +290,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-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-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/app.rs b/crates/argui-runtime/src/app.rs index 91631894..135b3469 100644 --- a/crates/argui-runtime/src/app.rs +++ b/crates/argui-runtime/src/app.rs @@ -24,6 +24,7 @@ mod layout; mod model_updates; mod visibility; pub use inspect::{Inspection, InspectionCache}; +mod inertia; mod lifecycle; #[cfg(all( feature = "webview", @@ -111,6 +112,7 @@ pub(crate) struct Application { primary_touch: Option, selection_click: text_selection::SelectionClick, touch_selection: Option, + touch_selection_handle: Option, input_epoch: Instant, last_cursor: argui_ui::CursorIcon, modifiers: Modifiers, @@ -122,7 +124,7 @@ pub(crate) struct Application { pub(crate) tasks: Option, pending_scrollbar_drag: Option, pending_pointer_scroll: Option, - scroll_inertia: scroll::ScrollInertia, + scroll_inertia: inertia::ScrollInertia, scroll_gesture: argui_ui::ScrollGesture, programmatic_scroll: Option, last_scroll_physics: Option, @@ -231,6 +233,7 @@ impl Application { primary_touch: None, selection_click: text_selection::SelectionClick::default(), touch_selection: None, + touch_selection_handle: None, input_epoch: Instant::now(), last_cursor: argui_ui::CursorIcon::Default, modifiers: Modifiers::default(), @@ -242,7 +245,7 @@ impl Application { tasks: None, pending_scrollbar_drag: None, pending_pointer_scroll: None, - scroll_inertia: scroll::ScrollInertia::default(), + scroll_inertia: inertia::ScrollInertia::default(), scroll_gesture: argui_ui::ScrollGesture::default(), programmatic_scroll: None, last_scroll_physics: None, @@ -459,6 +462,9 @@ impl Application { update.merge(ui.window_blurred()); self.apply_ui_update(update, window, event_loop); } + if !focused { + self.update_ime(window); + } if focused && let (Some(ui), Some(layout)) = (&mut self.ui_tree, &self.ui_layout) { let update = ui.window_focused(&layout.hit_regions); self.apply_ui_update(update, window, event_loop); diff --git a/crates/argui-runtime/src/app/inertia.rs b/crates/argui-runtime/src/app/inertia.rs new file mode 100644 index 00000000..de88e6ce --- /dev/null +++ b/crates/argui-runtime/src/app/inertia.rs @@ -0,0 +1,204 @@ +use std::time::Duration; + +use argui_core::{Point, ScrollDelta}; +use argui_ui::{InertialScroll, NodeId, ScrollPhysics}; +use winit::event::TouchPhase; + +/// Tracks filtered pointer velocity and advances platform-independent scroll momentum. +#[derive(Debug)] +pub(super) struct ScrollInertia { + velocity: Point, + last_input: Option, + last_frame: Option, + released: bool, + target: Option, + config: InertialScroll, + point: Option, + dispatch_wheel: bool, +} + +impl Default for ScrollInertia { + fn default() -> Self { + Self { + velocity: Point::default(), + last_input: None, + last_frame: None, + released: false, + target: None, + config: InertialScroll::default(), + point: None, + dispatch_wheel: true, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct ScrollSample { + /// Pixel or line displacement reported by the current input event. + pub(super) delta: ScrollDelta, + /// Input phase used to begin, continue, release, or cancel momentum. + pub(super) phase: TouchPhase, + /// Monotonic time since the owning application started. + pub(super) now: Duration, + /// Scroll target under the input point, if any. + pub(super) target: Option, + /// Scroll physics selected for the target region. + pub(super) physics: ScrollPhysics, + /// Current viewport-space pointer position. + pub(super) point: Point, + /// Whether synthesized wheel events should accompany momentum. + pub(super) dispatch_wheel: bool, + /// Whether the system requests that animated motion be minimized. + pub(super) reduced_motion: bool, +} + +/// Maps finger movement to Argui's scroll delta convention. +/// +/// * `finger_delta` — viewport-space pointer displacement since the previous touch event. +/// * `natural` — whether rendered content should follow rather than oppose the finger. +/// +/// Returns the displacement in Argui's scroll convention for the requested direction. +#[must_use] +pub(super) const fn touch_scroll_delta(finger_delta: Point, natural: bool) -> Point { + if natural { + finger_delta + } else { + Point::new(-finger_delta.x, -finger_delta.y) + } +} + +impl ScrollInertia { + /// Records a scroll input and updates the filtered release velocity. + pub(super) fn observe(&mut self, sample: ScrollSample) { + let ScrollSample { + delta, + phase, + now, + target, + physics, + point, + dispatch_wheel, + reduced_motion, + } = sample; + if reduced_motion { + self.cancel(); + return; + } + let config = match physics { + ScrollPhysics::Hybrid => InertialScroll::default(), + ScrollPhysics::Inertial(config) => config, + ScrollPhysics::Direct | ScrollPhysics::Native => { + self.cancel(); + return; + } + }; + let config = valid_inertia(config); + let ScrollDelta::Pixels(delta) = delta else { + self.cancel(); + return; + }; + if phase == TouchPhase::Cancelled { + self.cancel(); + return; + } + let target_changed = self.target != target; + self.target = target; + self.config = config; + self.point = Some(point); + self.dispatch_wheel = dispatch_wheel; + if phase == TouchPhase::Ended { + self.released = true; + self.last_input = Some(now); + self.last_frame = Some(now); + return; + } + if phase == TouchPhase::Started || target_changed { + self.velocity = Point::default(); + } + let elapsed = self + .last_input + .map_or(1.0 / 60.0, |last| now.saturating_sub(last).as_secs_f32()) + .clamp(1.0 / 240.0, 1.0 / 20.0); + let sample = Point::new(delta.x / elapsed, delta.y / elapsed); + let sample_weight = config.sample_weight.clamp(0.0, 1.0); + let retained = 1.0 - sample_weight; + self.velocity.x = (self.velocity.x * retained + sample.x * sample_weight) + .clamp(-config.velocity_limit, config.velocity_limit); + self.velocity.y = (self.velocity.y * retained + sample.y * sample_weight) + .clamp(-config.velocity_limit, config.velocity_limit); + self.last_input = Some(now); + self.last_frame = Some(now); + self.released = false; + } + + /// Advances exponential momentum and returns the viewport scroll delta for this frame. + /// + /// * `now` — monotonic time since the owning application started. + /// + /// Returns the decayed pixel delta, prior pointer position, and wheel-dispatch policy while + /// momentum is active; returns `None` when no frame should be applied yet or motion has ended. + pub(super) fn advance(&mut self, now: Duration) -> Option<(Point, Point, bool)> { + let last_input = self.last_input?; + if !self.released && now.saturating_sub(last_input) < self.config.continuation_grace { + return None; + } + let elapsed = now + .saturating_sub(self.last_frame.unwrap_or(last_input)) + .as_secs_f32() + .clamp(1.0 / 240.0, 1.0 / 30.0); + self.last_frame = Some(now); + let decay = (-self.config.decay * elapsed).exp(); + let distance = if self.config.decay <= f32::EPSILON { + elapsed + } else { + (1.0 - decay) / self.config.decay + }; + let delta = Point::new(self.velocity.x * distance, self.velocity.y * distance); + self.velocity.x *= decay; + self.velocity.y *= decay; + if self.velocity.x.hypot(self.velocity.y) < self.config.stop_velocity { + self.cancel(); + } + self.point.map(|point| (delta, point, self.dispatch_wheel)) + } + + /// Cancels active momentum and forgets its input target. + pub(super) fn cancel(&mut self) { + *self = Self::default(); + } + + /// Reports whether another animation frame is needed for this input. + #[must_use] + pub(super) fn needs_frame(&self) -> bool { + self.last_input.is_some() + } + + /// Returns the node receiving the current momentum scroll, if one was selected. + #[must_use] + pub(super) const fn target(&self) -> Option { + self.target + } +} + +/// Replaces invalid inertia fields with defaults and clamps the sample weight. +fn valid_inertia(mut config: InertialScroll) -> InertialScroll { + let defaults = InertialScroll::default(); + config.velocity_limit = non_negative(config.velocity_limit, defaults.velocity_limit); + config.stop_velocity = non_negative(config.stop_velocity, defaults.stop_velocity); + config.decay = non_negative(config.decay, defaults.decay); + config.sample_weight = if config.sample_weight.is_finite() { + config.sample_weight.clamp(0.0, 1.0) + } else { + defaults.sample_weight + }; + config +} + +/// Returns a finite non-negative value or the supplied fallback. +fn non_negative(value: f32, fallback: f32) -> f32 { + if value.is_finite() { + value.max(0.0) + } else { + fallback + } +} diff --git a/crates/argui-runtime/src/app/lifecycle.rs b/crates/argui-runtime/src/app/lifecycle.rs index a25e0497..b4d6ac90 100644 --- a/crates/argui-runtime/src/app/lifecycle.rs +++ b/crates/argui-runtime/src/app/lifecycle.rs @@ -117,6 +117,7 @@ impl ApplicationHandler for Application { self.touch_points.clear(); self.primary_touch = None; self.touch_selection = None; + self.touch_selection_handle = None; } (self.on_event)(RuntimeEvent::Platform(PlatformEvent::Suspended)); } diff --git a/crates/argui-runtime/src/app/pointer.rs b/crates/argui-runtime/src/app/pointer.rs index 00922f04..6484f842 100644 --- a/crates/argui-runtime/src/app/pointer.rs +++ b/crates/argui-runtime/src/app/pointer.rs @@ -2,7 +2,7 @@ use argui_core::{Point, PointerEvent, PointerPhase}; use argui_platform::{ButtonState, ScrollDelta}; use argui_ui::{InteractionUpdate, UiTree}; -use super::Application; +use super::{Application, inertia::touch_scroll_delta}; #[cfg_attr(coverage_nightly, coverage(off))] impl Application { @@ -341,15 +341,26 @@ impl Application { self.primary_touch = Some(event.id); } event.primary = self.primary_touch == Some(event.id); - match event.phase { - PointerPhase::Pressed if event.primary => { - self.begin_touch_selection(event.id, event.position, window); - } - PointerPhase::Moved => self.move_touch_selection_candidate(event.id, event.position), - PointerPhase::Released | PointerPhase::Cancelled | PointerPhase::Left => { - self.cancel_touch_selection(event.id); + let started_selection_handle = event.phase == PointerPhase::Pressed + && event.primary + && self.begin_touch_selection_handle(event.id, event.position, window, event_loop); + let dragging_selection_handle = started_selection_handle + || self + .touch_selection_handle + .is_some_and(|capture| capture.tracks(event.id)); + if !dragging_selection_handle { + match event.phase { + PointerPhase::Pressed if event.primary => { + self.begin_touch_selection(event.id, event.position, window); + } + PointerPhase::Moved => { + self.move_touch_selection_candidate(event.id, event.position) + } + PointerPhase::Released | PointerPhase::Cancelled | PointerPhase::Left => { + self.cancel_touch_selection(event.id); + } + PointerPhase::Entered | PointerPhase::Pressed => {} } - PointerPhase::Entered | PointerPhase::Pressed => {} } let finger_delta = match event.phase { PointerPhase::Pressed => { @@ -370,9 +381,17 @@ impl Application { PointerPhase::Entered => None, }; let mut touch_scroll = None; + let mut pointer_default = None; let update = if let (Some(layout), Some(ui)) = (&self.ui_layout, &mut self.ui_tree) { let selecting = ui.document_selection_dragging() && event.primary; - let mut update = if selecting && event.phase == PointerPhase::Moved { + let mut update = if dragging_selection_handle && event.phase == PointerPhase::Moved { + Self::closest_static_text(layout, event.position) + .map_or_else(InteractionUpdate::default, |position| { + ui.drag_document_selection(position) + }) + } else if dragging_selection_handle { + InteractionUpdate::default() + } else if selecting && event.phase == PointerPhase::Moved { Self::closest_static_text(layout, event.position) .map_or_else(InteractionUpdate::default, |position| { ui.drag_document_selection(position) @@ -380,8 +399,30 @@ impl Application { } else { ui.pointer_event(event, &layout.hit_regions) }; + if event.phase == PointerPhase::Pressed && event.primary { + pointer_default = update + .events + .iter() + .find(|delivery| { + matches!( + delivery.kind, + argui_ui::UiEventKind::Pointer(argui_core::PointerEvent { + phase: PointerPhase::Pressed, + .. + }) + ) + }) + .cloned(); + } let default_prevented = update.events.iter().any(|event| event.default_prevented()); - if let Some(delta) = finger_delta.filter(|_| !selecting && !default_prevented) { + if let Some(finger_delta) = finger_delta.filter(|_| !selecting && !default_prevented) { + let natural = layout + .scroll_regions + .iter() + .rev() + .find(|region| region.config.enabled && region.contains(event.position)) + .is_none_or(|region| region.config.natural_touch_scroll); + let delta = touch_scroll_delta(finger_delta, natural); self.programmatic_scroll = None; touch_scroll = Some((Some(delta), event.phase)); update.merge(ui.scroll( @@ -389,7 +430,7 @@ impl Application { ScrollDelta::Pixels(delta), &layout.scroll_regions, )); - } else if event.primary { + } else if event.primary && !dragging_selection_handle { let phase = if selecting || default_prevented { PointerPhase::Cancelled } else { @@ -407,7 +448,15 @@ impl Application { if let Some(update) = update { self.apply_ui_update(update, window, event_loop); } - if matches!(event.phase, PointerPhase::Pressed | PointerPhase::Released) { + if pointer_default.is_some_and(|event| !event.default_prevented()) + && let (Some(ui), Some(layout)) = (&mut self.ui_tree, &self.ui_layout) + { + let update = ui.focus_pointer_default(event.id, &layout.hit_regions); + self.apply_ui_update(update, window, event_loop); + } + if !dragging_selection_handle + && matches!(event.phase, PointerPhase::Pressed | PointerPhase::Released) + { self.update_ime(window); } if matches!( @@ -420,6 +469,12 @@ impl Application { UiTree::release_document_selection, ); self.apply_ui_update(update, window, event_loop); + if self + .touch_selection_handle + .is_some_and(|capture| capture.tracks(event.id)) + { + self.touch_selection_handle = None; + } self.primary_touch = self.touch_points.keys().min_by_key(|id| id.get()).copied(); } event diff --git a/crates/argui-runtime/src/app/scroll.rs b/crates/argui-runtime/src/app/scroll.rs index ae1a5ab1..a245b948 100644 --- a/crates/argui-runtime/src/app/scroll.rs +++ b/crates/argui-runtime/src/app/scroll.rs @@ -1,40 +1,16 @@ use argui_core::{Point, ScrollDelta}; -use argui_ui::{InertialScroll, NodeId, ScrollBehavior, ScrollPhysics, ScrollRequest}; +use argui_ui::{NodeId, ScrollBehavior, ScrollPhysics, ScrollRequest}; use web_time::Instant; use winit::event::TouchPhase; -use crate::{RuntimeError, RuntimeEvent, app::Application}; +use crate::{ + RuntimeError, RuntimeEvent, + app::{Application, inertia::ScrollSample}, +}; mod request; use request::{ScrollTrack, scroll_tracks}; -#[derive(Debug)] -pub(super) struct ScrollInertia { - velocity: Point, - last_input: Option, - last_frame: Option, - released: bool, - target: Option, - config: InertialScroll, - point: Option, - dispatch_wheel: bool, -} - -impl Default for ScrollInertia { - fn default() -> Self { - Self { - velocity: Point::default(), - last_input: None, - last_frame: None, - released: false, - target: None, - config: InertialScroll::default(), - point: None, - dispatch_wheel: true, - } - } -} - #[derive(Clone, Copy, Debug)] pub(super) struct PendingScroll { delta: ScrollDelta, @@ -43,17 +19,6 @@ pub(super) struct PendingScroll { target: Option, } -#[derive(Clone, Copy, Debug)] -struct ScrollSample { - delta: ScrollDelta, - phase: TouchPhase, - now: Instant, - target: Option, - physics: ScrollPhysics, - point: Point, - dispatch_wheel: bool, -} - #[derive(Debug)] pub(super) struct ProgrammaticScroll { tracks: Vec, @@ -61,98 +26,6 @@ pub(super) struct ProgrammaticScroll { tween: argui_animation::Tween, } -impl ScrollInertia { - fn observe(&mut self, sample: ScrollSample) { - let ScrollSample { - delta, - phase, - now, - target, - physics, - point, - dispatch_wheel, - } = sample; - let config = match physics { - ScrollPhysics::Hybrid => InertialScroll::default(), - ScrollPhysics::Inertial(config) => config, - ScrollPhysics::Direct | ScrollPhysics::Native => { - self.cancel(); - return; - } - }; - let config = valid_inertia(config); - let ScrollDelta::Pixels(delta) = delta else { - self.cancel(); - return; - }; - if phase == TouchPhase::Cancelled { - self.cancel(); - return; - } - let target_changed = self.target != target; - self.target = target; - self.config = config; - self.point = Some(point); - self.dispatch_wheel = dispatch_wheel; - if phase == TouchPhase::Ended { - self.released = true; - self.last_input = Some(now); - self.last_frame = Some(now); - return; - } - if phase == TouchPhase::Started || target_changed { - self.velocity = Point::default(); - } - let elapsed = self - .last_input - .map_or(1.0 / 60.0, |last| now.duration_since(last).as_secs_f32()) - .clamp(1.0 / 240.0, 1.0 / 20.0); - let sample = Point::new(delta.x / elapsed, delta.y / elapsed); - let retained = 1.0 - config.sample_weight.clamp(0.0, 1.0); - let sample_weight = config.sample_weight.clamp(0.0, 1.0); - self.velocity.x = (self.velocity.x * retained + sample.x * sample_weight) - .clamp(-config.velocity_limit, config.velocity_limit); - self.velocity.y = (self.velocity.y * retained + sample.y * sample_weight) - .clamp(-config.velocity_limit, config.velocity_limit); - self.last_input = Some(now); - self.last_frame = Some(now); - self.released = false; - } - - fn advance(&mut self, now: Instant) -> Option<(Point, Point, bool)> { - let last_input = self.last_input?; - if !self.released && now.duration_since(last_input) < self.config.continuation_grace { - return None; - } - let elapsed = now - .duration_since(self.last_frame.unwrap_or(last_input)) - .as_secs_f32() - .clamp(1.0 / 240.0, 1.0 / 30.0); - self.last_frame = Some(now); - let decay = (-self.config.decay * elapsed).exp(); - let distance = if self.config.decay <= f32::EPSILON { - elapsed - } else { - (1.0 - decay) / self.config.decay - }; - let delta = Point::new(self.velocity.x * distance, self.velocity.y * distance); - self.velocity.x *= decay; - self.velocity.y *= decay; - if self.velocity.x.hypot(self.velocity.y) < self.config.stop_velocity { - self.cancel(); - } - self.point.map(|point| (delta, point, self.dispatch_wheel)) - } - - pub(super) fn cancel(&mut self) { - *self = Self::default(); - } - - fn needs_frame(&self) -> bool { - self.last_input.is_some() - } -} - pub(super) fn merge_delta(pending: &mut ScrollDelta, next: ScrollDelta) -> bool { let (pending, next) = match (pending, next) { (ScrollDelta::Lines(pending), ScrollDelta::Lines(next)) @@ -202,11 +75,12 @@ impl Application { self.scroll_inertia.observe(ScrollSample { delta: inertia_delta, phase, - now: Instant::now(), + now: self.input_epoch.elapsed(), target, physics, point, dispatch_wheel: true, + reduced_motion: self.environment.reduced_motion, }); if let Some(pending) = &mut self.pending_pointer_scroll { if !pending.dispatch_wheel @@ -240,8 +114,13 @@ impl Application { window: &dyn crate::host::WindowHost, event_loop: &dyn crate::host::LoopControl, ) { - let target = self.scroll_inertia.target; - if let Some((delta, point, dispatch_wheel)) = self.scroll_inertia.advance(Instant::now()) { + if self.environment.reduced_motion { + self.scroll_inertia.cancel(); + } + let target = self.scroll_inertia.target(); + if let Some((delta, point, dispatch_wheel)) = + self.scroll_inertia.advance(self.input_epoch.elapsed()) + { let delta = ScrollDelta::Pixels(delta); if let Some(pending) = &mut self.pending_pointer_scroll { if pending.dispatch_wheel != dispatch_wheel @@ -304,11 +183,12 @@ impl Application { self.scroll_inertia.observe(ScrollSample { delta: ScrollDelta::Pixels(delta.unwrap_or_default()), phase, - now: Instant::now(), + now: self.input_epoch.elapsed(), target, physics, point, dispatch_wheel: false, + reduced_motion: self.environment.reduced_motion, }); if self.scroll_inertia.needs_frame() { window.request_redraw(); @@ -373,7 +253,11 @@ impl Application { .map_or_else(argui_ui::InteractionUpdate::default, |target| { ui.scroll_from(target, point, delta, &layout.scroll_regions) }); + let did_scroll = update.scroll_changed; self.apply_ui_update(update, window, event_loop); + if !did_scroll { + self.scroll_inertia.cancel(); + } if !pending.dispatch_wheel { return; } @@ -507,24 +391,3 @@ impl Application { } } } - -fn valid_inertia(mut config: InertialScroll) -> InertialScroll { - let defaults = InertialScroll::default(); - config.velocity_limit = non_negative(config.velocity_limit, defaults.velocity_limit); - config.stop_velocity = non_negative(config.stop_velocity, defaults.stop_velocity); - config.decay = non_negative(config.decay, defaults.decay); - config.sample_weight = if config.sample_weight.is_finite() { - config.sample_weight.clamp(0.0, 1.0) - } else { - defaults.sample_weight - }; - config -} - -fn non_negative(value: f32, fallback: f32) -> f32 { - if value.is_finite() { - value.max(0.0) - } else { - fallback - } -} diff --git a/crates/argui-runtime/src/app/text_selection.rs b/crates/argui-runtime/src/app/text_selection.rs index adb6fc5b..47c31bc6 100644 --- a/crates/argui-runtime/src/app/text_selection.rs +++ b/crates/argui-runtime/src/app/text_selection.rs @@ -12,6 +12,23 @@ pub(super) struct TouchSelection { started: Instant, } +/// Captures the touch pointer currently dragging a text-selection endpoint. +#[derive(Clone, Copy, Debug)] +pub(super) struct TouchSelectionHandle { + id: PointerId, +} + +impl TouchSelectionHandle { + /// Reports whether this capture belongs to the supplied pointer. + /// + /// * `id` — pointer identity being checked. + /// + /// Returns `true` when `id` is the captured pointer. + pub(super) fn tracks(self, id: PointerId) -> bool { + self.id == id + } +} + impl TouchSelection { fn tracks(self, id: PointerId) -> bool { self.id == id @@ -97,6 +114,42 @@ impl Application { } } + /// Captures a touch press on a visible document-selection handle. + /// + /// * `id` — touch pointer that will own the drag. + /// * `point` — press position in viewport coordinates. + /// * `window` — host window used to request redraws after selection changes. + /// * `event_loop` — event-loop control used to apply the selection update. + /// + /// Returns `true` when a visible handle at `point` starts the drag; otherwise returns `false` + /// and leaves the current capture unchanged. + pub(super) fn begin_touch_selection_handle( + &mut self, + id: PointerId, + point: Point, + window: &dyn crate::host::WindowHost, + event_loop: &dyn crate::host::LoopControl, + ) -> bool { + let endpoint = self + .ui_layout + .as_ref() + .zip(self.ui_tree.as_ref()) + .and_then(|(layout, ui)| layout.selection_handle_at(ui, point)) + .map(|handle| handle.endpoint); + let Some(endpoint) = endpoint else { + return false; + }; + self.touch_selection = None; + self.touch_selection_handle = Some(TouchSelectionHandle { id }); + self.scroll_inertia.cancel(); + self.programmatic_scroll = None; + if let Some(ui) = &mut self.ui_tree { + let update = ui.begin_document_selection_handle_drag(endpoint); + self.apply_ui_update(update, window, event_loop); + } + true + } + pub(super) fn move_touch_selection_candidate(&mut self, id: PointerId, point: Point) { move_touch_candidate( &mut self.touch_selection, diff --git a/crates/argui-runtime/src/host.rs b/crates/argui-runtime/src/host.rs index a02c5c75..986beeaa 100644 --- a/crates/argui-runtime/src/host.rs +++ b/crates/argui-runtime/src/host.rs @@ -5,7 +5,7 @@ pub(crate) use event_loop::EventProxy; pub(crate) mod gtk; use argui_core::Insets; -#[cfg(any(target_os = "android", target_os = "ios"))] +#[cfg(target_os = "ios")] use argui_core::{Point, Rect, Size}; use argui_platform::WindowCapabilities; use winit::{ @@ -127,6 +127,8 @@ impl WindowHost for Arc { } fn set_ime_allowed(&self, allowed: bool) { self.as_ref().set_ime_allowed(allowed); + #[cfg(target_os = "android")] + argui_platform::mobile::set_android_soft_input_visible(allowed); } fn set_ime_cursor_area(&self, position: LogicalPosition, size: LogicalSize) { self.as_ref().set_ime_cursor_area(position, size); @@ -234,24 +236,8 @@ impl WindowHost for std::rc::Rc { #[cfg(target_os = "android")] fn android_safe_area_insets(window: &Window, scale_factor: f32) -> Option { - use winit::platform::android::WindowExtAndroid; - - let size = window.inner_size(); - let content = window.content_rect(); - Insets::try_from_physical_rects( - Rect::new( - Point::default(), - Size::new(size.width as f32, size.height as f32), - ), - Rect::new( - Point::new(content.left as f32, content.top as f32), - Size::new( - content.right as f32 - content.left as f32, - content.bottom as f32 - content.top as f32, - ), - ), - scale_factor, - ) + let _ = window; + argui_platform::mobile::android_safe_area_insets(scale_factor) } #[cfg(target_os = "ios")] diff --git a/crates/argui-runtime/src/launch.rs b/crates/argui-runtime/src/launch.rs index 3d3a6614..3ed26d6a 100644 --- a/crates/argui-runtime/src/launch.rs +++ b/crates/argui-runtime/src/launch.rs @@ -326,6 +326,8 @@ fn launch_android( android_app: AndroidApp, application: MultiApplication, ) -> Result<(), RuntimeError> { + argui_platform::mobile::initialize_android(&android_app) + .map_err(|error| RuntimeError::NativeHost(error.to_string()))?; let mut builder = EventLoop::::with_user_event(); builder.with_android_app(android_app); let event_loop = builder.build().map_err(PlatformError::from)?; diff --git a/crates/argui-runtime/src/model/events.rs b/crates/argui-runtime/src/model/events.rs index b4d4f104..c7fa8109 100644 --- a/crates/argui-runtime/src/model/events.rs +++ b/crates/argui-runtime/src/model/events.rs @@ -287,9 +287,10 @@ impl Context { { return true; } - if let Some(listener) = listeners.next() - && listener.state.upgrade().is_some_and(|state| state.active()) - { + let listener = listeners + .next() + .expect("a queued listener batch cannot be empty"); + if listener.state.upgrade().is_some_and(|state| state.active()) { (listener.callback)(&event); } listeners.len() == 0 diff --git a/crates/argui-runtime/src/model/handler.rs b/crates/argui-runtime/src/model/handler.rs index 733585e5..6476ea5a 100644 --- a/crates/argui-runtime/src/model/handler.rs +++ b/crates/argui-runtime/src/model/handler.rs @@ -1,6 +1,8 @@ use std::rc::Rc; -use argui_ui::{EventHandlerId, EventListener, EventType, UiEvent}; +use argui_ui::{ + EventHandler, EventHandlerId, EventListener, EventType, FromHandlerValue, UiEvent, ValueHandler, +}; use super::{Context, Entity, Render}; @@ -51,6 +53,169 @@ impl HandlerRegistry { } impl Context { + fn register_handler( + &mut self, + handler: impl Fn(&mut T, &UiEvent, &mut Context) + 'static, + ) -> EventHandler { + let owner = self.owner.as_ref().map_or_else( + || panic!("event handlers require an entity render context"), + |owner| owner.0.get(), + ); + let entity = self + .entity + .as_ref() + .and_then(super::WeakEntity::upgrade) + .expect("event handlers require a live entity render context"); + let slot = entity + .0 + .presentation + .handlers + .borrow() + .next_slot(self.handlers.len()); + self.handlers.push(Rc::new(handler)); + EventHandler::from_identity(EventHandlerId::new(argui_ui::EventOwnerId(owner), slot)) + } + + /// Registers a local state callback that invalidates this presentation after delivery. + /// + /// `callback` receives mutable application state. The returned opaque handler can be + /// attached by a widget such as `Button::on_click`. The runtime calls + /// [`Context::notify`] after `callback` returns. + /// + /// # Panics + /// Panics if called outside a live entity render context. + #[must_use] + pub fn callback(&mut self, callback: impl Fn(&mut T) + 'static) -> EventHandler { + self.register_handler(move |model, _, cx| { + callback(model); + cx.notify(); + }) + } + + /// Registers a full event callback without implicitly invalidating the presentation. + /// + /// `handler` receives mutable application state, the routed [`UiEvent`], and the + /// presentation [`Context`]. It may inspect or stop propagation and request commands, + /// focus, or invalidation explicitly. The returned opaque identity is bound to an event + /// type by the widget that receives it. + /// + /// # Panics + /// Panics if called outside a live entity render context. + #[must_use] + pub fn event_handler( + &mut self, + handler: impl Fn(&mut T, &UiEvent, &mut Context) + 'static, + ) -> EventHandler { + self.register_handler(handler) + } + + fn mapped_handler( + &mut self, + map: impl Fn(&UiEvent) -> Option + 'static, + handler: impl Fn(&mut T, V, &UiEvent, &mut Context) + 'static, + ) -> ValueHandler { + ValueHandler::from_handler(self.register_handler(move |model, event, cx| { + if let Some(value) = map(event) { + handler(model, value, event, cx); + } + })) + } + + /// Registers a typed callback for a value supplied by a widget listener. + /// + /// `callback` receives mutable application state plus the widget's domain + /// value. The presentation is invalidated after the callback returns. A + /// delivery without a compatible value is ignored. + /// + /// # Panics + /// Panics if called outside a live entity render context. + #[must_use] + pub fn value_callback( + &mut self, + callback: impl Fn(&mut T, V) + 'static, + ) -> ValueHandler { + self.mapped_handler(V::from_handler_event, move |model, value, _, cx| { + callback(model, value); + cx.notify(); + }) + } + + /// Registers a typed full event handler for a widget-supplied value. + /// + /// `handler` receives the domain value, routed event, and context. It must + /// request invalidation explicitly when required. A delivery without a + /// compatible value is ignored. + /// + /// # Panics + /// Panics if called outside a live entity render context. + #[must_use] + pub fn value_event_handler( + &mut self, + handler: impl Fn(&mut T, V, &UiEvent, &mut Context) + 'static, + ) -> ValueHandler { + self.mapped_handler(V::from_handler_event, handler) + } + + /// Registers a text-input callback receiving the newly edited string. + /// + /// `callback` receives mutable application state and the new controlled + /// value. Non-input events are ignored. Successful delivery automatically + /// invalidates the presentation. + /// + /// # Panics + /// Panics if called outside a live entity render context. + #[must_use] + pub fn input_callback( + &mut self, + callback: impl Fn(&mut T, String) + 'static, + ) -> ValueHandler { + self.value_callback(callback) + } + + /// Registers a submit callback receiving the submitted string. + /// + /// `callback` receives mutable application state and the submitted value. + /// Non-submit events are ignored. Successful delivery automatically + /// invalidates the presentation. + /// + /// # Panics + /// Panics if called outside a live entity render context. + #[must_use] + pub fn submit_callback( + &mut self, + callback: impl Fn(&mut T, String) + 'static, + ) -> ValueHandler { + self.value_callback(callback) + } + + /// Registers a text-input handler with access to the routed event and context. + /// + /// `handler` receives the edited string and does not implicitly invalidate. + /// + /// # Panics + /// Panics if called outside a live entity render context. + #[must_use] + pub fn input_event_handler( + &mut self, + handler: impl Fn(&mut T, String, &UiEvent, &mut Context) + 'static, + ) -> ValueHandler { + self.value_event_handler(handler) + } + + /// Registers a submit handler with access to the routed event and context. + /// + /// `handler` receives the submitted string and does not implicitly invalidate. + /// + /// # Panics + /// Panics if called outside a live entity render context. + #[must_use] + pub fn submit_event_handler( + &mut self, + handler: impl Fn(&mut T, String, &UiEvent, &mut Context) + 'static, + ) -> ValueHandler { + self.value_event_handler(handler) + } + /// Creates an action binding whose callback runs when the matching action fires. /// /// `id` selects the action; `state` describes its current action state; @@ -85,26 +250,7 @@ impl Context { event: EventType, handler: impl Fn(&mut T, &UiEvent, &mut Context) + 'static, ) -> EventListener { - let owner = self.owner.as_ref().map_or_else( - || panic!("listeners require an entity render context"), - |owner| owner.0.get(), - ); - let entity = self - .entity - .as_ref() - .and_then(super::WeakEntity::upgrade) - .expect("listeners require a live entity render context"); - let slot = entity - .0 - .presentation - .handlers - .borrow() - .next_slot(self.handlers.len()); - self.handlers.push(Rc::new(handler)); - EventListener::new( - event, - EventHandlerId::new(argui_ui::EventOwnerId(owner), slot), - ) + self.event_handler(handler).listener(event) } } diff --git a/crates/argui-runtime/src/tasks/dispatcher.rs b/crates/argui-runtime/src/tasks/dispatcher.rs index 2e71ca64..b0939957 100644 --- a/crates/argui-runtime/src/tasks/dispatcher.rs +++ b/crates/argui-runtime/src/tasks/dispatcher.rs @@ -45,6 +45,8 @@ struct Inner { wake: Wake, #[cfg(not(target_arch = "wasm32"))] executor: RefCell>, + #[cfg(not(target_arch = "wasm32"))] + paused: bool, } impl Drop for Inner { fn drop(&mut self) { @@ -69,7 +71,16 @@ impl TaskRuntime { /// /// `wake` schedules a later call to [`Self::drain`] when completions are ready. pub fn new(wake: impl Fn() + Send + Sync + 'static) -> Self { - Self::with_wake(Arc::new(wake)) + Self::with_wake(Arc::new(wake), false) + } + #[cfg(not(target_arch = "wasm32"))] + /// Creates a dispatcher whose Tokio clock advances only through + /// [`Self::advance_time`]. + /// + /// `wake` schedules a later drain when task completions become available. + /// This constructor is intended for deterministic headless hosts. + pub fn new_paused(wake: impl Fn() + Send + Sync + 'static) -> Self { + Self::with_wake(Arc::new(wake), true) } #[cfg(target_arch = "wasm32")] /// Creates a browser task dispatcher with a local completion wake callback. @@ -78,7 +89,8 @@ impl TaskRuntime { pub fn new(wake: impl Fn() + 'static) -> Self { Self::with_wake(Rc::new(wake)) } - fn with_wake(wake: Wake) -> Self { + #[cfg(not(target_arch = "wasm32"))] + fn with_wake(wake: Wake, paused: bool) -> Self { let wake_pending = Arc::new(std::sync::atomic::AtomicBool::new(false)); let pending = wake_pending.clone(); #[cfg(not(target_arch = "wasm32"))] @@ -102,8 +114,28 @@ impl TaskRuntime { sender, receiver: RefCell::new(receiver), wake, - #[cfg(not(target_arch = "wasm32"))] executor: RefCell::new(None), + paused, + })) + } + #[cfg(target_arch = "wasm32")] + fn with_wake(wake: Wake) -> Self { + let wake_pending = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let pending = wake_pending.clone(); + let wake: Wake = Rc::new(move || { + if !pending.swap(true, std::sync::atomic::Ordering::AcqRel) { + wake(); + } + }); + let (sender, receiver) = mpsc::channel(64); + Self(Rc::new(Inner { + closed: Cell::new(false), + wake_pending, + entries: RefCell::new(HashMap::new()), + next: Cell::new(0), + sender, + receiver: RefCell::new(receiver), + wake, })) } #[must_use] @@ -159,6 +191,41 @@ impl TaskRuntime { (self.0.wake)(); } + #[cfg(not(target_arch = "wasm32"))] + /// Advances a paused dispatcher clock and lets all newly ready work run. + /// + /// `duration` is added to Tokio's virtual clock. A normally timed dispatcher + /// accepts only a zero duration so production hosts cannot accidentally alter + /// wall-clock scheduling. + /// + /// # Errors + /// Returns [`TaskError::Unavailable`] after shutdown or when a non-zero + /// duration is supplied to a normally timed dispatcher. + pub fn advance_time(&self, duration: std::time::Duration) -> Result<(), TaskError> { + if self.0.closed.get() { + return Err(TaskError::Unavailable); + } + if !self.0.paused && !duration.is_zero() { + return Err(TaskError::Unavailable); + } + let _ = self.executor()?; + let mut executor = self.0.executor.borrow_mut(); + executor + .as_mut() + .expect("executor was initialized") + .block_on(async { + if self.0.paused { + tokio::time::advance(duration).await; + } + for _ in 0..8 { + tokio::task::yield_now().await; + } + }); + drop(executor); + self.drain(); + Ok(()) + } + pub(super) fn register( &self, state: Arc, @@ -193,12 +260,18 @@ impl TaskRuntime { } let mut executor = self.0.executor.borrow_mut(); if executor.is_none() { + let mut builder = if self.0.paused { + tokio::runtime::Builder::new_current_thread() + } else { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder.worker_threads( + std::thread::available_parallelism().map_or(1, |count| count.get().min(4)), + ); + builder + }; + builder.enable_all().start_paused(self.0.paused); *executor = Some( - tokio::runtime::Builder::new_multi_thread() - .worker_threads( - std::thread::available_parallelism().map_or(1, |count| count.get().min(4)), - ) - .enable_all() + builder .build() .map_err(|error| TaskError::Runtime(error.to_string()))?, ); diff --git a/crates/argui-runtime/tests/app.rs b/crates/argui-runtime/tests/app.rs index 8cd13f0e..f604b7ff 100644 --- a/crates/argui-runtime/tests/app.rs +++ b/crates/argui-runtime/tests/app.rs @@ -1,3 +1,4 @@ mod app { + mod inertia; mod inspect; } diff --git a/crates/argui-runtime/tests/app/inertia.rs b/crates/argui-runtime/tests/app/inertia.rs new file mode 100644 index 00000000..ebfabee2 --- /dev/null +++ b/crates/argui-runtime/tests/app/inertia.rs @@ -0,0 +1,103 @@ +use std::time::Duration; + +use argui_core::{Point, ScrollDelta}; +use argui_ui::ScrollPhysics; +use winit::event::TouchPhase; + +#[path = "../../src/app/inertia.rs"] +mod implementation; + +use implementation::{ScrollInertia, ScrollSample, touch_scroll_delta}; + +fn sample(delta: Point, phase: TouchPhase, now: Duration) -> ScrollSample { + ScrollSample { + delta: ScrollDelta::Pixels(delta), + phase, + now, + target: None, + physics: ScrollPhysics::Hybrid, + point: Point::new(12.0, 24.0), + dispatch_wheel: false, + reduced_motion: false, + } +} + +fn seed_release(inertia: &mut ScrollInertia) { + inertia.observe(sample( + Point::default(), + TouchPhase::Started, + Duration::ZERO, + )); + inertia.observe(sample( + Point::new(0.0, 8.0), + TouchPhase::Moved, + Duration::from_millis(16), + )); + inertia.observe(sample( + Point::default(), + TouchPhase::Ended, + Duration::from_millis(32), + )); +} + +#[test] +fn released_scroll_decays_deterministically() { + let mut inertia = ScrollInertia::default(); + seed_release(&mut inertia); + assert_eq!(inertia.target(), None); + + let first = inertia.advance(Duration::from_millis(48)).unwrap().0; + let second = inertia.advance(Duration::from_millis(64)).unwrap().0; + + assert!(first.y > 0.0); + assert!(second.y > 0.0 && second.y < first.y); +} + +#[test] +fn touch_scroll_uses_the_layout_delta_convention_so_content_follows_the_finger() { + assert_eq!( + touch_scroll_delta(Point::new(-8.0, 3.0), true), + Point::new(-8.0, 3.0) + ); + assert_eq!( + touch_scroll_delta(Point::new(-8.0, 3.0), false), + Point::new(8.0, -3.0) + ); +} + +#[test] +fn new_contact_discards_previous_release_velocity() { + let mut inertia = ScrollInertia::default(); + seed_release(&mut inertia); + inertia.observe(sample( + Point::default(), + TouchPhase::Started, + Duration::from_millis(40), + )); + + assert!(inertia.advance(Duration::from_secs(1)).is_none()); +} + +#[test] +fn reduced_motion_and_cancelled_touch_stop_momentum() { + let mut inertia = ScrollInertia::default(); + seed_release(&mut inertia); + let mut reduced = sample( + Point::default(), + TouchPhase::Moved, + Duration::from_millis(40), + ); + reduced.reduced_motion = true; + inertia.observe(reduced); + assert!(!inertia.needs_frame()); + assert!(inertia.advance(Duration::from_secs(1)).is_none()); + + seed_release(&mut inertia); + inertia.observe(sample( + Point::default(), + TouchPhase::Cancelled, + Duration::from_millis(40), + )); + assert!(!inertia.needs_frame()); + assert!(inertia.advance(Duration::from_secs(1)).is_none()); +} diff --git a/crates/argui-runtime/tests/model/handler.rs b/crates/argui-runtime/tests/model/handler.rs index c713d622..8d4db5fc 100644 --- a/crates/argui-runtime/tests/model/handler.rs +++ b/crates/argui-runtime/tests/model/handler.rs @@ -1,5 +1,8 @@ use argui_runtime::{Context, Entity, Render}; -use argui_ui::{ActionId, ActionScope, ActionState, Element, Shortcut, UiEventKind, UiTree}; +use argui_ui::{ + ActionId, ActionScope, ActionState, ClickEvent, Element, ElementKind, EventType, Shortcut, + UiEventKind, UiTree, +}; #[derive(Default)] struct Commands { @@ -63,3 +66,69 @@ fn action_default_routes_once_to_the_retained_owner_and_revalidates_disabled_sta ); assert_eq!(entity.read(|model| model.hits), 2); } + +#[derive(Default)] +struct CallbackModes { + value: usize, +} + +impl Render for CallbackModes { + fn render(&mut self, cx: &mut Context) -> Element { + let automatic = cx.callback(|model| model.value += 1); + let explicit = cx.event_handler(|model, event, _| { + assert_eq!(event.target_key(), Some("explicit")); + model.value += 10; + }); + let incompatible = cx.value_callback(|model, value: usize| model.value += value); + Element::column([ + Element::text(self.value.to_string()).keyed("value"), + Element::text("automatic") + .keyed("automatic") + .on(automatic.direct_listener(EventType::Click)), + Element::text("explicit") + .keyed("explicit") + .on(explicit.direct_listener(EventType::Click)), + Element::text("incompatible") + .keyed("incompatible") + .on(incompatible.direct_listener(EventType::Click)), + ]) + } +} + +fn click_key(entity: &Entity, tree: &mut UiTree, key: &str) { + let target = tree + .node_ids() + .iter() + .copied() + .find(|node| tree.key(*node) == Some(key)) + .unwrap(); + for event in tree.event_deliveries(target, UiEventKind::Click(ClickEvent::accessibility())) { + if event.should_dispatch() { + entity.dispatch_event(&event); + } + } +} + +#[test] +fn callback_invalidates_while_event_handler_requires_explicit_invalidation() { + let entity = Entity::new(CallbackModes::default()); + let first = entity.render(); + let mut tree = UiTree::new(first.clone()); + + click_key(&entity, &mut tree, "explicit"); + assert_eq!(entity.read(|model| model.value), 10); + assert!(entity.render().ptr_eq(&first)); + + click_key(&entity, &mut tree, "incompatible"); + assert_eq!(entity.read(|model| model.value), 10); + assert!(entity.render().ptr_eq(&first)); + + click_key(&entity, &mut tree, "automatic"); + assert_eq!(entity.read(|model| model.value), 11); + let rebuilt = entity.render(); + assert!(!rebuilt.ptr_eq(&first)); + let ElementKind::Text { content, .. } = &rebuilt.children[0].kind else { + panic!("value text"); + }; + assert_eq!(content.as_str(), "11"); +} diff --git a/crates/argui-runtime/tests/tasks.rs b/crates/argui-runtime/tests/tasks.rs index d586d7a8..714f231f 100644 --- a/crates/argui-runtime/tests/tasks.rs +++ b/crates/argui-runtime/tests/tasks.rs @@ -16,6 +16,29 @@ fn cancellation_is_shared_and_empty_slot_is_idle() { slot.cancel(); } +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn dispatcher_clock_rejects_wall_time_control_and_shutdown() { + use argui_runtime::tasks::{TaskError, TaskRuntime}; + + let realtime = TaskRuntime::new(|| {}); + assert!(matches!( + realtime.advance_time(std::time::Duration::from_millis(1)), + Err(TaskError::Unavailable) + )); + realtime.advance_time(std::time::Duration::ZERO).unwrap(); + + let paused = TaskRuntime::new_paused(|| {}); + paused + .advance_time(std::time::Duration::from_millis(1)) + .unwrap(); + paused.shutdown(); + assert!(matches!( + paused.advance_time(std::time::Duration::ZERO), + Err(TaskError::Unavailable) + )); +} + #[test] #[cfg(not(target_arch = "wasm32"))] fn data_models_share_the_executor_without_rendering_or_parent_retention() { diff --git a/crates/argui-testing/Cargo.toml b/crates/argui-testing/Cargo.toml new file mode 100644 index 00000000..6f5b0a6c --- /dev/null +++ b/crates/argui-testing/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "argui-testing" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +description = "Headless application testing harness for Argui" + +[features] +default = ["tasks"] +tasks = ["argui-runtime/tasks"] + +[dependencies] +argui-accessibility.workspace = true +argui-animation.workspace = true +argui-core.workspace = true +argui-layout.workspace = true +argui-platform.workspace = true +argui-runtime.workspace = true +argui-text.workspace = true +argui-ui.workspace = true +thiserror.workspace = true +epaint_default_fonts.workspace = true + +[dev-dependencies] +argui-widgets = { path = "../argui-widgets", features = ["all"] } + +[lints] +workspace = true diff --git a/crates/argui-testing/src/actions.rs b/crates/argui-testing/src/actions.rs new file mode 100644 index 00000000..f33c54ef --- /dev/null +++ b/crates/argui-testing/src/actions.rs @@ -0,0 +1,556 @@ +use argui_accessibility::{Role, SemanticAction, SemanticValue}; +use argui_core::{ + Key, KeyInput, KeyState, Modifiers, Point, PointerEvent, PointerId, PointerKind, PointerPhase, + ScrollDelta, Size, +}; +use argui_platform::PlatformEvent; +use argui_runtime::{AppEvent, AppModel, Render, WindowEnvironment}; +use argui_ui::{FocusRequest, InteractionUpdate, UiEventKind}; + +use crate::{Selector, SemanticMatcher, TestApp, TestError, TestNode}; + +impl TestApp { + /// 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-ui/src/event.rs b/crates/argui-ui/src/event.rs index 97d4a2f1..6bea281c 100644 --- a/crates/argui-ui/src/event.rs +++ b/crates/argui-ui/src/event.rs @@ -4,6 +4,13 @@ use argui_core::{KeyInput, Point, PointerEvent, PointerId, PointerPhase, Rect, S use crate::{ClickEvent, Element, NodeId, SelectionCapabilities, SemanticAction, SemanticValue}; +mod listener; +pub use listener::{ + ColorHandlerValue, ColorValueFormat, ContinuousValuePhase, EventFilter, EventHandler, + EventHandlerId, EventListener, EventListenerOptions, EventOwnerId, EventPhase, + FromHandlerValue, HandlerValue, RangeHandlerValue, SplitHandlerValue, ValueHandler, +}; + impl Element { /// Registers an event listener on this element. /// @@ -88,131 +95,6 @@ impl EventType { } } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum EventPhase { - Capture, - #[default] - Target, - Bubble, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct EventListenerOptions { - pub capture: bool, - pub passive: bool, - pub once: bool, -} - -impl EventListenerOptions { - /// Enables capture phase delivery for this listener. - #[must_use] - pub const fn capture(mut self, capture: bool) -> Self { - self.capture = capture; - self - } - - /// Marks this listener passive, disallowing default prevention. - #[must_use] - pub const fn passive(mut self, passive: bool) -> Self { - self.passive = passive; - self - } - - /// Marks this listener for delivery only once. - /// - /// * `once` — whether to remove the listener after its first delivery. - #[must_use] - pub const fn once(mut self, once: bool) -> Self { - self.once = once; - self - } -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub struct EventOwnerId(pub usize); - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub struct EventHandlerId { - owner: EventOwnerId, - slot: u32, -} - -impl EventHandlerId { - /// Creates a handler identity from an owner and its local slot. - /// - /// * `owner` — identity of the registering owner. - /// * `slot` — handler slot within that owner. - #[doc(hidden)] - #[must_use] - pub const fn new(owner: EventOwnerId, slot: u32) -> Self { - Self { owner, slot } - } - - /// Returns the owner associated with this handler. - #[doc(hidden)] - #[must_use] - pub const fn owner(self) -> EventOwnerId { - self.owner - } - - /// Returns this handler's owner-local slot. - #[doc(hidden)] - #[must_use] - pub const fn slot(self) -> u32 { - self.slot - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct EventListener { - pub event: EventType, - pub options: EventListenerOptions, - pub(crate) handler: EventHandlerId, -} - -impl EventListener { - /// Creates a listener for an event and handler. - /// - /// * `event` — event type to receive. - /// * `handler` — registered event handler identity. - #[doc(hidden)] - #[must_use] - pub const fn new(event: EventType, handler: EventHandlerId) -> Self { - Self { - event, - options: EventListenerOptions { - capture: false, - passive: false, - once: false, - }, - handler, - } - } - - /// Enables or disables capture phase delivery. - #[must_use] - pub const fn capture(mut self, capture: bool) -> Self { - self.options.capture = capture; - self - } - - /// Enables or disables passive listener behavior. - #[must_use] - pub const fn passive(mut self, passive: bool) -> Self { - self.options.passive = passive; - self - } - - /// Enables or disables one-time delivery. - /// - /// * `once` — whether to remove the listener after its first delivery. - #[must_use] - pub const fn once(mut self, once: bool) -> Self { - self.options.once = once; - self - } -} - #[derive(Clone, Debug, PartialEq)] pub enum UiEventKind { Action(crate::ActionInvocation), @@ -342,6 +224,7 @@ pub struct UiEvent { current_target: NodeId, current_key: Option, current_handler: Option, + handler_value: Option, phase: EventPhase, passive: bool, once: Option>>, @@ -351,6 +234,17 @@ pub struct UiEvent { history: Option<(bool, bool)>, } +/// Internal listener-delivery metadata copied onto one dispatched event. +pub(crate) struct EventDelivery { + pub(crate) current_target: NodeId, + pub(crate) current_key: Option, + pub(crate) current_handler: Option, + pub(crate) phase: EventPhase, + pub(crate) passive: bool, + pub(crate) once: Option>>, + pub(crate) handler_value: Option, +} + impl PartialEq for UiEvent { fn eq(&self, other: &Self) -> bool { ( @@ -359,6 +253,7 @@ impl PartialEq for UiEvent { &self.target_key, self.current_target, &self.current_key, + &self.handler_value, self.phase, ) == ( other.target, @@ -366,6 +261,7 @@ impl PartialEq for UiEvent { &other.target_key, other.current_target, &other.current_key, + &other.handler_value, other.phase, ) } @@ -388,6 +284,7 @@ impl UiEvent { current_target: target, current_key: key, current_handler: None, + handler_value: None, phase: EventPhase::Target, passive: false, once: None, @@ -398,25 +295,19 @@ impl UiEvent { } } - pub(crate) fn delivery( - &self, - current_target: NodeId, - current_key: Option, - current_handler: Option, - phase: EventPhase, - passive: bool, - once: Option>>, - ) -> Self { + /// Clones this event for one listener with the supplied propagation metadata. + pub(crate) fn delivery(&self, delivery: EventDelivery) -> Self { Self { target: self.target, kind: self.kind.clone(), target_key: self.target_key.clone(), - current_target, - current_key, - current_handler, - phase, - passive, - once, + current_target: delivery.current_target, + current_key: delivery.current_key, + current_handler: delivery.current_handler, + handler_value: delivery.handler_value, + phase: delivery.phase, + passive: delivery.passive, + once: delivery.once, control: Rc::clone(&self.control), default_action: self.default_action, focused_node: self.focused_node, @@ -437,6 +328,13 @@ impl UiEvent { self.current_handler } + /// Returns the typed value attached by the current widget listener. + #[doc(hidden)] + #[must_use] + pub const fn handler_value(&self) -> Option<&HandlerValue> { + self.handler_value.as_ref() + } + /// Returns the key of the original target, if it had one. #[must_use] pub fn target_key(&self) -> Option<&str> { diff --git a/crates/argui-ui/src/event/listener.rs b/crates/argui-ui/src/event/listener.rs new file mode 100644 index 00000000..49808f6b --- /dev/null +++ b/crates/argui-ui/src/event/listener.rs @@ -0,0 +1,414 @@ +use std::fmt; +use std::{ + hash::{Hash, Hasher}, + marker::PhantomData, +}; + +use super::{EventType, UiEventKind}; + +mod color; +mod value; +pub use color::{ColorHandlerValue, ColorValueFormat}; +use value::HandlerValueSource; +pub use value::{ + ContinuousValuePhase, FromHandlerValue, HandlerValue, RangeHandlerValue, SplitHandlerValue, +}; + +/// Additional deterministic predicate applied before listener delivery. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum EventFilter { + #[default] + Any, + EscapePressed, + EnterPressed, + SpacePressed, + TabPressed, + VerticalArrowPressed, + ArrowUpPressed, + ArrowDownPressed, + ArrowLeftPressed, + ArrowRightPressed, + HomePressed, + EndPressed, + PageUpPressed, + PageDownPressed, + PanEndedLeft, + PanEndedRight, + DoubleClick, +} + +impl EventFilter { + /// Returns whether this filter accepts the supplied event payload. + /// + /// * `kind` — event payload considered before listener delivery. + pub(crate) fn accepts(self, kind: &UiEventKind) -> bool { + let pressed = |key| { + matches!( + kind, + UiEventKind::KeyInput(argui_core::KeyInput { + key: candidate, + state: argui_core::KeyState::Pressed, + .. + }) if *candidate == key + ) + }; + match self { + Self::Any => true, + Self::EscapePressed => pressed(argui_core::Key::Escape), + Self::EnterPressed => pressed(argui_core::Key::Enter), + Self::SpacePressed => pressed(argui_core::Key::Character(" ".into())), + Self::TabPressed => pressed(argui_core::Key::Tab), + Self::VerticalArrowPressed => { + pressed(argui_core::Key::ArrowDown) || pressed(argui_core::Key::ArrowUp) + } + Self::ArrowUpPressed => pressed(argui_core::Key::ArrowUp), + Self::ArrowDownPressed => pressed(argui_core::Key::ArrowDown), + Self::ArrowLeftPressed => pressed(argui_core::Key::ArrowLeft), + Self::ArrowRightPressed => pressed(argui_core::Key::ArrowRight), + Self::HomePressed => pressed(argui_core::Key::Home), + Self::EndPressed => pressed(argui_core::Key::End), + Self::PageUpPressed => pressed(argui_core::Key::PageUp), + Self::PageDownPressed => pressed(argui_core::Key::PageDown), + Self::PanEndedLeft => matches!( + kind, + UiEventKind::Gesture(crate::GestureEvent { + phase: crate::GesturePhase::Ended, + kind: crate::GestureKind::Pan { total, .. }, + .. + }) if total.x <= -40.0 + ), + Self::PanEndedRight => matches!( + kind, + UiEventKind::Gesture(crate::GestureEvent { + phase: crate::GesturePhase::Ended, + kind: crate::GestureKind::Pan { total, .. }, + .. + }) if total.x >= 40.0 + ), + Self::DoubleClick => matches!(kind, UiEventKind::Click(click) if click.count >= 2), + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum EventPhase { + Capture, + #[default] + Target, + Bubble, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct EventListenerOptions { + pub capture: bool, + pub passive: bool, + pub once: bool, + pub target_only: bool, + pub filter: EventFilter, +} + +impl EventListenerOptions { + /// Enables capture phase delivery for this listener. + #[must_use] + pub const fn capture(mut self, capture: bool) -> Self { + self.capture = capture; + self + } + + /// Marks this listener passive, disallowing default prevention. + #[must_use] + pub const fn passive(mut self, passive: bool) -> Self { + self.passive = passive; + self + } + + /// Marks this listener for delivery only once. + /// + /// * `once` — whether to remove the listener after its first delivery. + #[must_use] + pub const fn once(mut self, once: bool) -> Self { + self.once = once; + self + } + + /// Restricts delivery to events whose original target is the listener's element. + /// + /// `target_only` prevents activation bubbling from an interactive descendant from + /// invoking the listener while preserving ordinary capture and bubble semantics + /// for other listeners. + #[must_use] + pub const fn target_only(mut self, target_only: bool) -> Self { + self.target_only = target_only; + self + } + + /// Applies an additional deterministic `filter` before delivery. + #[must_use] + pub const fn filter(mut self, filter: EventFilter) -> Self { + self.filter = filter; + self + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct EventOwnerId(pub usize); + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct EventHandlerId { + owner: EventOwnerId, + slot: u32, +} + +impl EventHandlerId { + /// Creates a handler identity from an owner and its local slot. + /// + /// * `owner` — identity of the registering owner. + /// * `slot` — handler slot within that owner. + #[doc(hidden)] + #[must_use] + pub const fn new(owner: EventOwnerId, slot: u32) -> Self { + Self { owner, slot } + } + + /// Returns the owner associated with this handler. + #[doc(hidden)] + #[must_use] + pub const fn owner(self) -> EventOwnerId { + self.owner + } + + /// Returns this handler's owner-local slot. + #[doc(hidden)] + #[must_use] + pub const fn slot(self) -> u32 { + self.slot + } +} + +/// Opaque identity of a callback registered by the application runtime. +/// +/// A handler does not select an event type. Widgets bind it to their semantic +/// event while building an [`crate::Element`]. The identity is presentation-owned and +/// cannot be constructed or inspected by normal application code. +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub struct EventHandler { + identity: EventHandlerId, +} + +impl fmt::Debug for EventHandler { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("EventHandler") + } +} + +impl EventHandler { + /// Wraps the runtime's presentation-owned handler identity. + /// + /// `identity` is allocated by the runtime for the current render slot. + #[doc(hidden)] + #[must_use] + pub const fn from_identity(identity: EventHandlerId) -> Self { + Self { identity } + } + + /// Binds this handler to `event` using ordinary propagation semantics. + #[doc(hidden)] + #[must_use] + pub const fn listener(self, event: EventType) -> EventListener { + EventListener::new(event, self.identity) + } + + /// Binds this handler to `event` only when the element is the original target. + #[doc(hidden)] + #[must_use] + pub const fn direct_listener(self, event: EventType) -> EventListener { + self.listener(event).target_only(true) + } +} + +/// Opaque runtime handler whose callback receives a domain value of type `V`. +/// +/// The value adapter remains in the runtime registry; this marker never stores +/// callbacks or application data in an element description. +pub struct ValueHandler { + handler: EventHandler, + marker: PhantomData, +} + +impl Copy for ValueHandler {} + +impl Clone for ValueHandler { + fn clone(&self) -> Self { + *self + } +} + +impl PartialEq for ValueHandler { + fn eq(&self, other: &Self) -> bool { + self.handler == other.handler + } +} + +impl Eq for ValueHandler {} + +impl Hash for ValueHandler { + fn hash(&self, state: &mut H) { + self.handler.hash(state); + } +} + +impl ValueHandler { + /// Associates an opaque event handler with its runtime-adapted value type. + /// + /// `handler` is the underlying presentation-owned handler identity. + #[doc(hidden)] + #[must_use] + pub const fn from_handler(handler: EventHandler) -> Self { + Self { + handler, + marker: PhantomData, + } + } + + /// Binds this value handler to `event` for the element's own delivery. + #[doc(hidden)] + #[must_use] + pub const fn direct_listener(self, event: EventType) -> EventListener { + self.handler.direct_listener(event) + } +} + +impl> ValueHandler { + /// Binds this handler to `event` and supplies the typed `value` on delivery. + #[doc(hidden)] + #[must_use] + pub fn direct_listener_value(self, event: EventType, value: V) -> EventListener { + self.direct_listener(event).handler_value(value) + } + + /// Binds this handler to a propagating `event` and supplies `value`. + #[doc(hidden)] + #[must_use] + pub fn listener_value(self, event: EventType, value: V) -> EventListener { + self.handler.listener(event).handler_value(value) + } +} + +impl fmt::Debug for ValueHandler { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ValueHandler") + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EventListener { + pub event: EventType, + pub options: EventListenerOptions, + pub(crate) handler: EventHandlerId, + pub(crate) value: Option, + pub(crate) target_key: Option, +} + +impl EventListener { + /// Creates a listener for an event and handler. + /// + /// * `event` — event type to receive. + /// * `handler` — registered event handler identity. + #[doc(hidden)] + #[must_use] + pub const fn new(event: EventType, handler: EventHandlerId) -> Self { + Self { + event, + options: EventListenerOptions { + capture: false, + passive: false, + once: false, + target_only: false, + filter: EventFilter::Any, + }, + handler, + value: None, + target_key: None, + } + } + + /// Enables or disables capture phase delivery. + #[must_use] + pub const fn capture(mut self, capture: bool) -> Self { + self.options.capture = capture; + self + } + + /// Enables or disables passive listener behavior. + #[must_use] + pub const fn passive(mut self, passive: bool) -> Self { + self.options.passive = passive; + self + } + + /// Enables or disables one-time delivery. + /// + /// * `once` — whether to remove the listener after its first delivery. + #[must_use] + pub const fn once(mut self, once: bool) -> Self { + self.options.once = once; + self + } + + /// Restricts delivery to an event originally targeted at this element. + /// + /// `target_only` is useful for direct control callbacks that must ignore + /// activation bubbling from interactive descendants. + #[must_use] + pub const fn target_only(mut self, target_only: bool) -> Self { + self.options.target_only = target_only; + self + } + + /// Applies an additional deterministic `filter` before delivery. + #[must_use] + pub const fn filter(mut self, filter: EventFilter) -> Self { + self.options.filter = filter; + self + } + + /// Associates a typed widget value with this listener delivery. + #[doc(hidden)] + #[must_use] + pub fn handler_value(mut self, value: impl Into) -> Self { + self.value = Some(HandlerValueSource::Static(value.into())); + self + } + + /// Derives a continuous numeric value from the delivered event and target bounds. + #[doc(hidden)] + #[must_use] + pub fn range_handler_value(mut self, value: RangeHandlerValue) -> Self { + self.value = Some(HandlerValueSource::Range(value)); + self + } + + /// Derives a color-picker value from the delivered event and target bounds. + #[doc(hidden)] + #[must_use] + pub fn color_handler_value(mut self, value: ColorHandlerValue) -> Self { + self.value = Some(HandlerValueSource::Color(value)); + self + } + + /// Derives a split-pane size from the delivered event. + #[doc(hidden)] + #[must_use] + pub fn split_handler_value(mut self, value: SplitHandlerValue) -> Self { + self.value = Some(HandlerValueSource::Split(value)); + self + } + + /// Restricts delivery to events whose original target has `key`. + #[doc(hidden)] + #[must_use] + pub fn target_key(mut self, key: impl Into) -> Self { + self.target_key = Some(key.into()); + self + } +} diff --git a/crates/argui-ui/src/event/listener/color.rs b/crates/argui-ui/src/event/listener/color.rs new file mode 100644 index 00000000..4cad451f --- /dev/null +++ b/crates/argui-ui/src/event/listener/color.rs @@ -0,0 +1,251 @@ +use argui_core::{Color, Key, KeyState, Rect}; + +use crate::{GestureKind, GesturePhase, UiEventKind}; + +use super::{ContinuousValuePhase, RangeHandlerValue}; + +/// Text channel representation used by a color-value adapter. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ColorValueFormat { + Hex, + Rgb, + Hsl, + Hsv, +} + +/// Pure color mapping used by color-picker listeners in normal event dispatch. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ColorHandlerValue { + Pad { + hsv: [f32; 3], + alpha: f32, + }, + Hue { + hsv: [f32; 3], + alpha: f32, + phase: ContinuousValuePhase, + }, + Alpha { + hsv: [f32; 3], + alpha: f32, + phase: ContinuousValuePhase, + }, + Field { + hsv: [f32; 3], + alpha: f32, + format: ColorValueFormat, + index: usize, + }, +} + +impl ColorHandlerValue { + /// Creates a saturation/brightness pad mapping from controlled HSV and alpha values. + #[must_use] + pub const fn pad(hsv: [f32; 3], alpha: f32) -> Self { + Self::Pad { hsv, alpha } + } + + /// Creates a hue-track mapping for the requested continuous delivery `phase`. + #[must_use] + pub const fn hue(hsv: [f32; 3], alpha: f32, phase: ContinuousValuePhase) -> Self { + Self::Hue { hsv, alpha, phase } + } + + /// Creates an alpha-track mapping for the requested continuous delivery `phase`. + #[must_use] + pub const fn alpha(hsv: [f32; 3], alpha: f32, phase: ContinuousValuePhase) -> Self { + Self::Alpha { hsv, alpha, phase } + } + + /// Creates a text-field mapping for one channel `index` in `format`. + #[must_use] + pub const fn field(hsv: [f32; 3], alpha: f32, format: ColorValueFormat, index: usize) -> Self { + Self::Field { + hsv, + alpha, + format, + index, + } + } + + pub(super) fn event_value(self, kind: &UiEventKind, bounds: Option) -> Option { + let (hsv, alpha) = match self { + Self::Pad { mut hsv, alpha } => { + update_pad(&mut hsv, kind, bounds)?; + (hsv, alpha) + } + Self::Hue { + mut hsv, + alpha, + phase, + } => { + hsv[0] = RangeHandlerValue::new(hsv[0], 0.0, 360.0, 1.0, false, false, phase) + .event_value(kind, bounds)?; + (hsv, alpha) + } + Self::Alpha { hsv, alpha, phase } => { + let alpha = + RangeHandlerValue::new(alpha * 100.0, 0.0, 100.0, 1.0, false, false, phase) + .event_value(kind, bounds)? + / 100.0; + (hsv, alpha) + } + Self::Field { + mut hsv, + mut alpha, + format, + index, + } => { + let text = match kind { + UiEventKind::TextChanged(text) | UiEventKind::Submitted(text) => text, + _ => return None, + }; + update_field(&mut hsv, &mut alpha, format, index, text)?; + (hsv, alpha) + } + }; + let [r, g, b] = hsv_to_rgb(hsv); + Some(Color::srgba(r, g, b, alpha)) + } +} + +fn update_pad(hsv: &mut [f32; 3], kind: &UiEventKind, bounds: Option) -> Option<()> { + match kind { + UiEventKind::Gesture(gesture) if gesture.phase != GesturePhase::Cancelled => { + let position = match gesture.kind { + GestureKind::Tap { position } | GestureKind::Pan { position, .. } => position, + _ => return None, + }; + let bounds = bounds?; + if bounds.size.width <= 0.0 || bounds.size.height <= 0.0 { + return None; + } + hsv[1] = ((position.x - bounds.origin.x) / bounds.size.width).clamp(0.0, 1.0); + hsv[2] = (1.0 - (position.y - bounds.origin.y) / bounds.size.height).clamp(0.0, 1.0); + } + UiEventKind::KeyInput(input) + if input.state == KeyState::Pressed + && !input.modifiers.command() + && !input.modifiers.alt => + { + let step = if input.modifiers.shift { 0.001 } else { 0.01 }; + match input.key { + Key::ArrowLeft => hsv[1] = (hsv[1] - step).max(0.0), + Key::ArrowRight => hsv[1] = (hsv[1] + step).min(1.0), + Key::ArrowDown => hsv[2] = (hsv[2] - step).max(0.0), + Key::ArrowUp => hsv[2] = (hsv[2] + step).min(1.0), + Key::Home => { + hsv[1] = 0.0; + hsv[2] = 1.0; + } + Key::End => { + hsv[1] = 1.0; + hsv[2] = 0.0; + } + _ => return None, + } + } + _ => return None, + } + Some(()) +} + +fn update_field( + hsv: &mut [f32; 3], + alpha: &mut f32, + format: ColorValueFormat, + index: usize, + text: &str, +) -> Option<()> { + if format == ColorValueFormat::Hex { + let color = Color::from_hex(text.trim()).ok()?; + let [r, g, b, next_alpha] = color.to_srgba(); + *hsv = rgb_to_hsv([r, g, b], hsv[0]); + *alpha = next_alpha; + return Some(()); + } + let value = text.trim().parse::().ok()?; + let maximum = match (format, index) { + (ColorValueFormat::Rgb, 0..=2) => 255.0, + (ColorValueFormat::Hsl | ColorValueFormat::Hsv, 0) => 360.0, + _ => 100.0, + }; + if !value.is_finite() || !(0.0..=maximum).contains(&value) { + return None; + } + if index == 3 { + *alpha = value / 100.0; + return Some(()); + } + match format { + ColorValueFormat::Rgb => { + let mut rgb = hsv_to_rgb(*hsv); + *rgb.get_mut(index)? = value / 255.0; + *hsv = rgb_to_hsv(rgb, hsv[0]); + } + ColorValueFormat::Hsv => { + *hsv.get_mut(index)? = if index == 0 { value } else { value / 100.0 }; + } + ColorValueFormat::Hsl => { + let [h, s, v] = *hsv; + let lightness = v * (1.0 - s * 0.5); + let saturation = if lightness == 0.0 || lightness == 1.0 { + 0.0 + } else { + (v - lightness) / lightness.min(1.0 - lightness) + }; + let mut hsl = [h, saturation, lightness]; + *hsl.get_mut(index)? = if index == 0 { value } else { value / 100.0 }; + let [h, s, l] = hsl; + let v = l + s * l.min(1.0 - l); + *hsv = [ + h, + if v == 0.0 { + hsv[1] + } else { + 2.0 * (1.0 - l / v) + }, + v, + ]; + } + ColorValueFormat::Hex => unreachable!("hex is handled before numeric parsing"), + } + Some(()) +} + +fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let chroma = v * s; + let sector = h.rem_euclid(360.0) / 60.0; + let x = chroma * (1.0 - (sector % 2.0 - 1.0).abs()); + let rgb = match sector as u32 { + 0 => [chroma, x, 0.0], + 1 => [x, chroma, 0.0], + 2 => [0.0, chroma, x], + 3 => [0.0, x, chroma], + 4 => [x, 0.0, chroma], + _ => [chroma, 0.0, x], + }; + rgb.map(|channel| channel + v - chroma) +} + +fn rgb_to_hsv([r, g, b]: [f32; 3], previous_hue: f32) -> [f32; 3] { + let maximum = r.max(g).max(b); + let minimum = r.min(g).min(b); + let delta = maximum - minimum; + let hue = if delta < f32::EPSILON { + previous_hue + } else if maximum == r { + 60.0 * ((g - b) / delta).rem_euclid(6.0) + } else if maximum == g { + 60.0 * ((b - r) / delta + 2.0) + } else { + 60.0 * ((r - g) / delta + 4.0) + }; + [ + hue, + if maximum == 0.0 { 0.0 } else { delta / maximum }, + maximum, + ] +} diff --git a/crates/argui-ui/src/event/listener/value.rs b/crates/argui-ui/src/event/listener/value.rs new file mode 100644 index 00000000..beda8609 --- /dev/null +++ b/crates/argui-ui/src/event/listener/value.rs @@ -0,0 +1,334 @@ +use crate::{SemanticAction, SemanticValue, UiEvent, UiEventKind}; + +use super::ColorHandlerValue; + +/// Delivery stage selected by a continuous-value widget handler. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContinuousValuePhase { + Change, + Commit, +} + +/// Pure value mapping used by range-like widgets inside normal event dispatch. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RangeHandlerValue { + current: f32, + minimum: f32, + maximum: f32, + step: f32, + vertical: bool, + reverse: bool, + phase: ContinuousValuePhase, +} + +impl RangeHandlerValue { + /// Creates a range value mapping for one controlled value and delivery phase. + #[doc(hidden)] + #[must_use] + pub fn new( + current: f32, + minimum: f32, + maximum: f32, + step: f32, + vertical: bool, + reverse: bool, + phase: ContinuousValuePhase, + ) -> Self { + let lower = minimum.min(maximum); + let upper = minimum.max(maximum); + Self { + current: current.clamp(lower, upper), + minimum: lower, + maximum: upper, + step: step.abs().max(f32::EPSILON), + vertical, + reverse, + phase, + } + } + + fn clamp(self, value: f32) -> f32 { + let stepped = ((value - self.minimum) / self.step).round() * self.step + self.minimum; + stepped.clamp(self.minimum, self.maximum) + } + + pub(super) fn event_value( + self, + kind: &UiEventKind, + bounds: Option, + ) -> Option { + match kind { + UiEventKind::KeyInput(input) => self.key_value(input), + UiEventKind::SemanticAction { action, value } => self.semantic_value(*action, value), + UiEventKind::Gesture(gesture) => self.gesture_value(*gesture, bounds), + _ => None, + } + } + + fn key_value(self, input: &argui_core::KeyInput) -> Option { + let delta = match input.key { + argui_core::Key::ArrowLeft | argui_core::Key::ArrowDown => -self.step, + argui_core::Key::ArrowRight | argui_core::Key::ArrowUp => self.step, + argui_core::Key::Home => self.minimum - self.current, + argui_core::Key::End => self.maximum - self.current, + _ => return None, + }; + match self.phase { + ContinuousValuePhase::Change if input.state == argui_core::KeyState::Pressed => { + Some(self.clamp(self.current + delta)) + } + ContinuousValuePhase::Commit if input.state == argui_core::KeyState::Released => { + Some(self.current) + } + _ => None, + } + } + + fn semantic_value(self, action: SemanticAction, value: &Option) -> Option { + if self.phase != ContinuousValuePhase::Commit { + return None; + } + let value = match action { + SemanticAction::Increment => self.current + self.step, + SemanticAction::Decrement => self.current - self.step, + SemanticAction::SetValue => match value { + Some(SemanticValue::Number { value, .. }) => *value as f32, + _ => return None, + }, + _ => return None, + }; + Some(self.clamp(value)) + } + + fn gesture_value( + self, + gesture: crate::GestureEvent, + bounds: Option, + ) -> Option { + let is_commit = gesture.phase == crate::GesturePhase::Ended; + if (self.phase == ContinuousValuePhase::Commit) != is_commit + || gesture.phase == crate::GesturePhase::Cancelled + { + return None; + } + let position = match gesture.kind { + crate::GestureKind::Tap { position } | crate::GestureKind::Pan { position, .. } => { + position + } + _ => return None, + }; + let bounds = bounds?; + let ratio = if self.vertical { + (position.y - bounds.origin.y) / bounds.size.height.max(f32::EPSILON) + } else { + (position.x - bounds.origin.x) / bounds.size.width.max(f32::EPSILON) + } + .clamp(0.0, 1.0); + let ratio = if self.reverse { 1.0 - ratio } else { ratio }; + Some(self.clamp(self.minimum + ratio * (self.maximum - self.minimum))) + } +} + +/// Pure split-pane size mapping used inside normal event dispatch. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SplitHandlerValue { + current: f32, + minimum: f32, + maximum: f32, + reset: f32, + horizontal: bool, + trailing: bool, +} + +impl SplitHandlerValue { + /// Creates a split size adapter from controlled bounds and orientation. + #[must_use] + pub fn new( + current: f32, + minimum: f32, + maximum: f32, + reset: f32, + horizontal: bool, + trailing: bool, + ) -> Self { + Self { + current, + minimum, + maximum, + reset, + horizontal, + trailing, + } + } + + fn event_value(self, kind: &UiEventKind) -> Option { + let direction = if self.trailing { -1.0 } else { 1.0 }; + let value = match kind { + UiEventKind::Gesture(gesture) => { + let crate::GestureKind::Pan { total, .. } = gesture.kind else { + return None; + }; + if gesture.phase == crate::GesturePhase::Cancelled { + self.current + } else { + self.current + (if self.horizontal { total.x } else { total.y }) * direction + } + } + UiEventKind::Click(click) if click.count >= 2 => self.reset, + UiEventKind::KeyInput(input) if input.state == argui_core::KeyState::Pressed => { + let step = if input.modifiers.shift { 1.0 } else { 10.0 }; + match (&input.key, self.horizontal) { + (argui_core::Key::ArrowLeft, true) | (argui_core::Key::ArrowUp, false) => { + self.current - step * direction + } + (argui_core::Key::ArrowRight, true) | (argui_core::Key::ArrowDown, false) => { + self.current + step * direction + } + (argui_core::Key::Home, _) => self.minimum, + (argui_core::Key::End, _) => self.maximum, + _ => return None, + } + } + _ => return None, + }; + value + .is_finite() + .then(|| value.clamp(self.minimum, self.maximum)) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum HandlerValueSource { + Static(HandlerValue), + Range(RangeHandlerValue), + Color(ColorHandlerValue), + Split(SplitHandlerValue), +} + +impl HandlerValueSource { + pub(crate) fn resolve( + &self, + kind: &UiEventKind, + bounds: Option, + ) -> Option { + match self { + Self::Static(value) => Some(value.clone()), + Self::Range(range) => range.event_value(kind, bounds).map(HandlerValue::Number), + Self::Color(color) => color.event_value(kind, bounds).map(HandlerValue::Color), + Self::Split(split) => split.event_value(kind).map(HandlerValue::Number), + } + } +} + +/// Typed value carried from a widget listener to a runtime callback. +#[doc(hidden)] +#[derive(Clone, Debug, PartialEq)] +pub enum HandlerValue { + Bool(bool), + Checked(crate::CheckedState), + Color(argui_core::Color), + Index(usize), + IndexList(Vec), + Number(f32), + Text(String), + TextList(Vec), +} + +impl From for HandlerValue { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl From for HandlerValue { + fn from(value: crate::CheckedState) -> Self { + Self::Checked(value) + } +} + +impl From for HandlerValue { + fn from(value: argui_core::Color) -> Self { + Self::Color(value) + } +} + +impl From for HandlerValue { + fn from(value: usize) -> Self { + Self::Index(value) + } +} + +impl From> for HandlerValue { + fn from(value: Vec) -> Self { + Self::IndexList(value) + } +} + +impl From for HandlerValue { + fn from(value: f32) -> Self { + Self::Number(value) + } +} + +impl From for HandlerValue { + fn from(value: String) -> Self { + Self::Text(value) + } +} + +impl From<&str> for HandlerValue { + fn from(value: &str) -> Self { + Self::Text(value.to_owned()) + } +} + +impl From> for HandlerValue { + fn from(value: Vec) -> Self { + Self::TextList(value) + } +} + +/// Converts the current widget delivery into its typed callback value. +pub trait FromHandlerValue: Sized + 'static { + /// Returns the typed value for `event`, or `None` when incompatible. + #[doc(hidden)] + fn from_handler_event(event: &UiEvent) -> Option; +} + +macro_rules! from_value { + ($ty:ty, $variant:ident) => { + impl FromHandlerValue for $ty { + fn from_handler_event(event: &UiEvent) -> Option { + match event.handler_value()? { + HandlerValue::$variant(value) => Some(value.clone()), + _ => None, + } + } + } + }; +} + +from_value!(bool, Bool); +from_value!(crate::CheckedState, Checked); +from_value!(argui_core::Color, Color); +from_value!(usize, Index); +from_value!(Vec, IndexList); +from_value!(f32, Number); +from_value!(Vec, TextList); + +impl FromHandlerValue for String { + fn from_handler_event(event: &UiEvent) -> Option { + match event.handler_value() { + Some(HandlerValue::Text(value)) => Some(value.clone()), + _ => match &event.kind { + UiEventKind::TextChanged(value) | UiEventKind::Submitted(value) => { + Some(value.clone()) + } + _ => None, + }, + } + } +} diff --git a/crates/argui-ui/src/interaction.rs b/crates/argui-ui/src/interaction.rs index 609bab5a..2ada5724 100644 --- a/crates/argui-ui/src/interaction.rs +++ b/crates/argui-ui/src/interaction.rs @@ -319,7 +319,8 @@ impl RawUpdate { #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct InteractionState { hovered: HashMap, - pressed: HashMap, + mouse_position: Option, + pressed: HashMap, keyboard_pressed: Option, focused: Option, focus_visible: bool, @@ -341,6 +342,14 @@ impl InteractionState { pub(crate) fn captured_node(&self, pointer: PointerId) -> Option { self.captured.get(&pointer).copied() } + + /// Returns the most recently observed mouse position. + /// + /// Returns `None` before the tree receives a mouse position or after the pointer leaves. + pub(crate) const fn mouse_position(&self) -> Option { + self.mouse_position + } + pub fn visual_states(&self, node: NodeId) -> VisualStates { let mut states = VisualStates::NONE; if self.focused == Some(node) { @@ -352,8 +361,10 @@ impl InteractionState { if self.hovered.values().any(|hovered| *hovered == node) { states.insert(VisualState::Hovered); } - if self.pressed.values().any(|pressed| *pressed == node) - || self.keyboard_pressed == Some(node) + if self.pressed.iter().any(|(pointer, press)| { + press.target == node + && (!press.activation_cancelled || self.captured.get(pointer) == Some(&node)) + }) || self.keyboard_pressed == Some(node) { states.insert(VisualState::Pressed); } @@ -366,11 +377,15 @@ impl InteractionState { regions: &[HitRegion], preserve_on_background: bool, ) -> RawUpdate { - let target = self.pressed.get(&pointer).copied().filter(|target| { - regions.iter().any(|region| { - region.node == *target && region.enabled && region.focus_policy.is_focusable() - }) - }); + let target = self + .pressed + .get(&pointer) + .map(|press| press.target) + .filter(|target| { + regions.iter().any(|region| { + region.node == *target && region.enabled && region.focus_policy.is_focusable() + }) + }); let Some(target) = target else { return if preserve_on_background { RawUpdate::default() @@ -508,7 +523,7 @@ impl InteractionState { pub fn retain(&mut self, ids: &[NodeId]) { let exists = |candidate: Option| candidate.is_some_and(|id| ids.contains(&id)); self.hovered.retain(|_, node| ids.contains(node)); - self.pressed.retain(|_, node| ids.contains(node)); + self.pressed.retain(|_, press| ids.contains(&press.target)); if !exists(self.keyboard_pressed) { self.keyboard_pressed = None; } diff --git a/crates/argui-ui/src/interaction/pointer.rs b/crates/argui-ui/src/interaction/pointer.rs index 72a2ea33..71cc14af 100644 --- a/crates/argui-ui/src/interaction/pointer.rs +++ b/crates/argui-ui/src/interaction/pointer.rs @@ -13,8 +13,18 @@ pub(super) struct ClickRecord { count: u8, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct PressRecord { + pub target: NodeId, + origin: Point, + pub activation_cancelled: bool, +} + impl InteractionState { pub fn pointer_moved(&mut self, event: PointerEvent, regions: &[HitRegion]) -> RawUpdate { + if event.id == PointerId::MOUSE { + self.mouse_position = Some(event.position); + } let hit = hit_test(regions, event.position).filter(|region| region.enabled); let hovered = self.hovered.get(&event.id).copied(); let mut update = RawUpdate::default(); @@ -36,6 +46,25 @@ impl InteractionState { } update.paint_changed = true; } + if event.phase == PointerPhase::Moved + && let Some(press) = self.pressed.get_mut(&event.id) + && !press.activation_cancelled + { + let delta = Point::new( + event.position.x - press.origin.x, + event.position.y - press.origin.y, + ); + let slop = self.pointer_settings.activation_slop_distance(); + if !delta.x.is_finite() + || !delta.y.is_finite() + || delta.x * delta.x + delta.y * delta.y > slop * slop + { + let keeps_pressed = self.captured.get(&event.id) == Some(&press.target); + press.activation_cancelled = true; + self.last_click = None; + update.paint_changed |= !keeps_pressed; + } + } if event.phase == PointerPhase::Moved && let Some(target) = self .captured @@ -50,6 +79,9 @@ impl InteractionState { pub fn pointer_left(&mut self, event: PointerEvent) -> RawUpdate { let mut update = RawUpdate::default(); + if event.id == PointerId::MOUSE { + self.mouse_position = None; + } if let Some(node) = self.hovered.remove(&event.id) { update.push(node, UiEventKind::Pointer(event)); update.paint_changed = true; @@ -59,10 +91,20 @@ impl InteractionState { pub fn primary_pressed(&mut self, event: PointerEvent) -> RawUpdate { let mut update = RawUpdate::default(); + if event.id == PointerId::MOUSE { + self.mouse_position = Some(event.position); + } let Some(target) = self.hovered.get(&event.id).copied() else { return update; }; - self.pressed.insert(event.id, target); + self.pressed.insert( + event.id, + PressRecord { + target, + origin: event.position, + activation_cancelled: false, + }, + ); update.push(target, UiEventKind::Pointer(event)); update.paint_changed = true; update @@ -79,12 +121,13 @@ impl InteractionState { if let Some(target) = captured { update.push(target, UiEventKind::LostPointerCapture(event.id)); } - if let Some(target) = pressed - && hovered == Some(target) + if let Some(press) = pressed + && !press.activation_cancelled + && hovered == Some(press.target) { - let count = self.click_count(target, event); + let count = self.click_count(press.target, event); update.push( - target, + press.target, UiEventKind::Click(ClickEvent::pointer(event, count)), ); } @@ -95,13 +138,16 @@ impl InteractionState { pub fn primary_cancelled(&mut self, event: PointerEvent) -> RawUpdate { let mut update = RawUpdate::default(); let captured = self.captured.remove(&event.id); - if let Some(target) = captured.or_else(|| self.pressed.get(&event.id).copied()) { + if let Some(target) = + captured.or_else(|| self.pressed.get(&event.id).map(|press| press.target)) + { update.push(target, UiEventKind::Pointer(event)); } if let Some(target) = captured { update.push(target, UiEventKind::LostPointerCapture(event.id)); } update.paint_changed = self.pressed.remove(&event.id).is_some(); + self.last_click = None; update } @@ -130,9 +176,9 @@ impl InteractionState { pub fn window_blurred(&mut self) -> RawUpdate { let mut update = RawUpdate::default(); - for (pointer, target) in self.pressed.drain() { + for (pointer, press) in self.pressed.drain() { update.push( - target, + press.target, UiEventKind::Pointer(PointerEvent { id: pointer, phase: PointerPhase::Cancelled, @@ -142,6 +188,7 @@ impl InteractionState { update.paint_changed = true; } self.hovered.clear(); + self.mouse_position = None; self.release_keyboard(&mut update, None); for (pointer, target) in self.captured.drain() { update.push(target, UiEventKind::LostPointerCapture(pointer)); diff --git a/crates/argui-ui/src/lib.rs b/crates/argui-ui/src/lib.rs index 6b00b8b5..9c4f21f0 100644 --- a/crates/argui-ui/src/lib.rs +++ b/crates/argui-ui/src/lib.rs @@ -67,8 +67,10 @@ pub use cursor::CursorIcon; pub use effect::{EffectScope, ScopedEffect}; pub use element::{Element, ElementKind, TextEditorSpec}; pub use event::{ + ColorHandlerValue, ColorValueFormat, ContinuousValuePhase, EventFilter, EventHandler, EventHandlerId, EventListener, EventListenerOptions, EventOwnerId, EventPhase, EventType, - UiEvent, UiEventKind, + FromHandlerValue, HandlerValue, RangeHandlerValue, SplitHandlerValue, UiEvent, UiEventKind, + ValueHandler, }; pub use focus::{FocusContainment, FocusRequest, FocusScope, FocusTarget, InitialFocus}; pub use gesture::{ @@ -115,8 +117,8 @@ pub use text_input::{ TextSelectionRequest, }; pub use text_selection::{ - DocumentTextPoint, DocumentTextSelection, SelectionCapabilities, SelectionCommand, - SelectionGranularity, TextSelectionHighlight, TextSelectionStyle, UserSelect, + DocumentSelectionEndpoint, DocumentTextPoint, DocumentTextSelection, SelectionCapabilities, + SelectionCommand, SelectionGranularity, TextSelectionHighlight, TextSelectionStyle, UserSelect, }; pub use tree::{TreeUpdate, TreeUpdateStats, UiTree}; pub use virtual_list::{ diff --git a/crates/argui-ui/src/scroll_config.rs b/crates/argui-ui/src/scroll_config.rs index c6398ee9..c5035e41 100644 --- a/crates/argui-ui/src/scroll_config.rs +++ b/crates/argui-ui/src/scroll_config.rs @@ -104,6 +104,7 @@ pub struct ScrollConfig { pub enabled: bool, pub axes: ScrollAxes, pub polarity: ScrollPolarity, + pub natural_touch_scroll: bool, pub propagation: ScrollPropagation, pub line_size: f32, pub multiplier: f32, @@ -120,6 +121,7 @@ impl Default for ScrollConfig { enabled: true, axes: ScrollAxes::Vertical, polarity: ScrollPolarity::Normal, + natural_touch_scroll: true, propagation: ScrollPropagation::Chain, line_size: 40.0, multiplier: 1.0, @@ -163,6 +165,17 @@ impl ScrollConfig { self } + /// Sets whether content follows a finger during direct touch scrolling. + /// + /// * `natural` — `true` makes content follow the finger; `false` reverses touch movement. + /// + /// Wheel and trackpad deltas remain controlled by [`Self::polarity`]. + #[must_use] + pub const fn natural_touch_scroll(mut self, natural: bool) -> Self { + self.natural_touch_scroll = natural; + self + } + /// Sets whether unconsumed scroll deltas chain to ancestors. /// * `propagation` — policy for forwarding unused scroll movement. #[must_use] diff --git a/crates/argui-ui/src/text_selection.rs b/crates/argui-ui/src/text_selection.rs index f37b6ef3..caffbeb1 100644 --- a/crates/argui-ui/src/text_selection.rs +++ b/crates/argui-ui/src/text_selection.rs @@ -1,10 +1,10 @@ -use std::ops::Range; - use argui_core::{CaretAffinity, TextPosition}; use argui_paint::{Color, CornerRadii, Fill}; use crate::{Element, InteractionUpdate, NodeId, UiTree}; +mod document; +mod handles; mod policy; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -108,6 +108,15 @@ pub struct DocumentTextSelection { pub focus: DocumentTextPoint, } +/// Identifies one directed endpoint of a document text selection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DocumentSelectionEndpoint { + /// The selection's anchor endpoint. + Anchor, + /// The selection's focus endpoint. + Focus, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum SelectionGranularity { #[default] @@ -148,6 +157,7 @@ pub(crate) struct DocumentSelectionState { struct DocumentSelectionDrag { granularity: SelectionGranularity, origin: DocumentTextSelection, + endpoint: Option, } impl Element { @@ -275,6 +285,7 @@ impl UiTree { Some(DocumentSelectionDrag { granularity, origin: selection, + endpoint: None, }), ) } @@ -287,6 +298,16 @@ impl UiTree { return InteractionUpdate::default(); }; let point = self.clamp_to_selection_scope(point); + if let Some(endpoint) = drag.endpoint { + let Some(mut selection) = self.document_selection.selection else { + return InteractionUpdate::default(); + }; + match endpoint { + DocumentSelectionEndpoint::Anchor => selection.anchor = point, + DocumentSelectionEndpoint::Focus => selection.focus = point, + } + return self.set_document_selection(selection, Some(drag)); + } let selection = if drag.granularity == SelectionGranularity::Character { DocumentTextSelection { anchor: drag.origin.anchor, @@ -373,178 +394,6 @@ impl UiTree { ) } - /// Reports supported text-selection commands for a node or document. - /// - /// * `target` — node whose text-input capabilities are queried. - #[must_use] - pub fn selection_capabilities(&self, target: NodeId) -> SelectionCapabilities { - if let Some(input) = self.text_inputs.get(target) { - let selected = input.has_selection() && !input.protected(); - return SelectionCapabilities { - editable: true, - cut: selected && !input.read_only(), - copy: selected, - paste: !input.read_only(), - select_all: !input.value().is_empty(), - }; - } - self.document_selection_capabilities() - } - - /// Reports capabilities for the current document-wide selection. - #[must_use] - pub fn document_selection_capabilities(&self) -> SelectionCapabilities { - SelectionCapabilities { - copy: self.has_document_selection(), - select_all: !self.selectable_text_entries().is_empty(), - ..SelectionCapabilities::default() - } - } - - /// Applies a selection command to the currently focused target. - /// - /// * `command` — copy, paste, cut, undo, redo, or select-all operation. - /// - /// Returns the interaction changes caused by the command. - pub fn focused_selection_command(&mut self, command: SelectionCommand) -> InteractionUpdate { - let target = self.interaction.focused(); - self.selection_command(target, command) - } - - /// Applies a selection command to a specific input or the document selection. - /// - /// * `target` — optional text-input node; `None` targets document text. - /// * `command` — copy, paste, cut, undo, redo, or select-all operation. - /// - /// Returns the interaction changes caused by the command. - pub fn selection_command( - &mut self, - target: Option, - command: SelectionCommand, - ) -> InteractionUpdate { - if target - .is_some_and(|node| self.text_inputs.get(node).is_some() && !self.input_available(node)) - { - return InteractionUpdate::default(); - } - if let Some(node) = target - && let Some(state) = self.text_inputs.get_mut(node) - { - let mut result = state.selection_command(command); - if matches!(result.clipboard, Some(crate::ClipboardRequest::Read { .. })) { - result.clipboard = Some(crate::ClipboardRequest::Read { target: Some(node) }); - } - return self.text_input_update(node, result); - } - match command { - SelectionCommand::Copy => { - self.selected_document_text() - .map_or_else(InteractionUpdate::default, |text| InteractionUpdate { - clipboard: Some(crate::ClipboardRequest::Write(text)), - ..InteractionUpdate::default() - }) - } - SelectionCommand::SelectAll => self.select_all_document_text(), - SelectionCommand::Cut - | SelectionCommand::Paste - | SelectionCommand::Undo - | SelectionCommand::Redo => InteractionUpdate::default(), - } - } - - /// Returns the selected document text, joined by newlines, when non-empty. - #[must_use] - pub fn selected_document_text(&self) -> Option { - self.document_selection.selection?; - let mut selected = Vec::new(); - for entry in self.selectable_text_entries() { - let Some(range) = self.document_selection_range(entry.node, entry.text.len()) else { - continue; - }; - if range.start < range.end { - selected.push(entry.text[range].to_owned()); - } - } - (!selected.is_empty()).then(|| selected.join("\n")) - } - - /// Returns the selected byte range within `node`, bounded by `text_len`. - /// - /// * `node` — node whose text range is requested. - /// * `text_len` — byte length of that node's text. - #[must_use] - pub fn document_selection_range(&self, node: NodeId, text_len: usize) -> Option> { - let selection = self.document_selection.selection?; - let anchor = self.document_order(selection.anchor)?; - let focus = self.document_order(selection.focus)?; - let (start, end) = if anchor <= focus { - (selection.anchor, selection.focus) - } else { - (selection.focus, selection.anchor) - }; - let index = self.node_index(node)?; - let start_index = self.node_index(start.node)?; - let end_index = self.node_index(end.node)?; - if index < start_index || index > end_index { - return None; - } - let range = if start.node == end.node { - start.position.index.min(text_len)..end.position.index.min(text_len) - } else if node == start.node { - start.position.index.min(text_len)..text_len - } else if node == end.node { - 0..end.position.index.min(text_len) - } else { - 0..text_len - }; - Some(range) - } - - /// Returns whether `point` lies within the document selection, inclusively. - /// - /// * `point` — document position to test. - #[must_use] - pub fn document_selection_contains(&self, point: DocumentTextPoint) -> bool { - let Some(selection) = self.document_selection.selection else { - return false; - }; - let Some(anchor) = self.document_order(selection.anchor) else { - return false; - }; - let Some(focus) = self.document_order(selection.focus) else { - return false; - }; - let Some(candidate) = self.document_order(point) else { - return false; - }; - let (start, end) = if anchor <= focus { - (anchor, focus) - } else { - (focus, anchor) - }; - start <= candidate && candidate <= end - } - - /// Returns whether the selection intersects a node-index interval. - /// - /// * `first` — first node index in the interval. - /// * `count` — number of node indices in the interval. - #[must_use] - pub fn document_selection_intersects(&self, first: usize, count: usize) -> bool { - let Some(selection) = self.document_selection.selection else { - return false; - }; - let Some(anchor) = self.node_index(selection.anchor.node) else { - return false; - }; - let Some(focus) = self.node_index(selection.focus.node) else { - return false; - }; - let start = anchor.min(focus); - let end = anchor.max(focus); - start < first.saturating_add(count) && end >= first - } - fn set_document_selection( &mut self, selection: DocumentTextSelection, diff --git a/crates/argui-ui/src/text_selection/document.rs b/crates/argui-ui/src/text_selection/document.rs new file mode 100644 index 00000000..2b47b133 --- /dev/null +++ b/crates/argui-ui/src/text_selection/document.rs @@ -0,0 +1,178 @@ +use std::ops::Range; + +use super::{SelectionCapabilities, SelectionCommand}; +use crate::{InteractionUpdate, NodeId, UiTree}; + +impl UiTree { + /// Reports supported text-selection commands for a node or document. + /// + /// * `target` — node whose text-input capabilities are queried. + #[must_use] + pub fn selection_capabilities(&self, target: NodeId) -> SelectionCapabilities { + if let Some(input) = self.text_inputs.get(target) { + let selected = input.has_selection() && !input.protected(); + return SelectionCapabilities { + editable: true, + cut: selected && !input.read_only(), + copy: selected, + paste: !input.read_only(), + select_all: !input.value().is_empty(), + }; + } + self.document_selection_capabilities() + } + + /// Reports capabilities for the current document-wide selection. + #[must_use] + pub fn document_selection_capabilities(&self) -> SelectionCapabilities { + SelectionCapabilities { + copy: self.has_document_selection(), + select_all: !self.selectable_text_entries().is_empty(), + ..SelectionCapabilities::default() + } + } + + /// Applies a selection command to the currently focused target. + /// + /// * `command` — copy, paste, cut, undo, redo, or select-all operation. + /// + /// Returns the interaction changes caused by the command. + pub fn focused_selection_command(&mut self, command: SelectionCommand) -> InteractionUpdate { + let target = self.interaction.focused(); + self.selection_command(target, command) + } + + /// Applies a selection command to a specific input or document selection. + /// + /// * `target` — optional text-input node; `None` targets document text. + /// * `command` — copy, paste, cut, undo, redo, or select-all operation. + /// + /// Returns the interaction changes caused by the command. + pub fn selection_command( + &mut self, + target: Option, + command: SelectionCommand, + ) -> InteractionUpdate { + if target + .is_some_and(|node| self.text_inputs.get(node).is_some() && !self.input_available(node)) + { + return InteractionUpdate::default(); + } + if let Some(node) = target + && let Some(state) = self.text_inputs.get_mut(node) + { + let mut result = state.selection_command(command); + if matches!(result.clipboard, Some(crate::ClipboardRequest::Read { .. })) { + result.clipboard = Some(crate::ClipboardRequest::Read { target: Some(node) }); + } + return self.text_input_update(node, result); + } + match command { + SelectionCommand::Copy => { + self.selected_document_text() + .map_or_else(InteractionUpdate::default, |text| InteractionUpdate { + clipboard: Some(crate::ClipboardRequest::Write(text)), + ..InteractionUpdate::default() + }) + } + SelectionCommand::SelectAll => self.select_all_document_text(), + SelectionCommand::Cut + | SelectionCommand::Paste + | SelectionCommand::Undo + | SelectionCommand::Redo => InteractionUpdate::default(), + } + } + + /// Returns the selected document text, joined by newlines, when non-empty. + #[must_use] + pub fn selected_document_text(&self) -> Option { + self.document_selection.selection?; + let mut selected = Vec::new(); + for entry in self.selectable_text_entries() { + let Some(range) = self.document_selection_range(entry.node, entry.text.len()) else { + continue; + }; + if range.start < range.end { + selected.push(entry.text[range].to_owned()); + } + } + (!selected.is_empty()).then(|| selected.join("\n")) + } + + /// Returns the selected byte range within `node`, bounded by `text_len`. + /// + /// * `node` — node whose text range is requested. + /// * `text_len` — byte length of that node's text. + #[must_use] + pub fn document_selection_range(&self, node: NodeId, text_len: usize) -> Option> { + let selection = self.document_selection.selection?; + let anchor = self.document_order(selection.anchor)?; + let focus = self.document_order(selection.focus)?; + let (start, end) = if anchor <= focus { + (selection.anchor, selection.focus) + } else { + (selection.focus, selection.anchor) + }; + let index = self.node_index(node)?; + let start_index = self.node_index(start.node)?; + let end_index = self.node_index(end.node)?; + if index < start_index || index > end_index { + return None; + } + let range = if start.node == end.node { + start.position.index.min(text_len)..end.position.index.min(text_len) + } else if node == start.node { + start.position.index.min(text_len)..text_len + } else if node == end.node { + 0..end.position.index.min(text_len) + } else { + 0..text_len + }; + Some(range) + } + + /// Returns whether `point` lies within the document selection, inclusively. + /// + /// * `point` — document position to test. + #[must_use] + pub fn document_selection_contains(&self, point: super::DocumentTextPoint) -> bool { + let Some(selection) = self.document_selection.selection else { + return false; + }; + let Some(anchor) = self.document_order(selection.anchor) else { + return false; + }; + let Some(focus) = self.document_order(selection.focus) else { + return false; + }; + let Some(candidate) = self.document_order(point) else { + return false; + }; + let (start, end) = if anchor <= focus { + (anchor, focus) + } else { + (focus, anchor) + }; + start <= candidate && candidate <= end + } + + /// Returns whether the selection intersects a node-index interval. + /// + /// * `first` — first node index in the interval. + /// * `count` — number of node indices in the interval. + #[must_use] + pub fn document_selection_intersects(&self, first: usize, count: usize) -> bool { + let Some(selection) = self.document_selection.selection else { + return false; + }; + let Some(anchor) = self.node_index(selection.anchor.node) else { + return false; + }; + let Some(focus) = self.node_index(selection.focus.node) else { + return false; + }; + let start = anchor.min(focus); + let end = anchor.max(focus); + start < first.saturating_add(count) && end >= first + } +} diff --git a/crates/argui-ui/src/text_selection/handles.rs b/crates/argui-ui/src/text_selection/handles.rs new file mode 100644 index 00000000..c669e506 --- /dev/null +++ b/crates/argui-ui/src/text_selection/handles.rs @@ -0,0 +1,32 @@ +use super::{DocumentSelectionDrag, DocumentSelectionEndpoint, SelectionGranularity}; +use crate::{InteractionUpdate, UiTree}; + +impl UiTree { + /// Begins dragging one visible touch-selection endpoint. + /// + /// * `endpoint` — anchor or focus endpoint whose position will follow later drag updates. + /// + /// Returns the interaction changes caused by beginning the drag. Returns an empty update when + /// touch handles are not visible or the selection is empty. + pub fn begin_document_selection_handle_drag( + &mut self, + endpoint: DocumentSelectionEndpoint, + ) -> InteractionUpdate { + if !self.document_selection.touch_handles || !self.has_document_selection() { + return InteractionUpdate::default(); + } + let Some(selection) = self.document_selection.selection else { + return InteractionUpdate::default(); + }; + let drag = DocumentSelectionDrag { + granularity: SelectionGranularity::Character, + origin: selection, + endpoint: Some(endpoint), + }; + if self.document_selection.drag == Some(drag) { + return InteractionUpdate::default(); + } + self.document_selection.drag = Some(drag); + self.document_selection_update(false) + } +} diff --git a/crates/argui-ui/src/tree.rs b/crates/argui-ui/src/tree.rs index 43b837bc..a3127c0c 100644 --- a/crates/argui-ui/src/tree.rs +++ b/crates/argui-ui/src/tree.rs @@ -68,6 +68,7 @@ pub struct UiTree { events: EventRegistry, pending_gestures: Vec, native_portals: std::collections::HashMap, + interaction_bounds: std::collections::HashMap, } impl UiTree { @@ -106,6 +107,7 @@ impl UiTree { events, pending_gestures: Vec::new(), native_portals: Default::default(), + interaction_bounds: Default::default(), }; tree.sync_text_inputs(); tree.sync_responsive_registry(); @@ -209,6 +211,8 @@ impl UiTree { self.root = root; self.sync_animation_registry(structure_changed); self.interaction.retain(&self.node_ids); + self.interaction_bounds + .retain(|node, _| self.node_ids.contains(node)); self.pending_gestures .retain(|gesture| self.node_ids.contains(&gesture.target)); self.scroll.retain(&self.node_ids); diff --git a/crates/argui-ui/src/tree/action.rs b/crates/argui-ui/src/tree/action.rs index 963f1cf8..32ba3e32 100644 --- a/crates/argui-ui/src/tree/action.rs +++ b/crates/argui-ui/src/tree/action.rs @@ -116,14 +116,20 @@ impl UiTree { ); base.set_focused_node(invocation.origin.or(self.focused_node())); return InteractionUpdate { - events: vec![base.delivery( - node, - self.key_for(node).map(str::to_owned), - Some(binding.listener.handler), - EventPhase::Target, - false, - None, - )], + events: vec![base.delivery(crate::event::EventDelivery { + current_target: node, + current_key: self.key_for(node).map(str::to_owned), + current_handler: Some(binding.listener.handler), + phase: EventPhase::Target, + passive: false, + once: None, + handler_value: binding.listener.value.as_ref().and_then(|source| { + source.resolve( + &base.kind, + self.interaction_bounds.get(&base.target).copied(), + ) + }), + })], ..Default::default() }; } diff --git a/crates/argui-ui/src/tree/event.rs b/crates/argui-ui/src/tree/event.rs index 2827b211..3f1ac649 100644 --- a/crates/argui-ui/src/tree/event.rs +++ b/crates/argui-ui/src/tree/event.rs @@ -27,7 +27,7 @@ impl EventRegistry { }); } - fn once_state(&mut self, node: NodeId, listener: EventListener) -> Option>> { + fn once_state(&mut self, node: NodeId, listener: &EventListener) -> Option>> { if !listener.options.once { return None; } @@ -141,42 +141,42 @@ impl UiTree { .element_at(index) .map(|element| element.event_listeners.clone()) .unwrap_or_default(); - for listener in listeners - .into_iter() - .filter(|listener| listener.event == event_type && listener.options.capture == capture) - { - let once = self.events.once_state(node, listener); + for listener in listeners.into_iter().filter(|listener| { + listener.event == event_type + && listener.options.capture == capture + && (!listener.options.target_only || base.target == node) + && listener + .target_key + .as_deref() + .is_none_or(|key| base.target_key() == Some(key)) + && listener.options.filter.accepts(&base.kind) + }) { + let value = match &listener.value { + Some(source) => { + let Some(value) = source.resolve( + &base.kind, + self.interaction_bounds.get(&base.target).copied(), + ) else { + continue; + }; + Some(value) + } + None => None, + }; + let once = self.events.once_state(node, &listener); if once.as_ref().is_some_and(|consumed| consumed.get()) { continue; } - output.push(self.delivery( - base, - index, + let element = self.element_at(index); + output.push(base.delivery(crate::event::EventDelivery { + current_target: self.node_ids[index], + current_key: element.and_then(|value| value.key.clone()), + current_handler: Some(listener.handler), phase, - listener.options.passive, + passive: listener.options.passive, once, - Some(listener.handler), - )); + handler_value: value, + })); } } - - fn delivery( - &self, - base: &UiEvent, - index: usize, - phase: EventPhase, - passive: bool, - once: Option>>, - handler: Option, - ) -> UiEvent { - let element = self.element_at(index); - base.delivery( - self.node_ids[index], - element.and_then(|value| value.key.clone()), - handler, - phase, - passive, - once, - ) - } } diff --git a/crates/argui-ui/src/tree/focus.rs b/crates/argui-ui/src/tree/focus.rs index b5b103e7..1a944783 100644 --- a/crates/argui-ui/src/tree/focus.rs +++ b/crates/argui-ui/src/tree/focus.rs @@ -174,12 +174,10 @@ impl UiTree { /// /// * `regions` — current hit regions used to resolve focus behavior. pub fn primary_pressed(&mut self, regions: &[HitRegion]) -> InteractionUpdate { + let position = self.interaction.mouse_position().unwrap_or_default(); let event = argui_core::PointerEvent { phase: argui_core::PointerPhase::Pressed, - ..argui_core::PointerEvent::mouse( - argui_core::PointerPhase::Pressed, - argui_core::Point::default(), - ) + ..argui_core::PointerEvent::mouse(argui_core::PointerPhase::Pressed, position) }; let mut update = self.primary_pressed_for(event, regions); update.merge(self.focus_pointer_default(event.id, regions)); diff --git a/crates/argui-ui/src/tree/pointer.rs b/crates/argui-ui/src/tree/pointer.rs index 030b7840..0fd3d7f0 100644 --- a/crates/argui-ui/src/tree/pointer.rs +++ b/crates/argui-ui/src/tree/pointer.rs @@ -46,6 +46,9 @@ impl UiTree { event: PointerEvent, regions: &[HitRegion], ) -> InteractionUpdate { + self.interaction_bounds.clear(); + self.interaction_bounds + .extend(regions.iter().map(|region| (region.node, region.bounds))); let region = regions .iter() .rev() @@ -176,10 +179,11 @@ impl UiTree { /// Processes release of the primary mouse button. /// Returns the resulting interaction update. pub fn primary_released(&mut self) -> InteractionUpdate { + let position = self.interaction.mouse_position().unwrap_or_default(); let update = self.interaction.primary_released(PointerEvent { button: Some(argui_core::PointerButton::Primary), phase: PointerPhase::Released, - ..PointerEvent::mouse(PointerPhase::Released, Point::default()) + ..PointerEvent::mouse(PointerPhase::Released, position) }); self.decorate(update) } diff --git a/crates/argui-ui/tests/event.rs b/crates/argui-ui/tests/event.rs index 87e44ce6..4ed499c6 100644 --- a/crates/argui-ui/tests/event.rs +++ b/crates/argui-ui/tests/event.rs @@ -360,6 +360,36 @@ fn multiple_listeners_keep_distinct_handler_identities() { ); } +#[test] +fn target_only_listeners_ignore_descendant_bubbling_without_affecting_other_listeners() { + let options = EventListenerOptions::default(); + let root = Element::container([Element::text("child").keyed("child")]) + .keyed("root") + .listen(EventType::Click, options.target_only(true)) + .listen(EventType::Click, options); + let mut tree = UiTree::new(root); + let child = tree.node_id_at(1).unwrap(); + let descendant = tree.event_deliveries( + child, + UiEventKind::Click(argui_ui::ClickEvent::accessibility()), + ); + assert_eq!(descendant.len(), 1); + assert_eq!(descendant[0].current_key(), Some("root")); + assert_eq!(descendant[0].phase(), EventPhase::Bubble); + + let root = tree.node_id_at(0).unwrap(); + let direct = tree.event_deliveries( + root, + UiEventKind::Click(argui_ui::ClickEvent::accessibility()), + ); + assert_eq!(direct.len(), 2); + assert!( + direct + .iter() + .all(|event| event.phase() == EventPhase::Target) + ); +} + #[test] fn top_light_dismiss_portal_receives_pointer_outside() { let mut tree = UiTree::new(Element::container([ diff --git a/crates/argui-ui/tests/interaction/pointer.rs b/crates/argui-ui/tests/interaction/pointer.rs index 4fb0dabd..2cb4ad49 100644 --- a/crates/argui-ui/tests/interaction/pointer.rs +++ b/crates/argui-ui/tests/interaction/pointer.rs @@ -92,6 +92,163 @@ fn captured_cursor_ignores_missing_or_disabled_regions() { assert_eq!(tree.captured_cursor(PointerId::MOUSE, &[region]), None); } +#[test] +fn movement_at_the_activation_slop_boundary_preserves_mouse_and_touch_taps() { + for (kind, pointer) in [ + (PointerKind::Mouse, PointerId::MOUSE), + (PointerKind::Touch, PointerId::new(41)), + ] { + let mut tree = UiTree::new(interactive("tap")); + tree.set_pointer_settings(argui_core::PointerSettings::default().activation_slop(10.0)); + let region = region(tree.node_id_at(0).unwrap()); + let regions = [region]; + let origin = Point::new(30.0, 20.0); + let edge = Point::new(30.0, 30.0); + + tree.pointer_event( + pointer_event(pointer, kind, PointerPhase::Pressed, origin), + ®ions, + ); + tree.pointer_event( + pointer_event(pointer, kind, PointerPhase::Moved, edge), + ®ions, + ); + let released = tree.pointer_event( + pointer_event(pointer, kind, PointerPhase::Released, edge), + ®ions, + ); + + assert!( + released + .events + .iter() + .any(|event| matches!(event.kind, UiEventKind::Click(_))), + "movement equal to activation slop should still click for {kind:?}" + ); + } +} + +#[test] +fn convenience_mouse_press_tracks_the_last_hover_position_for_drag_slop() { + let mut tree = UiTree::new(interactive("near-tap")); + let node = tree.node_id_at(0).unwrap(); + let regions = [region(node)]; + tree.pointer_moved(Point::new(30.0, 20.0), ®ions); + tree.primary_pressed(®ions); + tree.pointer_moved(Point::new(39.0, 20.0), ®ions); + + let released = tree.primary_released(); + + assert!( + released + .events + .iter() + .any(|event| matches!(event.kind, UiEventKind::Click(_))) + ); +} + +#[test] +fn dragging_beyond_activation_slop_cancels_mouse_touch_and_pen_clicks() { + for (kind, pointer) in [ + (PointerKind::Mouse, PointerId::MOUSE), + (PointerKind::Touch, PointerId::new(42)), + (PointerKind::Pen, PointerId::new(43)), + ] { + let mut tree = UiTree::new(interactive("drag")); + let node = tree.node_id_at(0).unwrap(); + let regions = [region(node)]; + let origin = Point::new(30.0, 20.0); + let dragged = Point::new(30.0, 31.0); + + tree.pointer_event( + pointer_event(pointer, kind, PointerPhase::Pressed, origin), + ®ions, + ); + let moved = tree.pointer_event( + pointer_event(pointer, kind, PointerPhase::Moved, dragged), + ®ions, + ); + assert!(!tree.visual_states(node).contains(VisualState::Pressed)); + assert!(moved.paint_changed); + let released = tree.pointer_event( + pointer_event(pointer, kind, PointerPhase::Released, dragged), + ®ions, + ); + + assert!( + released + .events + .iter() + .all(|event| !matches!(event.kind, UiEventKind::Click(_))), + "movement beyond activation slop must not click for {kind:?}" + ); + assert!(!tree.visual_states(node).contains(VisualState::Pressed)); + } +} + +#[test] +fn a_drag_suppresses_activation_without_stealing_gesture_pointer_capture() { + let mut tree = UiTree::new(interactive("pan")); + let node = tree.node_id_at(0).unwrap(); + let pointer = PointerId::new(44); + let mut hit = region(node); + hit.cursor = CursorIcon::Grab; + hit.gestures = argui_ui::GestureSet::EMPTY + .pan(argui_ui::PanGesture::default().capture(argui_ui::GestureCapture::OnPress)); + let regions = [hit]; + let origin = Point::new(30.0, 20.0); + let dragged = Point::new(30.0, 31.0); + + tree.pointer_event( + pointer_event(pointer, PointerKind::Touch, PointerPhase::Pressed, origin), + ®ions, + ); + tree.pointer_event( + pointer_event(pointer, PointerKind::Touch, PointerPhase::Moved, dragged), + ®ions, + ); + assert!(tree.visual_states(node).contains(VisualState::Pressed)); + assert_eq!( + tree.captured_cursor(pointer, ®ions), + Some(CursorIcon::Grab) + ); + let released = tree.pointer_event( + pointer_event(pointer, PointerKind::Touch, PointerPhase::Released, dragged), + ®ions, + ); + + assert!( + released + .events + .iter() + .any(|event| { event.kind == UiEventKind::LostPointerCapture(pointer) }) + ); + assert!( + released + .events + .iter() + .all(|event| !matches!(event.kind, UiEventKind::Click(_))) + ); + assert_eq!(tree.captured_cursor(pointer, ®ions), None); +} + +fn pointer_event( + id: PointerId, + kind: PointerKind, + phase: PointerPhase, + position: Point, +) -> PointerEvent { + PointerEvent { + id, + kind, + phase, + position, + button: Some(PointerButton::Primary), + buttons: u16::from(matches!(phase, PointerPhase::Pressed | PointerPhase::Moved)), + ..PointerEvent::mouse(phase, position) + } +} + #[test] fn replacing_capture_releases_the_previous_target_and_same_capture_is_idempotent() { let mut tree = UiTree::new(Element::row([interactive("first"), interactive("second")])); diff --git a/crates/argui-ui/tests/scroll.rs b/crates/argui-ui/tests/scroll.rs index 204ab09c..04ffdfc5 100644 --- a/crates/argui-ui/tests/scroll.rs +++ b/crates/argui-ui/tests/scroll.rs @@ -155,6 +155,7 @@ fn both_axes_preserve_pixel_precision() { ); assert_eq!(tree.scroll_offset(node), Point::new(3.25, 7.5)); } + #[test] fn exhausted_nested_scrolls_chain_to_their_parent() { let mut tree = UiTree::new(Element::column([ diff --git a/crates/argui-ui/tests/scroll_config.rs b/crates/argui-ui/tests/scroll_config.rs new file mode 100644 index 00000000..53d5bde6 --- /dev/null +++ b/crates/argui-ui/tests/scroll_config.rs @@ -0,0 +1,11 @@ +use argui_ui::ScrollConfig; + +#[test] +fn touch_scroll_direction_is_natural_by_default_and_configurable() { + assert!(ScrollConfig::default().natural_touch_scroll); + assert!( + !ScrollConfig::default() + .natural_touch_scroll(false) + .natural_touch_scroll + ); +} diff --git a/crates/argui-ui/tests/text_selection.rs b/crates/argui-ui/tests/text_selection.rs index 9a6d439d..a2e75cd0 100644 --- a/crates/argui-ui/tests/text_selection.rs +++ b/crates/argui-ui/tests/text_selection.rs @@ -1,9 +1,9 @@ use argui_core::{CaretAffinity, TextPosition}; use argui_ui::{ - ClipboardRequest, DocumentTextPoint, Element, EventHandlerId, EventListener, EventOwnerId, - EventType, GestureSet, Interaction, Role, SelectionCommand, SelectionGranularity, Semantics, - TapGesture, TextEditorSpec, TextInputFilter, TextSelectionStyle, UiEventKind, UiTree, - UserSelect, + ClipboardRequest, DocumentSelectionEndpoint, DocumentTextPoint, Element, EventHandlerId, + EventListener, EventOwnerId, EventType, GestureSet, Interaction, Role, SelectionCommand, + SelectionGranularity, Semantics, TapGesture, TextEditorSpec, TextInputFilter, + TextSelectionStyle, UiEventKind, UiTree, UserSelect, }; fn listens_for_selection(element: Element) -> Element { @@ -231,6 +231,48 @@ fn touch_selection_exposes_handles_and_empty_documents_stay_empty() { assert!(empty.selected_document_text().is_none()); } +#[test] +fn touch_selection_handles_drag_both_endpoints_through_crossing_and_release() { + let mut tree = UiTree::new(Element::text("alpha beta gamma")); + let text = tree.node_id_at(0).unwrap(); + let point = + |index| DocumentTextPoint::new(text, TextPosition::new(index, CaretAffinity::After)); + + tree.begin_touch_document_selection(point(7), SelectionGranularity::Word); + tree.release_document_selection(); + assert_eq!(tree.selected_document_text().as_deref(), Some("beta")); + + tree.begin_document_selection_handle_drag(DocumentSelectionEndpoint::Anchor); + assert!(tree.document_selection_dragging()); + tree.drag_document_selection(point(13)); + let crossed_anchor = tree.document_selection().unwrap(); + assert_eq!(crossed_anchor.anchor.position.index, 13); + assert_eq!(crossed_anchor.focus.position.index, 10); + assert_eq!(tree.selected_document_text().as_deref(), Some(" ga")); + + tree.drag_document_selection(point(3)); + assert_eq!(tree.selected_document_text().as_deref(), Some("ha beta")); + tree.release_document_selection(); + + tree.begin_document_selection_handle_drag(DocumentSelectionEndpoint::Focus); + tree.drag_document_selection(point(14)); + assert_eq!( + tree.selected_document_text().as_deref(), + Some("ha beta gam") + ); + tree.drag_document_selection(point(1)); + let crossed_focus = tree.document_selection().unwrap(); + assert_eq!(crossed_focus.anchor.position.index, 3); + assert_eq!(crossed_focus.focus.position.index, 1); + assert_eq!(tree.selected_document_text().as_deref(), Some("lp")); + + tree.release_document_selection(); + assert!(!tree.document_selection_dragging()); + assert!(tree.document_selection_handles_visible()); + assert!(tree.drag_document_selection(point(8)).events.is_empty()); + assert_eq!(tree.selected_document_text().as_deref(), Some("lp")); +} + #[test] fn auto_follows_the_cascade_instead_of_inferring_style_from_interaction() { let mut tree = UiTree::new(Element::column([ diff --git a/crates/argui-widget-gallery/src/app.rs b/crates/argui-widget-gallery/src/app.rs index a5ee74dc..7a73e06b 100644 --- a/crates/argui-widget-gallery/src/app.rs +++ b/crates/argui-widget-gallery/src/app.rs @@ -6,9 +6,7 @@ use argui::{ theme::ThemeMode, ui::{Element, ScrollRequest, UiEvent, UiEventKind}, widgets::{ - DialogAction, DialogBehavior, RadioGroupAction, RadioGroupBehavior, RangeBehavior, - RangeConfig, RangeState, SelectAction, SelectBehavior, SelectOption, Spinner, TablerIcon, - TabsAction, TabsBehavior, WidgetAssets, + RangeState, SelectAction, SelectBehavior, SelectOption, Spinner, TablerIcon, WidgetAssets, }, }; use argui_image::ImageLibrary; @@ -82,6 +80,8 @@ pub struct WidgetGallery { #[cfg(feature = "updater")] pub(crate) updater: std::cell::OnceCell>, pub(crate) tasks: Entity, + #[cfg(any(target_os = "android", target_os = "ios"))] + pub(crate) mobile_activity: Entity, pub(crate) motion: Entity, pub(crate) text_selection: Entity, pub(crate) editing: Entity, @@ -89,7 +89,6 @@ pub struct WidgetGallery { pub(crate) i18n: Entity, pub(crate) timeline: Entity, pub(crate) slider_state: RangeState, - pub(crate) plain_slider_state: RangeState, images: ImageLibrary, pub(crate) logo: ImageId, light_assets: WidgetAssets, @@ -143,6 +142,8 @@ impl Default for WidgetGallery { #[cfg(feature = "updater")] updater: std::cell::OnceCell::new(), tasks: Entity::new(pages::async_tasks::TasksDemo::default()), + #[cfg(any(target_os = "android", target_os = "ios"))] + mobile_activity: Entity::new(pages::mobile_activity::MobileActivityDemo::default()), motion: Entity::new(pages::motion::MotionDemo::default()), text_selection: Entity::new(pages::text_selection::SelectionDemo::default()), editing: Entity::new(pages::editing::EditingDemo::default()), @@ -186,7 +187,6 @@ impl Default for WidgetGallery { editor_size: EDITOR_DEFAULT_SIZE, editor_resize_start: EDITOR_DEFAULT_SIZE, slider_state: RangeState::default(), - plain_slider_state: RangeState::default(), images, logo, light_assets, @@ -271,119 +271,15 @@ impl WidgetGallery { if crate::property_slider::update(self, event, cx) { return; } - let plain_slider = RangeBehavior::new( - "plain-slider", - "Plain slider", - self.plain_slider, - RangeConfig::default(), - ); - if let Some(action) = self.plain_slider_state.update(event, &plain_slider) { - self.plain_slider = action.value(); - cx.notify(); - return; - } - if let UiEventKind::TextChanged(value) = &event.kind { - match event.target_key() { - Some("gallery-search") => { - self.search.clone_from(value); - self.search_highlight = 0; - } - Some("name") => self.name.clone_from(value), - Some("workspace-id") => self.workspace_id.clone_from(value), - Some("email") => self.email.clone_from(value), - Some("input-search-demo") => self.input_search.clone_from(value), - Some("invalid") => self.invalid_email.clone_from(value), - Some("notes") => self.notes.clone_from(value), - _ => return, - } - cx.notify(); - return; - } - if let Some(page) = event - .target_key() - .and_then(Page::from_navigation_key) - .filter(|_| matches!(event.kind, UiEventKind::Click(_))) + if matches!(event.kind, UiEventKind::Click(_)) + && let Some(index) = event + .target_key() + .and_then(|key| key.strip_prefix("primary::")) + .and_then(|value| value.parse::().ok()) + .filter(|index| *index < PRIMARIES.len()) { - self.select_page(page); - cx.notify(); - return; - } - if matches!(event.kind, UiEventKind::Click(_)) { - match event.target_key() { - Some("theme-mode") => { - self.cycle_theme(); - cx.set_theme(self.theme_request()); - return; - } - Some( - "demo-button" | "secondary" | "outline" | "ghost" | "danger" - | "button-elevated" | "button-lift" | "button-shader", - ) => { - self.clicks = self.clicks.saturating_add(1); - cx.notify(); - return; - } - Some("accepted") => { - self.accepted = !self.accepted; - cx.notify(); - return; - } - Some("check-empty" | "switch-off") => { - let value = if event.target_key() == Some("check-empty") { - &mut self.email_updates - } else { - &mut self.offline_rendering - }; - *value = !*value; - cx.notify(); - return; - } - Some("notifications") => { - self.notifications = !self.notifications; - cx.notify(); - return; - } - _ => { - if let Some(index) = event - .target_key() - .and_then(|key| key.strip_prefix("primary::")) - .and_then(|value| value.parse::().ok()) - .filter(|index| *index < PRIMARIES.len()) - { - self.primary = index; - cx.set_theme(self.theme_request()); - return; - } - } - } - } - let radio = RadioGroupBehavior::new( - "quality", - "Quality", - [ - ("Maximum quality".into(), true), - ("Balanced".into(), true), - ("Performance".into(), true), - ], - Some(self.radio), - ); - if let Some(RadioGroupAction::Select(selection)) = radio.action(event) { - self.radio = selection; - cx.notify(); - return; - } - let tabs = TabsBehavior::new( - "demo-tabs", - [ - ("General".into(), true), - ("Performance".into(), true), - ("Advanced".into(), true), - ], - self.tab, - ); - if let Some(TabsAction::Select(selection)) = tabs.action(event) { - self.tab = selection; - cx.notify(); + self.primary = index; + cx.set_theme(self.theme_request()); return; } let options = Self::select_options(); @@ -416,26 +312,12 @@ impl WidgetGallery { } } cx.notify(); - return; - } - if let Some(action) = - DialogBehavior::new("demo-dialog", "Delete GPU cache", self.dialog_open).action(event) - { - self.dialog_open = action == DialogAction::Open; - cx.notify(); } } fn handle_layout(&mut self, layout: &LayoutSnapshot) { self.backdrop.layout_changed(layout); crate::property_slider::layout_changed(self, layout); - let behavior = RangeBehavior::new( - "plain-slider", - "Plain slider", - self.plain_slider, - RangeConfig::default(), - ); - self.plain_slider_state.layout_changed(layout, &behavior); } } diff --git a/crates/argui-widget-gallery/src/app/interaction.rs b/crates/argui-widget-gallery/src/app/interaction.rs index 5342123d..15c83820 100644 --- a/crates/argui-widget-gallery/src/app/interaction.rs +++ b/crates/argui-widget-gallery/src/app/interaction.rs @@ -13,6 +13,8 @@ impl WidgetGallery { pub(super) fn render_element(&mut self, cx: &mut Context) -> Element { let environment = cx.environment(); let safe_area_insets = environment.safe_area_insets; + #[cfg(target_os = "android")] + argui::platform::mobile::set_android_system_bar_color_scheme(environment.color_scheme); let themes = shadcn(&environment); let theme = themes.resolve(environment.color_scheme); let assets = match environment.color_scheme { @@ -35,17 +37,23 @@ impl WidgetGallery { cx.notify(); } }); - self.view( - environment, - theme, - assets, - cx, - ResizeListeners { - textarea: resize_listener, - textarea_reset: resize_reset_listener, - }, - ) - .safe_area(safe_area_insets) + let root = self + .view( + environment, + theme, + assets, + cx, + ResizeListeners { + textarea: resize_listener, + textarea_reset: resize_reset_listener, + }, + ) + .safe_area(safe_area_insets); + if safe_area_insets == argui::core::Insets::ZERO { + root + } else { + root.background(theme.background) + } } fn resize_editor(&mut self, gesture: argui::ui::GestureEvent, cx: &mut Context) { diff --git a/crates/argui-widget-gallery/src/app/layout.rs b/crates/argui-widget-gallery/src/app/layout.rs index 18a69c80..dbc0ade3 100644 --- a/crates/argui-widget-gallery/src/app/layout.rs +++ b/crates/argui-widget-gallery/src/app/layout.rs @@ -24,16 +24,16 @@ impl WidgetGallery { ) -> Element { let content = self.content(theme, assets, cx, resize); let body = if self.compact { - Element::column([self.mobile_navigation(theme, assets), content]) + Element::column([self.mobile_navigation(theme, assets, cx), content]) .grow(1.0) .min_height(length(0.0)) } else { - Element::row([self.sidebar(theme, assets), content]) + Element::row([self.sidebar(theme, assets, cx), content]) .grow(1.0) .min_height(length(0.0)) }; Element::column([ - self.topbar(theme, assets, environment), + self.topbar(theme, assets, environment, cx), body, cx.entity(&self.toasts), ]) @@ -98,6 +98,7 @@ impl WidgetGallery { theme: &WidgetTheme, assets: &WidgetAssets, environment: WindowEnvironment, + cx: &mut Context, ) -> Element { let brand = Element::row([ Element::image(self.logo) @@ -113,12 +114,14 @@ impl WidgetGallery { ]) .align_items(AlignItems::CENTER) .gap(10.0); - let controls = (!self.compact).then(|| self.desktop_controls(theme, assets, environment)); + let controls = + (!self.compact).then(|| self.desktop_controls(theme, assets, environment, cx)); Element::row(std::iter::once(brand).chain(controls)) + .keyed("gallery-topbar") .height(length(64.0)) .padding(Sides { left: length(if self.compact { 14.0 } else { 22.0 }), - right: length(126.0), + right: length(if self.compact { 14.0 } else { 126.0 }), top: length(12.0), bottom: length(12.0), }) @@ -139,6 +142,7 @@ impl WidgetGallery { theme: &WidgetTheme, assets: &WidgetAssets, environment: WindowEnvironment, + cx: &mut Context, ) -> Element { let swatches = Element::row(PRIMARIES.iter().enumerate().map(|(index, color)| { Element::container([]) @@ -174,11 +178,16 @@ impl WidgetGallery { if cfg!(feature = "desktop-backdrop") { controls.push(self.backdrop_controls(theme, environment)); } - controls.push(self.theme_button(theme, assets)); + controls.push(self.theme_button(theme, assets, cx)); Element::row(controls).gap(10.0) } - pub(super) fn theme_button(&self, theme: &WidgetTheme, assets: &WidgetAssets) -> Element { + pub(super) fn theme_button( + &self, + theme: &WidgetTheme, + assets: &WidgetAssets, + cx: &mut Context, + ) -> Element { let icon = match self.theme_mode { ThemeMode::Light => TablerIcon::Sun, ThemeMode::Dark => TablerIcon::Moon, @@ -190,6 +199,10 @@ impl WidgetGallery { theme.ghost_button(), ) .leading(assets.icon(icon, 17.0)) + .on_click(cx.event_handler(|gallery, _, cx| { + gallery.cycle_theme(); + cx.set_theme(gallery.theme_request()); + })) .build() } } diff --git a/crates/argui-widget-gallery/src/app/navigation.rs b/crates/argui-widget-gallery/src/app/navigation.rs index 73046c3a..fe84d32d 100644 --- a/crates/argui-widget-gallery/src/app/navigation.rs +++ b/crates/argui-widget-gallery/src/app/navigation.rs @@ -5,8 +5,8 @@ use argui::{ paint::{Border, BorderWidths}, runtime::Context, ui::{ - Axes, Element, ElementKind, JustifyContent, Overflow, Role, ScrollAxes, ScrollConfig, - Semantics, Sides, TextSelection, UiEvent, UiEventKind, length, percent, + Axes, Element, ElementKind, EventHandler, JustifyContent, Overflow, Role, ScrollAxes, + ScrollConfig, Semantics, Sides, TextSelection, UiEvent, UiEventKind, length, percent, }, widgets::{Button, Input, InputKind, TablerIcon, WidgetAssets, WidgetTheme}, }; @@ -36,8 +36,13 @@ impl WidgetGallery { } } - pub(super) fn sidebar(&self, theme: &WidgetTheme, assets: &WidgetAssets) -> Element { - let mut children = vec![self.search_input(theme, assets)]; + pub(super) fn sidebar( + &self, + theme: &WidgetTheme, + assets: &WidgetAssets, + cx: &mut Context, + ) -> Element { + let mut children = vec![self.search_input(theme, assets, cx)]; for category in ["Widgets", "Effects", "Examples"] { let pages = self .filtered_pages() @@ -48,11 +53,10 @@ impl WidgetGallery { continue; } children.push(text(category, 11.0, theme.muted_foreground, 700)); - children.extend( - pages - .into_iter() - .map(|page| self.navigation_button(page, theme, true)), - ); + for page in pages { + let select = cx.callback(move |gallery| gallery.select_page(page)); + children.push(self.navigation_button(page, theme, true, select)); + } } let mut sidebar = Element::column(children) .keyed("gallery-sidebar") @@ -80,8 +84,13 @@ impl WidgetGallery { sidebar } - pub(super) fn mobile_navigation(&self, theme: &WidgetTheme, assets: &WidgetAssets) -> Element { - let mut items = vec![self.theme_button(theme, assets)]; + pub(super) fn mobile_navigation( + &self, + theme: &WidgetTheme, + assets: &WidgetAssets, + cx: &mut Context, + ) -> Element { + let mut items = vec![self.theme_button(theme, assets, cx)]; for category in ["Widgets", "Effects", "Examples"] { let pages = self .filtered_pages() @@ -96,11 +105,10 @@ impl WidgetGallery { .padding(Sides::length(8.0)) .shrink(0.0), ); - items.extend( - pages - .into_iter() - .map(|page| self.navigation_button(page, theme, false)), - ); + for page in pages { + let select = cx.callback(move |gallery| gallery.select_page(page)); + items.push(self.navigation_button(page, theme, false, select)); + } } let strip = Element::row(items) .keyed("gallery-mobile-navigation-strip") @@ -128,7 +136,7 @@ impl WidgetGallery { ) .semantics(Semantics::new(Role::Navigation).label("Component navigation")); Element::column([ - Element::container([self.search_input(theme, assets)]) + Element::container([self.search_input(theme, assets, cx)]) .padding(Sides { left: length(12.0), right: length(12.0), @@ -150,7 +158,12 @@ impl WidgetGallery { }) } - fn search_input(&self, theme: &WidgetTheme, assets: &WidgetAssets) -> Element { + fn search_input( + &self, + theme: &WidgetTheme, + assets: &WidgetAssets, + cx: &mut Context, + ) -> Element { Input::new( "gallery-search", self.search.clone(), @@ -161,16 +174,27 @@ impl WidgetGallery { .label("Search components") .description("Type anywhere outside an editor, or press Ctrl or Command K") .leading(assets.icon(TablerIcon::Search, 16.0), 38.0) + .on_input(cx.input_callback(|gallery, value| { + gallery.search = value; + gallery.search_highlight = 0; + })) .build() } - fn navigation_button(&self, page: Page, theme: &WidgetTheme, full_width: bool) -> Element { + fn navigation_button( + &self, + page: Page, + theme: &WidgetTheme, + full_width: bool, + select: EventHandler, + ) -> Element { let style = if page == self.page { theme.button() } else { theme.ghost_button() }; let mut button = Button::new(format!("nav::{}", page.slug()), page.label(), style) + .on_click(select) .build() .shrink(0.0) .justify_content(JustifyContent::START); diff --git a/crates/argui-widget-gallery/src/navigation.rs b/crates/argui-widget-gallery/src/navigation.rs index 51a1eb07..118dedd2 100644 --- a/crates/argui-widget-gallery/src/navigation.rs +++ b/crates/argui-widget-gallery/src/navigation.rs @@ -79,12 +79,16 @@ pub enum Page { AsyncTasks, Editing, CustomTimeline, + #[cfg(any(target_os = "android", target_os = "ios"))] + MobileActivity, #[cfg(feature = "updater")] Updater, } impl Page { - pub const ALL: [Self; 77 + cfg!(feature = "updater") as usize] = [ + pub const ALL: [Self; + 77 + cfg!(feature = "updater") as usize + + cfg!(any(target_os = "android", target_os = "ios")) as usize] = [ Self::Accordion, Self::Alert, Self::AlertDialog, @@ -153,6 +157,8 @@ impl Page { Self::LiquidGlass, Self::ScrollShadow, Self::AsyncTasks, + #[cfg(any(target_os = "android", target_os = "ios"))] + Self::MobileActivity, Self::CustomTimeline, Self::Editing, Self::HotReload, @@ -170,6 +176,8 @@ impl Page { match self { #[cfg(feature = "updater")] Self::Updater => "Examples", + #[cfg(any(target_os = "android", target_os = "ios"))] + Self::MobileActivity => "Examples", Self::Accordion | Self::AlertDialog | Self::Attachment @@ -327,6 +335,8 @@ impl Page { Self::TextSelection => "Text selection", Self::WebView => "WebView", Self::AsyncTasks => "Async tasks", + #[cfg(any(target_os = "android", target_os = "ios"))] + Self::MobileActivity => "Background activity", Self::Editing => "Editing & Password", Self::CustomTimeline => "Custom Timeline", #[cfg(feature = "updater")] @@ -412,6 +422,8 @@ impl Page { Self::TextSelection => "text-selection", Self::WebView => "webview", Self::AsyncTasks => "async-tasks", + #[cfg(any(target_os = "android", target_os = "ios"))] + Self::MobileActivity => "mobile-activity", Self::Editing => "editing", Self::CustomTimeline => "custom-timeline", #[cfg(feature = "updater")] @@ -425,11 +437,6 @@ impl Page { || self.label().to_lowercase().contains(&query) || self.category().to_lowercase().contains(&query) } - - pub fn from_navigation_key(key: &str) -> Option { - let slug = key.strip_prefix("nav::")?; - Self::ALL.into_iter().find(|page| page.slug() == slug) - } } impl Page { @@ -533,6 +540,10 @@ impl Page { "Retained web content, with separate email and webpage security policies." } Self::AsyncTasks => "Owned, cancellable work with event-driven delivery to the UI.", + #[cfg(any(target_os = "android", target_os = "ios"))] + Self::MobileActivity => { + "Continue a cancellable task in the background with native progress where available." + } Self::Editing => { "Transactional undo/redo, Unicode, filtered fields and protected passwords." } diff --git a/crates/argui-widget-gallery/src/pages.rs b/crates/argui-widget-gallery/src/pages.rs index 1d0cd24e..b0c06d0d 100644 --- a/crates/argui-widget-gallery/src/pages.rs +++ b/crates/argui-widget-gallery/src/pages.rs @@ -42,6 +42,8 @@ mod kbd; mod label; pub(crate) mod liquid_glass; pub(crate) mod menus; +#[cfg(any(target_os = "android", target_os = "ios"))] +pub(crate) mod mobile_activity; pub(crate) mod motion; pub(crate) mod overlay_effects; mod pagination; @@ -139,18 +141,19 @@ pub(crate) fn render( Page::Alert => alert::render(theme, assets), Page::Separator => separator::render(theme), Page::Collapsible => collapsible::render(gallery, theme, assets, cx), - Page::Button => buttons::render(gallery, theme, cx.entity(&gallery.spinner)), - Page::Input => inputs::render(gallery, theme, assets), + Page::Button => buttons::render(gallery, theme, cx.entity(&gallery.spinner), cx), + Page::Input => inputs::render(gallery, theme, assets, cx), Page::TextArea => textarea( gallery, theme, assets, + cx, resize.textarea, resize.textarea_reset, ), - Page::Checkbox => checkboxes(gallery, theme, assets), - Page::Switch => switches(gallery, theme), - Page::RadioGroup => radios(gallery, theme), + Page::Checkbox => checkboxes(gallery, theme, assets, cx), + Page::Switch => switches(gallery, theme, cx), + Page::RadioGroup => radios(gallery, theme, cx), Page::AnimatedText => cx.entity( gallery .animated_text @@ -161,10 +164,10 @@ pub(crate) fn render( .color_picker .get_or_init(|| Entity::new(color_picker::ColorPickerDemo::default())), ), - Page::Slider => sliders(gallery, theme, assets), - Page::Tabs => tabs(gallery, theme), + Page::Slider => sliders(gallery, theme, assets, cx), + Page::Tabs => tabs(gallery, theme, cx), Page::Select => selects(gallery, theme, assets), - Page::Dialog => dialogs(gallery, theme), + Page::Dialog => dialogs(gallery, theme, cx), Page::Popover => cx.entity(&gallery.popover), Page::Tooltip => cx.entity(&gallery.tooltip), Page::HotReload => cx.entity(&gallery.hot_reload), @@ -177,6 +180,8 @@ pub(crate) fn render( Page::TextSelection => cx.entity(&gallery.text_selection), Page::WebView => cx.entity(&gallery.webview), Page::AsyncTasks => cx.entity(&gallery.tasks), + #[cfg(any(target_os = "android", target_os = "ios"))] + Page::MobileActivity => cx.entity(&gallery.mobile_activity), Page::Editing => cx.entity(&gallery.editing), Page::CustomTimeline => cx.entity(&gallery.timeline), #[cfg(feature = "updater")] @@ -214,6 +219,7 @@ fn textarea( gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAssets, + cx: &mut argui::runtime::Context, resize_listener: EventListener, resize_reset_listener: EventListener, ) -> Element { @@ -226,6 +232,7 @@ fn textarea( }); let area = TextArea::new("notes", &gallery.notes, "Write a note…", style) .scrollbar(scrollbar) + .on_input(cx.input_callback(|gallery, value| gallery.notes = value)) .build(); preview( "Resizable multiline input", @@ -278,7 +285,12 @@ fn resizable_textarea( .position(Position::Relative) } -fn checkboxes(gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAssets) -> Element { +fn checkboxes( + gallery: &WidgetGallery, + theme: &WidgetTheme, + assets: &WidgetAssets, + cx: &mut argui::runtime::Context, +) -> Element { preview( "Team notifications", "Choose which project updates you receive. Space toggles the focused option.", @@ -293,6 +305,11 @@ fn checkboxes(gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAsset }, ) .indicator(assets.icon(TablerIcon::Check, 14.0)) + .on_change( + cx.value_callback(|gallery, value: argui::ui::CheckedState| { + gallery.accepted = value != argui::ui::CheckedState::Unchecked; + }), + ) .build(theme), Checkbox::new( "check-empty", @@ -303,6 +320,11 @@ fn checkboxes(gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAsset argui::ui::CheckedState::Unchecked }, ) + .on_change( + cx.value_callback(|gallery, value: argui::ui::CheckedState| { + gallery.email_updates = value != argui::ui::CheckedState::Unchecked; + }), + ) .build(theme), Checkbox::new( "check-disabled", @@ -317,13 +339,21 @@ fn checkboxes(gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAsset ) } -fn switches(gallery: &WidgetGallery, theme: &WidgetTheme) -> Element { +fn switches( + gallery: &WidgetGallery, + theme: &WidgetTheme, + cx: &mut argui::runtime::Context, +) -> Element { preview( "Rendering preferences", "Enable profiling while you work, or prepare a render without a network connection.", Element::column([ - Switch::new("notifications", "GPU profiling", gallery.notifications).build(theme), - Switch::new("switch-off", "Offline rendering", gallery.offline_rendering).build(theme), + Switch::new("notifications", "GPU profiling", gallery.notifications) + .on_change(cx.value_callback(|gallery, value| gallery.notifications = value)) + .build(theme), + Switch::new("switch-off", "Offline rendering", gallery.offline_rendering) + .on_change(cx.value_callback(|gallery, value| gallery.offline_rendering = value)) + .build(theme), Switch::new( "switch-disabled", "Cloud rendering (connection required)", @@ -337,7 +367,11 @@ fn switches(gallery: &WidgetGallery, theme: &WidgetTheme) -> Element { ) } -fn radios(gallery: &WidgetGallery, theme: &WidgetTheme) -> Element { +fn radios( + gallery: &WidgetGallery, + theme: &WidgetTheme, + cx: &mut argui::runtime::Context, +) -> Element { preview( "Quality preset", "Choose a rendering preset. Use the arrow keys to compare the options.", @@ -351,12 +385,18 @@ fn radios(gallery: &WidgetGallery, theme: &WidgetTheme) -> Element { ], Some(gallery.radio), ) + .on_select(cx.value_callback(|gallery, selection| gallery.radio = selection)) .build(theme), theme, ) } -fn sliders(gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAssets) -> Element { +fn sliders( + gallery: &WidgetGallery, + theme: &WidgetTheme, + assets: &WidgetAssets, + cx: &mut argui::runtime::Context, +) -> Element { preview( "Continuous and stepped input", "Drag a slider or use the arrow keys to adjust the value. Home and End reach the limits.", @@ -368,6 +408,8 @@ fn sliders(gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAssets) gallery.plain_slider, RangeConfig::default(), ) + .on_change(cx.value_callback(|gallery, value| gallery.plain_slider = value)) + .on_commit(cx.value_callback(|gallery, value| gallery.plain_slider = value)) .build(theme), ]) .gap(12.0), @@ -375,7 +417,11 @@ fn sliders(gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAssets) ) } -fn tabs(gallery: &WidgetGallery, theme: &WidgetTheme) -> Element { +fn tabs( + gallery: &WidgetGallery, + theme: &WidgetTheme, + cx: &mut argui::runtime::Context, +) -> Element { preview( "Tabbed settings", "Switch categories to explore the available workspace settings.", @@ -391,6 +437,7 @@ fn tabs(gallery: &WidgetGallery, theme: &WidgetTheme) -> Element { ], gallery.tab, ) + .on_select(cx.value_callback(|gallery, selection| gallery.tab = selection)) .build(theme), theme, ) @@ -414,7 +461,11 @@ fn selects(gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAssets) ) } -fn dialogs(gallery: &WidgetGallery, theme: &WidgetTheme) -> Element { +fn dialogs( + gallery: &WidgetGallery, + theme: &WidgetTheme, + cx: &mut argui::runtime::Context, +) -> Element { let content = Element::column([ text("Delete GPU cache?", 22.0, theme.foreground, 700), text( @@ -441,6 +492,7 @@ fn dialogs(gallery: &WidgetGallery, theme: &WidgetTheme) -> Element { Button::new("open-dialog", "Open dialog", theme.button()).build(), content, ) + .on_open_change(cx.value_callback(|gallery, open| gallery.dialog_open = open)) .build(theme), theme, ) diff --git a/crates/argui-widget-gallery/src/pages/animated_text.rs b/crates/argui-widget-gallery/src/pages/animated_text.rs index 49ece549..94f9b6e4 100644 --- a/crates/argui-widget-gallery/src/pages/animated_text.rs +++ b/crates/argui-widget-gallery/src/pages/animated_text.rs @@ -1,6 +1,6 @@ use argui::{ runtime::{Context, Entity, Render}, - ui::{Element, EventType, FlexWrap, UiEvent, length}, + ui::{Element, FlexWrap, length}, widgets::{AnimatedText, Button, TextAnimation, shadcn}, }; @@ -38,28 +38,22 @@ impl Default for AnimatedTextDemo { } impl AnimatedTextDemo { - fn input(&mut self, event: &UiEvent, cx: &mut Context) { - match event.target_key() { - Some("animated-increment") => self.value = self.value.saturating_add(1), - Some("animated-decrement") => self.value = self.value.saturating_sub(1), - Some("animated-carry") => self.value = if self.value == 99 { 100 } else { 99 }, - Some("animated-reset") => self.value = 10, - Some("animated-save") => { - self.saved = !self.saved; - self.status.update(|status, cx| { - status.set_text(if self.saved { "Saved" } else { "Draft" }); - cx.notify(); - }); - } - _ => return, - } + fn set_value(&mut self, value: i32) { + self.value = value; for number in &self.numbers { number.update(|number, cx| { number.set_text(self.value.to_string()); cx.notify(); }); } - cx.notify(); + } + + fn toggle_saved(&mut self) { + self.saved = !self.saved; + self.status.update(|status, cx| { + status.set_text(if self.saved { "Saved" } else { "Draft" }); + cx.notify(); + }); } } @@ -83,21 +77,52 @@ impl Render for AnimatedTextDemo { .radius(argui::paint::CornerRadii::all(12.0)) }) .collect::>(); - super::preview("Only the changing digits move", + super::preview( + "Only the changing digits move", "Try 10 to 11, a carry from 99 to 100, or several quick clicks. Reduced motion shows the final value immediately.", Element::column([ Element::row(cards).gap(16.0).flex_wrap(FlexWrap::Wrap), Element::row([ - Button::new("animated-decrement", "−1", theme.outline_button()).build(), - Button::new("animated-increment", "+1", theme.button()).build(), - Button::new("animated-carry", "99 / 100", theme.outline_button()).build(), - Button::new("animated-reset", "Reset to 10", theme.ghost_button()).build(), - ]).gap(8.0).flex_wrap(FlexWrap::Wrap), + Button::new("animated-decrement", "−1", theme.outline_button()) + .on_click(cx.callback(|demo| { + let value = demo.value.saturating_sub(1); + demo.set_value(value); + })) + .build(), + Button::new("animated-increment", "+1", theme.button()) + .on_click(cx.callback(|demo| { + let value = demo.value.saturating_add(1); + demo.set_value(value); + })) + .build(), + Button::new("animated-carry", "99 / 100", theme.outline_button()) + .on_click(cx.callback(|demo| { + let value = if demo.value == 99 { 100 } else { 99 }; + demo.set_value(value); + })) + .build(), + Button::new("animated-reset", "Reset to 10", theme.ghost_button()) + .on_click(cx.callback(|demo| demo.set_value(10))) + .build(), + ]) + .gap(8.0) + .flex_wrap(FlexWrap::Wrap), Element::row([ - Button::new("animated-save", "Toggle saved status", theme.outline_button()).build(), + Button::new( + "animated-save", + "Toggle saved status", + theme.outline_button(), + ) + .on_click(cx.callback(Self::toggle_saved)) + .build(), cx.entity(&self.status), - ]).gap(16.0).align_items(argui::ui::AlignItems::CENTER).flex_wrap(FlexWrap::Wrap), - ]).gap(24.0), theme) - .on(cx.listener(EventType::Click, Self::input)) + ]) + .gap(16.0) + .align_items(argui::ui::AlignItems::CENTER) + .flex_wrap(FlexWrap::Wrap), + ]) + .gap(24.0), + theme, + ) } } diff --git a/crates/argui-widget-gallery/src/pages/async_tasks.rs b/crates/argui-widget-gallery/src/pages/async_tasks.rs index 4bcc8cae..b81d45df 100644 --- a/crates/argui-widget-gallery/src/pages/async_tasks.rs +++ b/crates/argui-widget-gallery/src/pages/async_tasks.rs @@ -80,10 +80,23 @@ impl Render for TasksDemo { text("Type a title or id:500. Invalid id:abc produces a real parsing error. New searches cancel old results.", 13.0, theme.muted_foreground, 400), Input::new("tasks-query", &self.query, "Title or id:500", theme.input()) .kind(InputKind::Search) - .label("Draft search").build().width(percent(1.0)), + .label("Draft search") + .on_input(cx.input_event_handler(|demo, query, _, cx| { + demo.query = query; + demo.start(cx); + })) + .build().width(percent(1.0)), Element::row([ - Button::new("tasks-search", "Search", theme.button()).build(), - Button::new("tasks-cancel", "Cancel", theme.ghost_button()).enabled(self.slot.is_running()).build(), + Button::new("tasks-search", "Search", theme.button()) + .on_click(cx.event_handler(|demo, _, cx| demo.start(cx))) + .build(), + Button::new("tasks-cancel", "Cancel", theme.ghost_button()) + .enabled(self.slot.is_running()) + .on_click(cx.callback(|demo| { + demo.slot.cancel(); + demo.status = "Cancelled".into(); + })) + .build(), ]).gap(8.0), text(&self.status, 13.0, theme.foreground, 400), VList::new("tasks-results", 32.0, 256.0, self.offset) @@ -92,24 +105,6 @@ impl Render for TasksDemo { }), Element::container([]).height(length(1.0)).background(Color::TRANSPARENT), ]).gap(12.0).width(percent(1.0)) - .on(cx.listener(EventType::Input, |demo, event, cx| { - if event.target_key() == Some("tasks-query") - && let UiEventKind::TextChanged(value) = &event.kind { - demo.query = value.clone(); - demo.start(cx); - } - })) - .on(cx.listener(EventType::Click, |demo, event, cx| { - match event.target_key() { - Some("tasks-search") => demo.start(cx), - Some("tasks-cancel") => { - demo.slot.cancel(); - demo.status = "Cancelled".into(); - cx.notify(); - } - _ => {} - } - })) .on(cx.listener(EventType::Scroll, |demo, event, cx| { if event.target_key() == Some("tasks-results") && let UiEventKind::Scrolled { offset, .. } = event.kind { diff --git a/crates/argui-widget-gallery/src/pages/breadcrumb.rs b/crates/argui-widget-gallery/src/pages/breadcrumb.rs index f5b956f5..1890d042 100644 --- a/crates/argui-widget-gallery/src/pages/breadcrumb.rs +++ b/crates/argui-widget-gallery/src/pages/breadcrumb.rs @@ -1,6 +1,6 @@ use argui::{ runtime::Context, - ui::{Element, EventType, length}, + ui::{Element, length}, widgets::{Breadcrumb, BreadcrumbLink, Card, WidgetTheme}, }; @@ -14,7 +14,14 @@ pub(super) fn render(theme: &WidgetTheme, cx: &mut Context) -> El BreadcrumbLink::new("components", "Components"), ], "Breadcrumb", - ); + ) + .on_activate(cx.value_callback(|gallery, id| { + gallery.page = if id == "home" { + Page::Button + } else { + Page::Card + }; + })); let custom = Breadcrumb::new( "project-path", [ @@ -24,24 +31,19 @@ pub(super) fn render(theme: &WidgetTheme, cx: &mut Context) -> El "Navigation", ) .separator("›") - .label("Project location"); + .label("Project location") + .on_activate(cx.value_callback(|gallery, id| { + gallery.page = if id == "projects" { + Page::Empty + } else { + Page::Card + }; + })); Element::column([ super::preview( "Know where you are", "Follow the trail back to an earlier page.", - breadcrumb - .build(theme) - .on(cx.listener(EventType::Click, move |gallery, event, cx| { - if let Some(id) = breadcrumb.action(event) { - gallery.page = if id == "home" { - Page::Button - } else { - Page::Card - }; - event.stop_propagation(); - cx.notify(); - } - })), + breadcrumb.build(theme), theme, ), super::preview( @@ -57,20 +59,7 @@ pub(super) fn render(theme: &WidgetTheme, cx: &mut Context) -> El ), ) .title("Component library") - .footer(custom.build(theme).on(cx.listener( - EventType::Click, - move |gallery, event, cx| { - if let Some(id) = custom.action(event) { - gallery.page = if id == "projects" { - Page::Empty - } else { - Page::Card - }; - event.stop_propagation(); - cx.notify(); - } - }, - ))) + .footer(custom.build(theme)) .build(theme) .max_width(length(560.0)), theme, diff --git a/crates/argui-widget-gallery/src/pages/buttons.rs b/crates/argui-widget-gallery/src/pages/buttons.rs index b35c30d8..c5354a7b 100644 --- a/crates/argui-widget-gallery/src/pages/buttons.rs +++ b/crates/argui-widget-gallery/src/pages/buttons.rs @@ -1,7 +1,8 @@ use argui::{ core::{Color, Rect, Transform2D}, paint::{CornerRadii, LayerMask, LayerStyle, Shadow}, - ui::{Element, FlexWrap, property}, + runtime::Context, + ui::{Element, EventHandler, FlexWrap, property}, widgets::{Button, WidgetTheme}, }; use argui_effects::{ANIMATED_GRADIENT_ID, AnimatedGradient}; @@ -9,18 +10,24 @@ use argui_effects::{ANIMATED_GRADIENT_ID, AnimatedGradient}; use super::{preview, text}; use crate::app::WidgetGallery; -pub(super) fn render(gallery: &WidgetGallery, theme: &WidgetTheme, spinner: Element) -> Element { +pub(super) fn render( + gallery: &WidgetGallery, + theme: &WidgetTheme, + spinner: Element, + cx: &mut Context, +) -> Element { + let activate = cx.callback(|gallery| gallery.clicks = gallery.clicks.saturating_add(1)); Element::column([ preview( "Variants", "Hover for a tooltip, then click any available button to update the count below.", - variants(theme, spinner), + variants(theme, spinner, activate), theme, ), preview( "Depth and GPU hover", "Shadows, transforms and typed WGSL parameters use the same retained hover transition.", - expressive_buttons(theme), + expressive_buttons(theme, activate), theme, ), text( @@ -33,13 +40,23 @@ pub(super) fn render(gallery: &WidgetGallery, theme: &WidgetTheme, spinner: Elem .gap(8.0) } -fn variants(theme: &WidgetTheme, spinner: Element) -> Element { +fn variants(theme: &WidgetTheme, spinner: Element, activate: EventHandler) -> Element { Element::row([ - Button::new("demo-button", "Primary", theme.button()).build(), - Button::new("secondary", "Secondary", theme.secondary_button()).build(), - Button::new("outline", "Outline", theme.outline_button()).build(), - Button::new("ghost", "Ghost", theme.ghost_button()).build(), - Button::new("danger", "Delete", theme.destructive_button()).build(), + Button::new("demo-button", "Primary", theme.button()) + .on_click(activate) + .build(), + Button::new("secondary", "Secondary", theme.secondary_button()) + .on_click(activate) + .build(), + Button::new("outline", "Outline", theme.outline_button()) + .on_click(activate) + .build(), + Button::new("ghost", "Ghost", theme.ghost_button()) + .on_click(activate) + .build(), + Button::new("danger", "Delete", theme.destructive_button()) + .on_click(activate) + .build(), Button::new("disabled", "Disabled", theme.outline_button()) .enabled(false) .build(), @@ -51,18 +68,19 @@ fn variants(theme: &WidgetTheme, spinner: Element) -> Element { .gap(10.0) } -fn expressive_buttons(theme: &WidgetTheme) -> Element { +fn expressive_buttons(theme: &WidgetTheme, activate: EventHandler) -> Element { Element::row([ - elevated_button(theme), - lift_button(theme), - shader_button(theme), + elevated_button(theme, activate), + lift_button(theme, activate), + shader_button(theme, activate), ]) .flex_wrap(FlexWrap::Wrap) .gap(14.0) } -fn elevated_button(theme: &WidgetTheme) -> Element { +fn elevated_button(theme: &WidgetTheme, activate: EventHandler) -> Element { Button::new("button-elevated", "Elevated", theme.secondary_button()) + .on_click(activate) .build() .layer(button_layer().shadow(Shadow::drop( [0.0, 5.0], @@ -71,7 +89,7 @@ fn elevated_button(theme: &WidgetTheme) -> Element { ))) } -fn lift_button(theme: &WidgetTheme) -> Element { +fn lift_button(theme: &WidgetTheme, activate: EventHandler) -> Element { let mut style = theme.outline_button(); style.hovered = style .hovered @@ -86,6 +104,7 @@ fn lift_button(theme: &WidgetTheme) -> Element { Transform2D::IDENTITY.translate(0.0, 1.0), ); Button::new("button-lift", "Lift on hover", style) + .on_click(activate) .build() .layer(button_layer().shadow(Shadow::drop( [0.0, 2.0], @@ -94,7 +113,7 @@ fn lift_button(theme: &WidgetTheme) -> Element { ))) } -fn shader_button(theme: &WidgetTheme) -> Element { +fn shader_button(theme: &WidgetTheme, activate: EventHandler) -> Element { let mut style = theme.button(); style.hovered = style.hovered.set( property::effect_f32(ANIMATED_GRADIENT_ID, "intensity"), @@ -105,6 +124,7 @@ fn shader_button(theme: &WidgetTheme) -> Element { 0.48, ); Button::new("button-shader", "GPU shader hover", style) + .on_click(activate) .build() .layer( button_layer().filter( diff --git a/crates/argui-widget-gallery/src/pages/card.rs b/crates/argui-widget-gallery/src/pages/card.rs index 5ab775f0..826b26f9 100644 --- a/crates/argui-widget-gallery/src/pages/card.rs +++ b/crates/argui-widget-gallery/src/pages/card.rs @@ -1,6 +1,6 @@ use argui::{ runtime::Context, - ui::{AlignItems, Element, EventType, FlexWrap, JustifyContent, length}, + ui::{AlignItems, Element, FlexWrap, JustifyContent, length}, widgets::{ Badge, BadgeVariant, Button, Card, Separator, TablerIcon, WidgetAssets, WidgetTheme, }, @@ -23,11 +23,10 @@ pub(super) fn render( .icon(TablerIcon::ChevronRight, 16.0) .vector_color(theme.foreground), ) - .build() - .on(cx.listener(EventType::Click, |gallery, _, cx| { + .on_click(cx.callback(|gallery| { gallery.page = Page::Collapsible; - cx.notify(); - })); + })) + .build(); super::preview( "Project overview", "Compose a header, an optional action, body content and a footer.", diff --git a/crates/argui-widget-gallery/src/pages/collapsible.rs b/crates/argui-widget-gallery/src/pages/collapsible.rs index a5924a77..c6504406 100644 --- a/crates/argui-widget-gallery/src/pages/collapsible.rs +++ b/crates/argui-widget-gallery/src/pages/collapsible.rs @@ -1,7 +1,7 @@ use argui::{ paint::{Border, CornerRadii}, runtime::Context, - ui::{Element, EventType, Sides, length}, + ui::{Element, Sides, length}, widgets::{Collapsible, TablerIcon, WidgetAssets, WidgetTheme}, }; @@ -43,21 +43,8 @@ pub(super) fn render( ) .vector_color(theme.foreground), ) - .build(theme) - .on(cx.listener(EventType::Click, |gallery, event, cx| { - if let Some(open) = Collapsible::new( - "project-files", - "Project files", - gallery.collapsible_open, - Element::container([]), - ) - .action(event) - { - gallery.collapsible_open = open; - event.stop_propagation(); - cx.notify(); - } - })); + .on_open_change(cx.value_callback(|gallery, open| gallery.collapsible_open = open)) + .build(theme); super::preview( "A closer look", "Expand the project or archive to browse its components.", @@ -85,21 +72,8 @@ pub(super) fn render( ) .vector_color(theme.foreground), ) - .build(theme) - .on(cx.listener(EventType::Click, |gallery, event, cx| { - if let Some(open) = Collapsible::new( - "archived-files", - "Archived files", - gallery.archived_open, - Element::container([]), - ) - .action(event) - { - gallery.archived_open = open; - event.stop_propagation(); - cx.notify(); - } - })), + .on_open_change(cx.value_callback(|gallery, open| gallery.archived_open = open)) + .build(theme), ]) .gap(24.0) .max_width(length(480.0)), diff --git a/crates/argui-widget-gallery/src/pages/empty.rs b/crates/argui-widget-gallery/src/pages/empty.rs index a98c00d6..86a0e70f 100644 --- a/crates/argui-widget-gallery/src/pages/empty.rs +++ b/crates/argui-widget-gallery/src/pages/empty.rs @@ -2,7 +2,7 @@ use crate::{WidgetGallery, navigation::Page}; use argui::{ paint::CornerRadii, runtime::Context, - ui::{Element, EventType, Sides, length}, + ui::{Element, Sides, length}, widgets::{Button, Empty, TablerIcon, WidgetAssets, WidgetTheme}, }; @@ -12,11 +12,10 @@ pub(super) fn render( cx: &mut Context, ) -> Element { let action = Button::new("empty-browse", "Browse components", theme.button()) - .build() - .on(cx.listener(EventType::Click, |gallery, _, cx| { + .on_click(cx.callback(|gallery| { gallery.page = Page::Card; - cx.notify(); - })); + })) + .build(); let media = Element::row([assets .icon(TablerIcon::Copy, 24.0) .vector_color(theme.foreground)]) diff --git a/crates/argui-widget-gallery/src/pages/hot_reload.rs b/crates/argui-widget-gallery/src/pages/hot_reload.rs index 5f8a8b1f..1e4cd644 100644 --- a/crates/argui-widget-gallery/src/pages/hot_reload.rs +++ b/crates/argui-widget-gallery/src/pages/hot_reload.rs @@ -1,6 +1,6 @@ use argui::{ runtime::{Context, Render}, - ui::{Element, EventType, Sides, UiEvent, percent}, + ui::{Element, Sides, percent}, widgets::{Button, shadcn}, }; @@ -9,17 +9,6 @@ pub(crate) struct HotReloadDemo { count: u32, } -impl HotReloadDemo { - fn event(&mut self, event: &UiEvent, cx: &mut Context) { - match event.target_key() { - Some("hot-reload-increment") => self.count = self.count.saturating_add(1), - Some("hot-reload-reset") => self.count = 0, - _ => return, - } - cx.notify(); - } -} - impl Render for HotReloadDemo { fn render(&mut self, cx: &mut Context) -> Element { let themes = shadcn(cx.environment()); @@ -44,8 +33,12 @@ impl Render for HotReloadDemo { 740, ), Element::row([ - Button::new("hot-reload-increment", "Increment", theme.button()).build(), - Button::new("hot-reload-reset", "Reset", theme.outline_button()).build(), + Button::new("hot-reload-increment", "Increment", theme.button()) + .on_click(cx.callback(|demo| demo.count = demo.count.saturating_add(1))) + .build(), + Button::new("hot-reload-reset", "Reset", theme.outline_button()) + .on_click(cx.callback(|demo| demo.count = 0)) + .build(), ]) .gap(8.0), crate::app::text( @@ -60,6 +53,5 @@ impl Render for HotReloadDemo { .width(percent(1.0)), theme, ) - .on(cx.listener(EventType::Click, Self::event)) } } diff --git a/crates/argui-widget-gallery/src/pages/i18n.rs b/crates/argui-widget-gallery/src/pages/i18n.rs index beb1ea36..7f06a599 100644 --- a/crates/argui-widget-gallery/src/pages/i18n.rs +++ b/crates/argui-widget-gallery/src/pages/i18n.rs @@ -2,7 +2,7 @@ use argui::{ i18n::{Catalog, FluentArgs, Localizer, langid}, paint::{Border, CornerRadii}, runtime::{Context, Render}, - ui::{AlignItems, Element, EventType, FlexWrap, Sides, UiEvent, WritingDirection, length}, + ui::{AlignItems, Element, FlexWrap, Sides, WritingDirection, length}, widgets::{Button, shadcn}, }; @@ -68,24 +68,6 @@ impl Default for I18nDemo { } impl I18nDemo { - fn event(&mut self, event: &UiEvent, cx: &mut Context) { - match event.target_key() { - Some("i18n-english") => { - self.localizer.select([langid!("en-US")]); - } - Some("i18n-french") => { - self.localizer.select([langid!("fr-CA")]); - } - Some("i18n-arabic") => { - self.localizer.select([langid!("ar")]); - } - Some("i18n-less") => self.count = self.count.saturating_sub(1).max(0), - Some("i18n-more") => self.count = self.count.saturating_add(1).min(99), - _ => return, - } - cx.notify(); - } - fn text(&self, id: &str) -> String { self.localizer .text(id) @@ -113,15 +95,31 @@ impl Render for I18nDemo { WritingDirection::Ltr }; let language = Element::row([ - Button::new("i18n-english", "English", theme.outline_button()).build(), - Button::new("i18n-french", "Français", theme.outline_button()).build(), - Button::new("i18n-arabic", "العربية", theme.outline_button()).build(), + Button::new("i18n-english", "English", theme.outline_button()) + .on_click(cx.callback(|demo| { + demo.localizer.select([langid!("en-US")]); + })) + .build(), + Button::new("i18n-french", "Français", theme.outline_button()) + .on_click(cx.callback(|demo| { + demo.localizer.select([langid!("fr-CA")]); + })) + .build(), + Button::new("i18n-arabic", "العربية", theme.outline_button()) + .on_click(cx.callback(|demo| { + demo.localizer.select([langid!("ar")]); + })) + .build(), ]) .gap(8.0) .flex_wrap(FlexWrap::Wrap); let controls = Element::row([ - Button::new("i18n-less", self.text("less"), theme.outline_button()).build(), - Button::new("i18n-more", self.text("more"), theme.outline_button()).build(), + Button::new("i18n-less", self.text("less"), theme.outline_button()) + .on_click(cx.callback(|demo| demo.count = demo.count.saturating_sub(1).max(0))) + .build(), + Button::new("i18n-more", self.text("more"), theme.outline_button()) + .on_click(cx.callback(|demo| demo.count = demo.count.saturating_add(1).min(99))) + .build(), Button::new( "i18n-save", self.localizer @@ -168,6 +166,5 @@ impl Render for I18nDemo { Element::column([language, localized]).gap(16.0), theme, ) - .on(cx.listener(EventType::Click, Self::event)) } } diff --git a/crates/argui-widget-gallery/src/pages/inputs.rs b/crates/argui-widget-gallery/src/pages/inputs.rs index 4a42503b..a08fa302 100644 --- a/crates/argui-widget-gallery/src/pages/inputs.rs +++ b/crates/argui-widget-gallery/src/pages/inputs.rs @@ -2,6 +2,7 @@ use argui::{ animation::{Duration, Keyframe, Keyframes}, core::{Color, Transform2D}, paint::{CornerRadii, QuadStyle}, + runtime::Context, ui::{ CaretAlign, CaretAnimation, CaretFrame, CaretHeight, CaretPrimitive, CaretStyle, CaretVisual, Element, @@ -16,9 +17,10 @@ pub(super) fn render( gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAssets, + cx: &mut Context, ) -> Element { Element::column([ - controlled_fields(gallery, theme, assets), + controlled_fields(gallery, theme, assets, cx), caret_playground(theme), ]) .gap(28.0) @@ -28,6 +30,7 @@ fn controlled_fields( gallery: &WidgetGallery, theme: &WidgetTheme, assets: &WidgetAssets, + cx: &mut Context, ) -> Element { let search = Input::new( "input-search-demo", @@ -38,6 +41,7 @@ fn controlled_fields( .kind(InputKind::Search) .label("GPU graph search") .leading(assets.icon(TablerIcon::Search, 16.0), 38.0) + .on_input(cx.input_callback(|gallery, value| gallery.input_search = value)) .build(); preview( "Controlled fields", @@ -45,10 +49,12 @@ fn controlled_fields( Element::column([ Input::new("name", &gallery.name, "Full name", theme.input()) .label("Full name") + .on_input(cx.input_callback(|gallery, value| gallery.name = value)) .build(), Input::new("email", &gallery.email, "Email", theme.input()) .label("Email address") .description("Used only for this local preview") + .on_input(cx.input_callback(|gallery, value| gallery.email = value)) .build(), search, Input::new("invalid", &gallery.invalid_email, "Email", theme.input()) @@ -59,6 +65,7 @@ fn controlled_fields( .split_once('@') .is_some_and(|(name, domain)| !name.is_empty() && domain.contains('.')), ) + .on_input(cx.input_callback(|gallery, value| gallery.invalid_email = value)) .build(), Input::new("readonly", "Read-only value", "", theme.input()) .read_only(true) diff --git a/crates/argui-widget-gallery/src/pages/label.rs b/crates/argui-widget-gallery/src/pages/label.rs index 02961df4..41d9eb94 100644 --- a/crates/argui-widget-gallery/src/pages/label.rs +++ b/crates/argui-widget-gallery/src/pages/label.rs @@ -13,8 +13,11 @@ pub(super) fn render( ) -> Element { let label = Label::new("profile-name-label", "Display name", "name"); let workspace = Label::new("profile-id-label", "Workspace ID", "workspace-id"); - let name = - label.associate(Input::new("name", &gallery.name, "Your name", theme.input()).build()); + let name = label.associate( + Input::new("name", &gallery.name, "Your name", theme.input()) + .on_input(cx.input_callback(|gallery, value| gallery.name = value)) + .build(), + ); let id = workspace.associate( Input::new( "workspace-id", @@ -22,6 +25,7 @@ pub(super) fn render( "Workspace ID", theme.input(), ) + .on_input(cx.input_callback(|gallery, value| gallery.workspace_id = value)) .build(), ); super::preview( diff --git a/crates/argui-widget-gallery/src/pages/menus.rs b/crates/argui-widget-gallery/src/pages/menus.rs index 7620f958..cdcc42a0 100644 --- a/crates/argui-widget-gallery/src/pages/menus.rs +++ b/crates/argui-widget-gallery/src/pages/menus.rs @@ -259,14 +259,9 @@ impl MenusDemo { .or_else(|| context.response(event)) } else if self.page == Page::Menubar { let menus = [self.menu("first-menu"), self.menu("second-menu")]; - match (Menubar { - key: "bar", - label: "Menu bar", - menus: &menus, - active: Some(&self.active_menu), - rtl: false, - }) - .response(event) + match Menubar::new("bar", "Menu bar", &menus) + .active(Some(&self.active_menu)) + .response(event) { Some(MenubarResponse::Focus { key }) => { self.active_menu = key.clone(); @@ -372,13 +367,12 @@ impl Render for MenusDemo { .height(argui::ui::length(180.0)), theme, ), - Page::Menubar => Menubar { - key: "bar", - label: "Menu bar", - menus: &[self.menu("first-menu"), self.menu("second-menu")], - active: Some(&self.active_menu), - rtl: false, - } + Page::Menubar => Menubar::new( + "bar", + "Menu bar", + &[self.menu("first-menu"), self.menu("second-menu")], + ) + .active(Some(&self.active_menu)) .build(theme), _ => self.menu("menu").build( Button::new("trigger", "Note actions", theme.outline_button()).build(), diff --git a/crates/argui-widget-gallery/src/pages/mobile_activity.rs b/crates/argui-widget-gallery/src/pages/mobile_activity.rs new file mode 100644 index 00000000..6a767af5 --- /dev/null +++ b/crates/argui-widget-gallery/src/pages/mobile_activity.rs @@ -0,0 +1,162 @@ +use std::time::Duration; + +use argui::{ + platform::mobile::{ + MobileActivity, MobileActivityCapability, MobileActivityError, MobileActivityProgress, + }, + runtime::{ + Context, Render, + tasks::{self, TaskSlot}, + }, + ui::{Element, length, percent}, + widgets::{Button, Progress, shadcn}, +}; + +use crate::app::text; + +/// Gallery example for a native background task and its platform progress surface. +#[derive(Default)] +pub(crate) struct MobileActivityDemo { + slot: TaskSlot, + activity: Option, + progress: Option, + status: String, +} + +impl MobileActivityDemo { + /// Starts a short task and its native Android service or iOS activity. + fn start(&mut self, cx: &mut Context) { + if self.slot.is_running() { + return; + } + match MobileActivity::begin("Argui background task", "Starting work…") { + Ok(activity) => { + let reporter = activity.progress(); + self.activity = Some(activity); + self.progress = None; + self.status = + "Working for about 20 seconds. You can leave the app open in the background." + .into(); + if let Err(error) = cx.spawn_latest( + &mut self.slot, + run_background_activity(reporter), + |demo, result, cx| { + demo.progress = if matches!(&result, Ok(Ok(()))) { + Some(100.0) + } else { + demo.progress + }; + demo.status = match result { + Ok(Ok(())) => "Finished successfully.".into(), + Ok(Err(error)) => format!("Task failed: {error}"), + Err(error) => format!("Task stopped: {error}"), + }; + if let Err(error) = demo.finish_activity() { + demo.status + .push_str(&format!(" Could not remove native activity: {error}")); + } + cx.notify(); + }, + ) { + let _ = self.finish_activity(); + self.status = format!("Could not start the task: {error}"); + } + } + Err(MobileActivityError::NotificationPermissionRequired) => { + self.status = "Android requested notification permission. Allow it, then tap Start again; if denied, enable notifications in app settings.".into(); + } + Err(error) => self.status = format!("Could not start native activity: {error}"), + } + cx.notify(); + } + + /// Cancels the task and removes its native activity surface. + fn cancel(&mut self, cx: &mut Context) { + self.slot.cancel(); + self.status = "Cancelled.".into(); + if let Err(error) = self.finish_activity() { + self.status + .push_str(&format!(" Could not remove native activity: {error}")); + } + cx.notify(); + } + + /// Finishes and removes the owned native mobile activity, if one is active. + fn finish_activity(&mut self) -> Result<(), MobileActivityError> { + if let Some(mut activity) = self.activity.take() { + activity.finish()?; + } + Ok(()) + } +} + +/// Simulates 20 seconds of cancellable work and reports progress once per second. +async fn run_background_activity(progress: MobileActivityProgress) -> Result<(), String> { + for tick in 1..=20_u8 { + tasks::sleep(Duration::from_secs(1)).await; + let percent = tick * 5; + progress + .update(percent, &format!("Working · {percent}%")) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +impl Render for MobileActivityDemo { + /// Renders task controls, local progress, and the current native-platform behavior. + fn render(&mut self, cx: &mut Context) -> Element { + let themes = shadcn(cx.environment()); + let theme = themes.resolve(cx.environment().color_scheme); + let capability = match MobileActivity::capability() { + MobileActivityCapability::OngoingNotification => { + "Android uses a foreground service with an ongoing progress notification." + } + MobileActivityCapability::LiveActivityOrTimeLimited => { + "iOS shows a Live Activity where allowed; otherwise execution is best-effort and time-limited, without an ongoing notification." + } + MobileActivityCapability::ForegroundOnly => { + "This platform does not provide a native background activity." + } + }; + Element::column([ + text( + "Start, then use Home or lock the device to check the native progress surface.", + 14.0, + theme.foreground, + 500, + ), + text(capability, 13.0, theme.muted_foreground, 400), + Element::row([ + Button::new("mobile-activity-start", "Start", theme.button()) + .enabled(!self.slot.is_running()) + .on_click(cx.event_handler(|demo, _, cx| demo.start(cx))) + .build(), + Button::new("mobile-activity-cancel", "Cancel", theme.outline_button()) + .enabled(self.slot.is_running()) + .on_click(cx.event_handler(|demo, _, cx| demo.cancel(cx))) + .build(), + ]) + .gap(8.0), + Progress::new( + "mobile-activity-progress", + "Background task progress", + self.progress, + ) + .build(&theme) + .width(percent(1.0)), + text( + if self.status.is_empty() { + "Ready. The demo runs for about 20 seconds.".into() + } else { + self.status.clone() + }, + 13.0, + theme.foreground, + 400, + ), + Element::container([]).height(length(1.0)), + ]) + .gap(14.0) + .width(percent(1.0)) + } +} diff --git a/crates/argui-widget-gallery/src/pages/pagination.rs b/crates/argui-widget-gallery/src/pages/pagination.rs index a7d1fdd4..2506ddb1 100644 --- a/crates/argui-widget-gallery/src/pages/pagination.rs +++ b/crates/argui-widget-gallery/src/pages/pagination.rs @@ -1,6 +1,6 @@ use argui::{ runtime::Context, - ui::{Element, EventType, length}, + ui::{Element, length}, widgets::{Card, Pagination, WidgetTheme}, }; @@ -12,7 +12,8 @@ pub(super) fn render( cx: &mut Context, ) -> Element { let page = gallery.result_page; - let pagination = Pagination::new("results", page, 12); + let pagination = Pagination::new("results", page, 12) + .on_select(cx.value_callback(|gallery, page| gallery.result_page = page)); let titles = [ "Navigation patterns", "Designing with contrast", @@ -38,19 +39,7 @@ pub(super) fn render( let content = Card::new("result-list", Element::column(rows).gap(24.0)) .title("Library notes") .description(format!("Page {page} of 12 · 36 notes")) - .footer( - pagination - .build(theme) - .on(cx.listener(EventType::Click, |gallery, event, cx| { - if let Some(page) = - Pagination::new("results", gallery.result_page, 12).action(event) - { - gallery.result_page = page; - event.stop_propagation(); - cx.notify(); - } - })), - ) + .footer(pagination.build(theme)) .build(theme) .max_width(length(720.0)); Element::column([ diff --git a/crates/argui-widget-gallery/src/pages/progress.rs b/crates/argui-widget-gallery/src/pages/progress.rs index 71272532..f718216d 100644 --- a/crates/argui-widget-gallery/src/pages/progress.rs +++ b/crates/argui-widget-gallery/src/pages/progress.rs @@ -1,7 +1,7 @@ use crate::app::text; use argui::{ runtime::{Context, Entity, Render}, - ui::{Element, EventType, FlexWrap, UiEvent, length}, + ui::{Element, FlexWrap, length}, widgets::{Button, Progress, shadcn}, }; @@ -21,44 +21,73 @@ impl Default for ProgressDemo { } } -impl ProgressDemo { - fn event(&mut self, event: &UiEvent, cx: &mut Context) { - let current = self.progress.read(Progress::value); - let value = match event.target_key() { - Some("progress-advance") => Some((current.unwrap_or(0.0) + 25.0).min(100.0)), - Some("progress-reset") => Some(0.0), - Some("progress-mode") => { - if current.is_some() { - None - } else { - Some(45.0) - } - } - _ => return, - }; - self.progress.update(|progress, cx| { - progress.set_value(value); - cx.notify(); - }); - cx.notify(); - } -} - impl Render for ProgressDemo { fn render(&mut self, cx: &mut Context) -> Element { let themes = shadcn(cx.environment()); let theme = themes.resolve(cx.environment().color_scheme); let value = self.progress.read(Progress::value); - super::preview("Keep track of the work", "Use a percentage when the total is known, or an indeterminate indicator while waiting.", + let advance = cx.callback(|demo| { + demo.progress.update(|progress, cx| { + progress.set_value(Some((progress.value().unwrap_or(0.0) + 25.0).min(100.0))); + cx.notify(); + }); + }); + let reset = cx.callback(|demo| { + demo.progress.update(|progress, cx| { + progress.set_value(Some(0.0)); + cx.notify(); + }); + }); + let mode = cx.callback(|demo| { + demo.progress.update(|progress, cx| { + progress.set_value(if progress.value().is_some() { + None + } else { + Some(45.0) + }); + cx.notify(); + }); + }); + super::preview( + "Keep track of the work", + "Use a percentage when the total is known, or an indeterminate indicator while waiting.", Element::column([ - text(value.map_or_else(|| "Preparing your upload…".into(), |value| format!("Uploading files · {value:.0}%")), 14.0, theme.foreground, 500), + text( + value.map_or_else( + || "Preparing your upload…".into(), + |value| format!("Uploading files · {value:.0}%"), + ), + 14.0, + theme.foreground, + 500, + ), cx.entity(&self.progress), Element::row([ - Button::new("progress-advance", "Advance", theme.button()).enabled(value.is_some_and(|value| value < 100.0)).build(), - Button::new("progress-reset", "Reset", theme.outline_button()).build(), - Button::new("progress-mode", if value.is_some() { "Indeterminate" } else { "Determinate" }, theme.outline_button()).build(), - ]).gap(8.0).flex_wrap(FlexWrap::Wrap), - ]).gap(20.0).max_width(length(520.0)), theme) - .on(cx.listener(EventType::Click, Self::event)) + Button::new("progress-advance", "Advance", theme.button()) + .enabled(value.is_some_and(|value| value < 100.0)) + .on_click(advance) + .build(), + Button::new("progress-reset", "Reset", theme.outline_button()) + .on_click(reset) + .build(), + Button::new( + "progress-mode", + if value.is_some() { + "Indeterminate" + } else { + "Determinate" + }, + theme.outline_button(), + ) + .on_click(mode) + .build(), + ]) + .gap(8.0) + .flex_wrap(FlexWrap::Wrap), + ]) + .gap(20.0) + .max_width(length(520.0)), + theme, + ) } } diff --git a/crates/argui-widget-gallery/src/pages/skeleton.rs b/crates/argui-widget-gallery/src/pages/skeleton.rs index 667e85df..cc030aaa 100644 --- a/crates/argui-widget-gallery/src/pages/skeleton.rs +++ b/crates/argui-widget-gallery/src/pages/skeleton.rs @@ -1,6 +1,6 @@ use argui::{ runtime::{Context, Entity, Render}, - ui::{AlignItems, Element, EventType, Role, SemanticState, Semantics, length, percent}, + ui::{AlignItems, Element, Role, SemanticState, Semantics, length, percent}, widgets::{Avatar, Button, Card, Skeleton, shadcn}, }; @@ -113,11 +113,10 @@ impl Render for SkeletonDemo { }, theme.outline_button(), ) - .build() - .on(cx.listener(EventType::Click, |demo, _, cx| { + .on_click(cx.callback(|demo| { demo.loaded = !demo.loaded; - cx.notify(); - })), + })) + .build(), ]) .gap(20.0) .max_width(length(480.0)), diff --git a/crates/argui-widget-gallery/src/pages/text_selection.rs b/crates/argui-widget-gallery/src/pages/text_selection.rs index 95b021cf..83b412f2 100644 --- a/crates/argui-widget-gallery/src/pages/text_selection.rs +++ b/crates/argui-widget-gallery/src/pages/text_selection.rs @@ -4,8 +4,8 @@ use argui::{ runtime::{Context, Render}, text::{TextStyle, TextWrap}, ui::{ - Element, EventType, FlexWrap, Sides, TextSelectionHighlight, TextSelectionStyle, - UiEventKind, UserSelect, length, percent, + Element, FlexWrap, Sides, TextSelectionHighlight, TextSelectionStyle, UserSelect, length, + percent, }, widgets::{Button, WidgetTheme, default_theme}, }; @@ -30,16 +30,6 @@ impl Default for SelectionDemo { } impl SelectionDemo { - /// Toggles the animated rainbow when its control is activated. - fn event(&mut self, event: &argui::ui::UiEvent, cx: &mut Context) { - if event.target_key() == Some("selection-rainbow-toggle") - && matches!(event.kind, UiEventKind::Click(_)) - { - self.rainbow_running = !self.rainbow_running; - cx.notify(); - } - } - /// Builds the four visual highlight variants for the current animation phase. fn highlights(&self, theme: &WidgetTheme) -> Element { let default = selection_card( @@ -172,6 +162,9 @@ impl Render for SelectionDemo { ), Button::new("selection-rainbow-toggle", rainbow_label, theme.outline_button()) .enabled(!self.reduced_motion) + .on_click(cx.callback(|demo| { + demo.rainbow_running = !demo.rainbow_running; + })) .build(), ]) .width(percent(1.0)) @@ -193,7 +186,6 @@ impl Render for SelectionDemo { .gap(22.0), theme, ) - .on(cx.listener(EventType::Click, Self::event)) } } diff --git a/crates/argui-widget-gallery/src/pages/toast.rs b/crates/argui-widget-gallery/src/pages/toast.rs index 85ee3565..a3f72388 100644 --- a/crates/argui-widget-gallery/src/pages/toast.rs +++ b/crates/argui-widget-gallery/src/pages/toast.rs @@ -57,21 +57,9 @@ impl ToastDemo { self.error = format!("{error:?}"); } } - fn event(&mut self, event: &UiEvent, cx: &mut Context) { - if let Some(id) = (ToastHost { - key: "toasts", - state: &self.state, - close_label: "Close", - }) - .close_action(event) - { - self.state.close(id, self.origin.elapsed()); - } else if let Some((id, reason, paused)) = (ToastHost { - key: "toasts", - state: &self.state, - close_label: "Close", - }) - .pause_action(event) + fn pause_event(&mut self, event: &UiEvent, cx: &mut Context) { + if let Some((id, reason, paused)) = + ToastHost::new("toasts", &self.state, "Close").pause_action(event) { self.state.pause(&id, reason, paused, self.origin.elapsed()); } else { @@ -85,21 +73,23 @@ impl Render for ToastDemo { self.schedule(cx); let themes = shadcn(cx.environment()); let theme = themes.resolve(cx.environment().color_scheme); - ToastHost { - key: "toasts", - state: &self.state, - close_label: "Close", - } - .build(theme, &self.icons) - .on(cx.listener(EventType::Click, Self::event)) - .on(cx - .listener(EventType::PointerEnter, Self::event) - .capture(true)) - .on(cx - .listener(EventType::PointerLeave, Self::event) - .capture(true)) - .on(cx.listener(EventType::Focus, Self::event).capture(true)) - .on(cx.listener(EventType::Blur, Self::event).capture(true)) + ToastHost::new("toasts", &self.state, "Close") + .on_close(cx.value_callback(|demo, id: String| { + demo.state.close(&id, demo.origin.elapsed()); + })) + .build(theme, &self.icons) + .on(cx + .listener(EventType::PointerEnter, Self::pause_event) + .capture(true)) + .on(cx + .listener(EventType::PointerLeave, Self::pause_event) + .capture(true)) + .on(cx + .listener(EventType::Focus, Self::pause_event) + .capture(true)) + .on(cx + .listener(EventType::Blur, Self::pause_event) + .capture(true)) } } @@ -109,17 +99,19 @@ pub(crate) fn controls( cx: &mut Context, ) -> Element { let error = entity.read(|demo| demo.error.clone()); - let entity = entity.clone(); + let add = { + let entity = entity.clone(); + cx.callback(move |_| { + entity.update(|demo, cx| { + demo.add(); + cx.notify(); + }); + }) + }; Element::column([ Button::new("toast-add", "Show notification", theme.button()) - .build() - .on(cx.listener(EventType::Click, move |_, _, cx| { - entity.update(|demo, cx| { - demo.add(); - cx.notify(); - }); - cx.notify(); - })), + .on_click(add) + .build(), Element::text(error).text_style(argui::text::TextStyle { color: theme.muted_foreground, ..Default::default() diff --git a/crates/argui-widget-gallery/tests/app.rs b/crates/argui-widget-gallery/tests/app.rs index 0f9f009d..21efbe1a 100644 --- a/crates/argui-widget-gallery/tests/app.rs +++ b/crates/argui-widget-gallery/tests/app.rs @@ -1,5 +1,5 @@ use argui::{ - core::{Insets, Point, PointerEvent, PointerId, PointerPhase}, + core::{Insets, Point, PointerEvent, PointerId, PointerPhase, Rect, Size}, runtime::{Entity, LayoutSnapshot, WindowEnvironment}, ui::{ ClickEvent, Element, GestureDelivery, GestureEvent, GestureKind, GesturePhase, UiEventKind, @@ -90,6 +90,24 @@ fn find_key<'a>(element: &'a Element, key: &str) -> Option<&'a Element> { .find_map(|child| find_key(child, key)) } +/// Finds the laid-out bounds for an element identified by its stable UI key. +/// +/// * `tree` — retained UI tree containing the keyed element. +/// * `output` — layout result produced for `tree`. +/// * `key` — stable key of the element whose bounds are requested. +/// +/// # Panics +/// +/// Panics when the tree does not contain `key` or the layout output omits it. +fn layout_bounds_for_key(tree: &UiTree, output: &argui::layout::LayoutOutput, key: &str) -> Rect { + output + .nodes + .iter() + .find(|node| tree.key(node.node) == Some(key)) + .unwrap_or_else(|| panic!("layout node with key {key:?} is missing")) + .bounds +} + #[test] fn gallery_app_builds_a_searchable_public_root() { let gallery = Entity::new(WidgetGallery::default()); @@ -111,6 +129,49 @@ fn gallery_root_respects_native_safe_area_insets() { assert_eq!(root.style.padding.bottom, length(34.0)); assert_eq!(root.style.padding.left, length(6.0)); assert!(has_key(&root, "gallery-root")); + let tree = UiTree::new(root); + assert!( + tree.resolved_quad(tree.node_ids()[0], tree.root()) + .background + .is_some() + ); +} + +#[test] +fn compact_gallery_layout_keeps_its_header_and_content_inside_native_safe_area() { + let gallery = Entity::new(WidgetGallery::default()); + let mount = gallery.mount().unwrap(); + resize_gallery(&mount, 390.0, 844.0); + let insets = Insets::new(44.0, 8.0, 24.0, 6.0); + let mut tree = UiTree::new( + mount + .render(WindowEnvironment { + safe_area_insets: insets, + ..WindowEnvironment::default() + }) + .unwrap(), + ); + let output = argui::layout::LayoutEngine::new() + .compute( + &mut tree, + &mut argui::text::TextEngine::new(), + Size::new(390.0, 844.0), + ) + .unwrap(); + + let gallery_bounds = layout_bounds_for_key(&tree, &output, "gallery-root"); + let topbar_bounds = layout_bounds_for_key(&tree, &output, "gallery-topbar"); + let mobile_header_bounds = layout_bounds_for_key(&tree, &output, "gallery-mobile-header"); + let content_bounds = layout_bounds_for_key(&tree, &output, "gallery-content-scroll"); + let safe_right = 390.0 - insets.right; + let safe_bottom = 844.0 - insets.bottom; + + assert_eq!(gallery_bounds.origin, Point::new(insets.left, insets.top)); + assert!(gallery_bounds.origin.x + gallery_bounds.size.width <= safe_right); + assert!(gallery_bounds.origin.y + gallery_bounds.size.height <= safe_bottom); + assert_eq!(topbar_bounds.origin.y, insets.top); + assert!(mobile_header_bounds.origin.y >= topbar_bounds.origin.y + topbar_bounds.size.height); + assert!(content_bounds.origin.y + content_bounds.size.height <= safe_bottom); } #[test] @@ -125,6 +186,8 @@ fn narrow_gallery_uses_a_horizontal_header_and_compact_content() { resize_gallery(&mount, 390.0, 844.0); let compact = mount.render(WindowEnvironment::default()).unwrap(); assert!(!has_key(&compact, "gallery-sidebar")); + let topbar = find_key(&compact, "gallery-topbar").unwrap(); + assert_eq!(topbar.style.padding.right, length(14.0)); let navigation = find_key(&compact, "gallery-mobile-navigation").unwrap(); assert_eq!(navigation.style.overflow.x, argui::ui::Overflow::Auto); assert_eq!(navigation.style.overflow.y, argui::ui::Overflow::Hidden); @@ -143,6 +206,8 @@ fn narrow_gallery_uses_a_horizontal_header_and_compact_content() { let desktop = mount.render(WindowEnvironment::default()).unwrap(); assert!(has_key(&desktop, "gallery-sidebar")); assert!(!has_key(&desktop, "gallery-mobile-navigation")); + let topbar = find_key(&desktop, "gallery-topbar").unwrap(); + assert_eq!(topbar.style.padding.right, length(126.0)); } fn resize_gallery(mount: &argui::runtime::Mount, width: f32, height: f32) { diff --git a/crates/argui-widgets/src/accordion.rs b/crates/argui-widgets/src/accordion.rs index 84d23f40..68980657 100644 --- a/crates/argui-widgets/src/accordion.rs +++ b/crates/argui-widgets/src/accordion.rs @@ -1,5 +1,5 @@ use crate::{ChoiceMode, Collapsible, WidgetTheme, choice_navigation::navigate}; -use argui_ui::{Element, Orientation, Role, Semantics, UiEvent}; +use argui_ui::{Element, EventType, Orientation, Role, Semantics, UiEvent, ValueHandler}; #[derive(Clone, Debug)] pub struct AccordionItem { @@ -38,6 +38,7 @@ pub struct Accordion { pub items: Vec, pub mode: ChoiceMode, pub collapsible: bool, + change_handlers: Vec>>, } impl Accordion { @@ -50,9 +51,17 @@ impl Accordion { items: items.into_iter().collect(), mode: ChoiceMode::Single, collapsible: true, + change_handlers: Vec::new(), } } + /// Adds a callback receiving the stable IDs that should remain open. + #[must_use] + pub fn on_change(mut self, handler: ValueHandler>) -> Self { + self.change_handlers.push(handler); + self + } + fn disclosure(&self, item: &AccordionItem) -> Collapsible { Collapsible::new( format!("{}::item::{}", self.key, item.id), @@ -112,17 +121,46 @@ impl Accordion { /// Builds the accordion using `theme` for its item styling. pub fn build(&self, theme: &WidgetTheme) -> Element { Element::column(self.items.iter().map(|item| { - self.disclosure(item) + let mut disclosure = self + .disclosure(item) .indicator(Element::text(if item.open { "−" } else { "+" }).text_style( argui_text::TextStyle { color: theme.foreground, ..Default::default() }, )) - .build(theme) + .build(theme); + if item.enabled { + let next = self.selection_after_toggle(item); + for handler in &self.change_handlers { + disclosure.children[0] = disclosure.children[0] + .clone() + .on(handler.direct_listener_value(EventType::Click, next.clone())); + } + } + disclosure })) .keyed(&self.key) .gap(8.0) .semantics(Semantics::new(Role::Group)) } + + fn selection_after_toggle(&self, item: &AccordionItem) -> Vec { + if item.open && (!self.collapsible && self.mode == ChoiceMode::Single) { + return vec![item.id.clone()]; + } + let mut selected = if self.mode == ChoiceMode::Multiple { + self.items + .iter() + .filter(|candidate| candidate.open && candidate.id != item.id) + .map(|candidate| candidate.id.clone()) + .collect::>() + } else { + Vec::new() + }; + if !item.open { + selected.push(item.id.clone()); + } + selected + } } diff --git a/crates/argui-widgets/src/alert_dialog.rs b/crates/argui-widgets/src/alert_dialog.rs index 98f6cd1f..a34ca24e 100644 --- a/crates/argui-widgets/src/alert_dialog.rs +++ b/crates/argui-widgets/src/alert_dialog.rs @@ -1,5 +1,5 @@ use crate::{Button, ButtonBehavior, Dialog, DialogAction, DialogBehavior, WidgetTheme}; -use argui_ui::{Element, InitialFocus, UiEvent}; +use argui_ui::{Element, EventHandler, EventType, InitialFocus, UiEvent, ValueHandler}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AlertDialogAction { @@ -19,6 +19,9 @@ pub struct AlertDialog { pub cancel_label: String, pub confirm_label: String, pub confirm_enabled: bool, + open_handlers: Vec>, + cancel_handlers: Vec, + confirm_handlers: Vec, } impl AlertDialog { @@ -43,9 +46,33 @@ impl AlertDialog { cancel_label: "Cancel".into(), confirm_label: "Continue".into(), confirm_enabled: true, + open_handlers: Vec::new(), + cancel_handlers: Vec::new(), + confirm_handlers: Vec::new(), } } + /// Adds a callback receiving the requested controlled open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + + /// Adds a callback for cancellation through the button or Escape. + #[must_use] + pub fn on_cancel(mut self, handler: EventHandler) -> Self { + self.cancel_handlers.push(handler); + self + } + + /// Adds a callback for confirmation while the confirm button is enabled. + #[must_use] + pub fn on_confirm(mut self, handler: EventHandler) -> Self { + self.confirm_handlers.push(handler); + self + } + #[must_use] /// Interprets `event` as opening, cancelling or confirming this dialog. pub fn action(&self, event: &UiEvent) -> Option { @@ -73,6 +100,28 @@ impl AlertDialog { let close = format!("{}::close", self.key); let title_key = format!("{}::title", self.key); let description_key = format!("{}::description", self.key); + let mut cancel = Button::new(&close, self.cancel_label, theme.outline_button()).build(); + for handler in &self.cancel_handlers { + cancel = cancel.on(handler.direct_listener(EventType::Click)); + } + for handler in &self.open_handlers { + cancel = cancel.on(handler.direct_listener_value(EventType::Click, false)); + } + let mut confirm = Button::new( + format!("{}::confirm", self.key), + self.confirm_label, + theme.destructive_button(), + ) + .enabled(self.confirm_enabled) + .build(); + if self.confirm_enabled { + for handler in &self.confirm_handlers { + confirm = confirm.on(handler.direct_listener(EventType::Click)); + } + for handler in &self.open_handlers { + confirm = confirm.on(handler.direct_listener_value(EventType::Click, false)); + } + } let content = Element::column([ crate::Typography::new(&self.title, crate::TypographyVariant::Heading(2)) .build(theme) @@ -80,24 +129,21 @@ impl AlertDialog { crate::Typography::new(self.description, crate::TypographyVariant::Paragraph) .build(theme) .keyed(&description_key), - Element::row([ - Button::new(&close, self.cancel_label, theme.outline_button()).build(), - Button::new( - format!("{}::confirm", self.key), - self.confirm_label, - theme.destructive_button(), - ) - .enabled(self.confirm_enabled) - .build(), - ]) - .gap(8.0) - .flex_wrap(argui_ui::FlexWrap::Wrap), + Element::row([cancel, confirm]) + .gap(8.0) + .flex_wrap(argui_ui::FlexWrap::Wrap), ]) .gap(16.0); - let mut root = Dialog::new(self.key, self.title, self.open, self.trigger, content) + let mut dialog = Dialog::new(self.key, self.title, self.open, self.trigger, content) .alert(true) - .initial_focus(InitialFocus::Target(close.into())) - .build(theme); + .initial_focus(InitialFocus::Target(close.into())); + for handler in self.open_handlers { + dialog = dialog.on_open_change(handler); + } + for handler in self.cancel_handlers { + dialog = dialog.on_dismiss(handler); + } + let mut root = dialog.build(theme); if let Some(overlay) = root.children.get_mut(1) { let panel = &mut overlay.children[1]; *panel = panel diff --git a/crates/argui-widgets/src/breadcrumb.rs b/crates/argui-widgets/src/breadcrumb.rs index bde2791a..4802b442 100644 --- a/crates/argui-widgets/src/breadcrumb.rs +++ b/crates/argui-widgets/src/breadcrumb.rs @@ -1,7 +1,7 @@ use argui_text::{TextStyle, TextWrap}; use argui_ui::{ - AlignItems, Element, FlexWrap, KeyboardActivation, Role, Semantics, Sides, UiEvent, auto, - length, percent, + AlignItems, Element, EventType, FlexWrap, KeyboardActivation, Role, Semantics, Sides, UiEvent, + ValueHandler, auto, length, percent, }; use crate::{Button, ButtonBehavior, WidgetTheme}; @@ -34,6 +34,7 @@ pub struct Breadcrumb { current: String, current_description: String, separator: String, + activate_handlers: Vec>, } impl Breadcrumb { @@ -59,6 +60,7 @@ impl Breadcrumb { current: current.into(), current_description: "Current page".into(), separator: "/".into(), + activate_handlers: Vec::new(), } } @@ -84,6 +86,13 @@ impl Breadcrumb { self } + /// Adds a callback receiving the activated ancestor's stable ID. + #[must_use] + pub fn on_activate(mut self, handler: ValueHandler) -> Self { + self.activate_handlers.push(handler); + self + } + #[must_use] /// Returns the element key for the link identified by `id`. pub fn link_key(&self, id: &str) -> String { @@ -135,6 +144,10 @@ impl Breadcrumb { if let Some(interaction) = &mut button.interaction { interaction.keyboard_activation = KeyboardActivation::Enter; } + for handler in &self.activate_handlers { + button = + button.on(handler.direct_listener_value(EventType::Click, link.id.clone())); + } children.push( Element::row([ button, diff --git a/crates/argui-widgets/src/button.rs b/crates/argui-widgets/src/button.rs index 870df561..cd8536cc 100644 --- a/crates/argui-widgets/src/button.rs +++ b/crates/argui-widgets/src/button.rs @@ -1,8 +1,8 @@ use argui_paint::PaintStyle; use argui_text::{TextStyle, TextWrap}; use argui_ui::{ - AlignItems, Display, Element, FlexDirection, JustifyContent, LayoutStyle, StylePatch, - StyleTransition, VisualState, + AlignItems, Display, Element, EventHandler, EventType, FlexDirection, JustifyContent, + LayoutStyle, StylePatch, StyleTransition, VisualState, }; use crate::{ButtonBehavior, ButtonPart}; @@ -100,6 +100,7 @@ pub struct Button { loading: Option, content: Option, tooltip: Option, + click_handlers: Vec, } impl Button { @@ -119,6 +120,7 @@ impl Button { enabled: true, loading: None, content: None, + click_handlers: Vec::new(), } } @@ -189,6 +191,18 @@ impl Button { self } + /// Adds a callback for this button's own activation. + /// + /// `handler` is normally created with `Context::callback` or + /// `Context::event_handler`. Handlers run in declaration order and use the + /// normal click propagation pipeline. Activation bubbling from interactive + /// descendants does not invoke them. + #[must_use] + pub fn on_click(mut self, handler: EventHandler) -> Self { + self.click_handlers.push(handler); + self + } + #[must_use] /// Builds the button element with its configured style and behavior. pub fn build(self) -> Element { @@ -220,6 +234,9 @@ impl Button { if let Some(focused) = self.style.focused { element = element.when(VisualState::FocusVisible, focused); } + for handler in self.click_handlers { + element = element.on(handler.direct_listener(EventType::Click)); + } element.tooltip = self.tooltip.filter(|_| self.enabled && !loading); element } diff --git a/crates/argui-widgets/src/calendar.rs b/crates/argui-widgets/src/calendar.rs index 0d14d69e..5103f5f4 100644 --- a/crates/argui-widgets/src/calendar.rs +++ b/crates/argui-widgets/src/calendar.rs @@ -3,8 +3,8 @@ use argui_core::{Key, KeyState}; use argui_paint::{Border, CornerRadii}; use argui_text::{TextStyle, TextWrap}; use argui_ui::{ - AlignItems, Element, FocusPolicy, GridPosition, JustifyContent, Role, SemanticState, Semantics, - Sides, UiEvent, UiEventKind, length, percent, + AlignItems, Element, EventType, FocusPolicy, GridPosition, JustifyContent, Role, SemanticState, + Semantics, Sides, UiEvent, UiEventKind, ValueHandler, length, percent, }; use time::{Date, Duration, Weekday}; @@ -65,6 +65,7 @@ pub struct Calendar<'a> { pub today: Date, pub locale: &'a dyn CalendarLocale, pub constraints: CalendarConstraints<'a>, + select_handlers: Vec>, } impl<'a> Calendar<'a> { @@ -85,9 +86,19 @@ impl<'a> Calendar<'a> { today, locale: &IsoCalendarLocale, constraints: CalendarConstraints::default(), + select_handlers: Vec::new(), } } + /// Adds a handler that receives the selected date in ISO `YYYY-MM-DD` form. + /// + /// `handler` is additive and is not delivered for constrained or disabled days. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + /// Returns the interaction key used for `date`'s day cell. pub fn day_key(&self, date: Date) -> String { format!("{}::day::{date}", self.key) @@ -178,6 +189,12 @@ impl<'a> Calendar<'a> { let mut element = Button::new(self.day_key(date), self.locale.format(date), style) .enabled(enabled) .build(); + if enabled { + for handler in &self.select_handlers { + element = element + .on(handler.direct_listener_value(EventType::Click, date.to_string())); + } + } element.children = vec![day(date, date == self.today, selected).semantic_hidden(true)]; if let Some(interaction) = &mut element.interaction { diff --git a/crates/argui-widgets/src/carousel.rs b/crates/argui-widgets/src/carousel.rs index a9ae197b..36a2638b 100644 --- a/crates/argui-widgets/src/carousel.rs +++ b/crates/argui-widgets/src/carousel.rs @@ -1,8 +1,9 @@ use crate::{Button, ButtonBehavior, WidgetTheme}; use argui_core::{Key, KeyState}; use argui_ui::{ - Element, FocusPolicy, GestureKind, GesturePhase, GestureSet, Interaction, LiveRegion, PanAxis, - PanGesture, Role, Semantics, UiEvent, UiEventKind, + Element, EventFilter, EventType, FocusPolicy, GestureKind, GesturePhase, GestureSet, + Interaction, LiveRegion, PanAxis, PanGesture, Role, Semantics, UiEvent, UiEventKind, + ValueHandler, }; /// Controlled, non-autoplaying carousel. Only the active slide contributes focus targets. @@ -16,6 +17,7 @@ pub struct Carousel { pub rtl: bool, pub previous_label: String, pub next_label: String, + select_handlers: Vec>, } impl Carousel { @@ -38,9 +40,17 @@ impl Carousel { rtl: false, previous_label: "Previous slide".into(), next_label: "Next slide".into(), + select_handlers: Vec::new(), } } + /// Adds a callback receiving the requested zero-based slide index. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + fn next(&self, forward: bool) -> Option { let count = self.slides.len(); if count < 2 { @@ -109,7 +119,7 @@ impl Carousel { } else { format!("{} / {}", selected + 1, self.slides.len()) }; - let viewport = Element::column(slide) + let mut viewport = Element::column(slide) .keyed(format!("{}::viewport", self.key)) .interaction( Interaction::default() @@ -119,14 +129,58 @@ impl Carousel { ), ) .semantics(Semantics::new(Role::Group).label(&self.label)); + for (filter, value) in [ + ( + EventFilter::HomePressed, + (!self.slides.is_empty()).then_some(0), + ), + (EventFilter::EndPressed, self.slides.len().checked_sub(1)), + (EventFilter::ArrowLeftPressed, self.next(self.rtl)), + (EventFilter::ArrowRightPressed, self.next(!self.rtl)), + (EventFilter::PanEndedLeft, self.next(!self.rtl)), + (EventFilter::PanEndedRight, self.next(self.rtl)), + ] { + if let Some(value) = value { + for handler in &self.select_handlers { + let event = if matches!( + filter, + EventFilter::PanEndedLeft | EventFilter::PanEndedRight + ) { + EventType::Gesture + } else { + EventType::Key + }; + viewport = + viewport.on(handler.direct_listener_value(event, value).filter(filter)); + } + } + } + let mut previous = Button::new( + format!("{}::previous", self.key), + &self.previous_label, + theme.outline_button(), + ) + .enabled(self.next(false).is_some()) + .build(); + if let Some(value) = self.next(false) { + for handler in &self.select_handlers { + previous = previous.on(handler.direct_listener_value(EventType::Click, value)); + } + } + let mut next = Button::new( + format!("{}::next", self.key), + &self.next_label, + theme.outline_button(), + ) + .enabled(self.next(true).is_some()) + .build(); + if let Some(value) = self.next(true) { + for handler in &self.select_handlers { + next = next.on(handler.direct_listener_value(EventType::Click, value)); + } + } let controls = Element::row([ - Button::new( - format!("{}::previous", self.key), - &self.previous_label, - theme.outline_button(), - ) - .enabled(self.next(false).is_some()) - .build(), + previous, Element::text(position.clone()) .text_style(argui_text::TextStyle { color: theme.muted_foreground, @@ -137,13 +191,7 @@ impl Carousel { .label(position) .live(LiveRegion::Polite), ), - Button::new( - format!("{}::next", self.key), - &self.next_label, - theme.outline_button(), - ) - .enabled(self.next(true).is_some()) - .build(), + next, ]) .gap(12.0) .align_items(argui_ui::AlignItems::CENTER); diff --git a/crates/argui-widgets/src/chart.rs b/crates/argui-widgets/src/chart.rs index 8edae73c..cf013286 100644 --- a/crates/argui-widgets/src/chart.rs +++ b/crates/argui-widgets/src/chart.rs @@ -1,6 +1,6 @@ use crate::{Button, ButtonBehavior, WidgetTheme}; use argui_core::{Color, Transform2D, TransformOrigin}; -use argui_ui::{Element, Role, Semantics, Sides, auto, length}; +use argui_ui::{Element, EventType, Role, Semantics, Sides, ValueHandler, auto, length}; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum ChartKind { @@ -34,6 +34,7 @@ pub struct Chart { pub kind: ChartKind, pub width: f32, pub height: f32, + activate_handlers: Vec>>, } impl Chart { @@ -55,9 +56,17 @@ impl Chart { kind: ChartKind::Bar, width: 480.0, height: 240.0, + activate_handlers: Vec::new(), } } + /// Adds a handler receiving `[series_index, category_index]` for an activated point. + #[must_use] + pub fn on_activate(mut self, handler: ValueHandler>) -> Self { + self.activate_handlers.push(handler); + self + } + /// Computes a zero-inclusive value domain after validating dimensions and series data. /// /// # Errors @@ -192,6 +201,11 @@ impl Chart { .height(length(height)) .padding(Sides::length(0.0)) .background(series.color); + for handler in &self.activate_handlers { + mark = mark + .on(handler + .direct_listener_value(EventType::Click, vec![series_index, index])); + } mark.tooltip = Some(label); marks.push(position(mark, left, top)); } diff --git a/crates/argui-widgets/src/collapsible.rs b/crates/argui-widgets/src/collapsible.rs index 59901cb8..6446c1f5 100644 --- a/crates/argui-widgets/src/collapsible.rs +++ b/crates/argui-widgets/src/collapsible.rs @@ -1,4 +1,6 @@ -use argui_ui::{Element, JustifyContent, Role, Semantics, UiEvent, length, percent}; +use argui_ui::{ + Element, EventType, JustifyContent, Role, Semantics, UiEvent, ValueHandler, length, percent, +}; use crate::{Button, ButtonBehavior, WidgetTheme}; @@ -13,6 +15,7 @@ pub struct Collapsible { trigger: Option, indicator: Option, content: Element, + open_handlers: Vec>, } impl Collapsible { @@ -35,6 +38,7 @@ impl Collapsible { trigger: None, indicator: None, content, + open_handlers: Vec::new(), } } @@ -81,6 +85,13 @@ impl Collapsible { .map(|_| !self.open) } + /// Adds a callback receiving the requested controlled open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + #[must_use] /// Builds the disclosure using `theme` for trigger styling. pub fn build(self, theme: &WidgetTheme) -> Element { @@ -115,6 +126,11 @@ impl Collapsible { if self.open { trigger = trigger.controls([content_key.clone()]); } + if self.enabled { + for handler in self.open_handlers { + trigger = trigger.on(handler.direct_listener_value(EventType::Click, !self.open)); + } + } let mut children = vec![trigger]; if self.open { children.push( diff --git a/crates/argui-widgets/src/color_picker.rs b/crates/argui-widgets/src/color_picker.rs index e3d40ed8..9d567c22 100644 --- a/crates/argui-widgets/src/color_picker.rs +++ b/crates/argui-widgets/src/color_picker.rs @@ -5,6 +5,7 @@ mod view; use argui_core::{Color, Rect}; use argui_runtime::LayoutSnapshot; +use argui_ui::ValueHandler; use crate::{RangeBehavior, RangeConfig, RangeState, WidgetTheme}; @@ -147,6 +148,7 @@ pub struct ColorPicker<'a> { key: String, label: String, state: &'a ColorPickerState, + change_handlers: Vec>, } impl<'a> ColorPicker<'a> { @@ -162,9 +164,20 @@ impl<'a> ColorPicker<'a> { key: key.into(), label: label.into(), state, + change_handlers: Vec::new(), } } + /// Adds a handler that receives the next color produced by editing the picker. + /// + /// Pointer, keyboard, semantic, and valid text edits use the same controlled + /// callback path. Invalid drafts do not invoke `handler`. + #[must_use] + pub fn on_change(mut self, handler: ValueHandler) -> Self { + self.change_handlers.push(handler); + self + } + #[must_use] /// Builds the picker using `theme` for controls and text. pub fn build(self, theme: &WidgetTheme) -> argui_ui::Element { diff --git a/crates/argui-widgets/src/color_picker/view.rs b/crates/argui-widgets/src/color_picker/view.rs index 9490c166..d86b33bb 100644 --- a/crates/argui-widgets/src/color_picker/view.rs +++ b/crates/argui-widgets/src/color_picker/view.rs @@ -2,9 +2,9 @@ use argui_core::{Color, ColorInterpolation, Point}; use argui_paint::{BilinearGradient, CornerRadii, Fill, GradientStop, LinearGradient}; use argui_text::TextStyle; use argui_ui::{ - AlignItems, Element, FocusPolicy, GestureCapture, GestureDelivery, GestureSet, Interaction, - PanGesture, Role, SemanticState, Semantics, Sides, StylePatch, UserSelect, VisualState, length, - percent, + AlignItems, ColorHandlerValue, ColorValueFormat, ContinuousValuePhase, Element, EventType, + FocusPolicy, GestureCapture, GestureDelivery, GestureSet, Interaction, PanGesture, Role, + SemanticState, Semantics, Sides, StylePatch, UserSelect, VisualState, length, percent, }; use super::{ColorFormat, ColorPicker, paint::Decoration, values::hsv_to_rgb}; @@ -41,7 +41,7 @@ impl ColorPicker<'_> { let mut style = theme.input(); style.layout.padding = Sides::length(6.0); style.text.font_size = 12.0; - let field = Input::new( + let mut field = Input::new( format!("{}::field::{index}", self.key), draft.map_or(value, |draft| draft.1.clone()), "", @@ -57,6 +57,25 @@ impl ColorPicker<'_> { .enabled(self.state.enabled) .build() .min_width(length(0.0)); + if self.state.enabled { + let format = match self.state.format { + ColorFormat::Hex => ColorValueFormat::Hex, + ColorFormat::Rgb => ColorValueFormat::Rgb, + ColorFormat::Hsl => ColorValueFormat::Hsl, + ColorFormat::Hsv => ColorValueFormat::Hsv, + }; + let source = + ColorHandlerValue::field(self.state.hsv, self.state.alpha, format, index); + for handler in &self.change_handlers { + field = field + .on(handler + .direct_listener(EventType::Input) + .color_handler_value(source)) + .on(handler + .direct_listener(EventType::Submit) + .color_handler_value(source)); + } + } Element::column([label_text(label, theme.muted_foreground, 11.0), field]) .gap(4.0) .grow(1.0) @@ -109,7 +128,7 @@ impl ColorPicker<'_> { let [h, s, v] = self.state.hsv; let [r, g, b] = hsv_to_rgb([h, 1.0, 1.0]); let marker = marker(self.state.color(), 14.0, s, 1.0 - v); - Element::container([marker]).keyed(format!("{}::pad", self.key)) + let mut pad = Element::container([marker]).keyed(format!("{}::pad", self.key)) .width(percent(1.0)).height(length(160.0)) .fill(Fill::Bilinear(BilinearGradient::new( [Color::WHITE, Color::srgb(r, g, b), Color::BLACK, Color::BLACK], ColorInterpolation::Srgb, @@ -123,7 +142,20 @@ impl ColorPicker<'_> { .label(format!("Saturation {:.0}%, brightness {:.0}%", s * 100.0, v * 100.0)) .description("Left/right changes saturation; up/down changes brightness. Shift adjusts by 0.1%. Color channels are also editable below.") .state(SemanticState { disabled: !self.state.enabled, ..SemanticState::default() })) - .when(VisualState::FocusVisible, StylePatch::new().set(argui_ui::property::BorderColor, theme.ring).set(argui_ui::property::BorderWidths, [2.0; 4])) + .when(VisualState::FocusVisible, StylePatch::new().set(argui_ui::property::BorderColor, theme.ring).set(argui_ui::property::BorderWidths, [2.0; 4])); + if self.state.enabled { + let source = ColorHandlerValue::pad(self.state.hsv, self.state.alpha); + for handler in &self.change_handlers { + pad = pad + .on(handler + .direct_listener(EventType::Gesture) + .color_handler_value(source)) + .on(handler + .direct_listener(EventType::Key) + .color_handler_value(source)); + } + } + pad } fn track( @@ -168,7 +200,29 @@ impl ColorPicker<'_> { .set(argui_ui::property::BorderWidths, [2.0; 4]), ), ); - behavior.decorate(RangePart::Root, control) + let mut control = behavior.decorate(RangePart::Root, control); + if self.state.enabled { + for handler in &self.change_handlers { + for phase in [ContinuousValuePhase::Change, ContinuousValuePhase::Commit] { + let source = if alpha { + ColorHandlerValue::alpha(self.state.hsv, self.state.alpha, phase) + } else { + ColorHandlerValue::hue(self.state.hsv, self.state.alpha, phase) + }; + control = control + .on(handler + .direct_listener(EventType::Gesture) + .color_handler_value(source)) + .on(handler + .direct_listener(EventType::Key) + .color_handler_value(source)) + .on(handler + .direct_listener(EventType::SemanticAction) + .color_handler_value(source)); + } + } + } + control } } diff --git a/crates/argui-widgets/src/combobox.rs b/crates/argui-widgets/src/combobox.rs index e651d9ef..66a9b15b 100644 --- a/crates/argui-widgets/src/combobox.rs +++ b/crates/argui-widgets/src/combobox.rs @@ -1,8 +1,9 @@ use crate::{Button, Input, InputKind, SelectOption, WidgetTheme, choice_navigation::navigate}; use argui_core::{Key, KeyState}; use argui_ui::{ - AnchorWidth, DismissPolicy, Element, FloatingPlacement, FocusPolicy, Orientation, Placement, - Role, Semantics, UiEvent, UiEventKind, WindowLayer, length, + AnchorWidth, DismissPolicy, Element, EventFilter, EventType, FloatingPlacement, FocusPolicy, + Orientation, Placement, Role, Semantics, UiEvent, UiEventKind, ValueHandler, WindowLayer, + length, }; #[derive(Clone, Debug, Eq, PartialEq)] @@ -27,6 +28,9 @@ pub struct Combobox { pub open: bool, pub enabled: bool, pub empty_label: String, + input_handlers: Vec>, + select_handlers: Vec>, + open_handlers: Vec>, } impl Combobox { @@ -49,9 +53,33 @@ impl Combobox { open: false, enabled: true, empty_label: "No results".into(), + input_handlers: Vec::new(), + select_handlers: Vec::new(), + open_handlers: Vec::new(), } } + /// Adds a callback receiving the edited query. + #[must_use] + pub fn on_input(mut self, handler: ValueHandler) -> Self { + self.input_handlers.push(handler); + self + } + + /// Adds a callback receiving an enabled option's source index. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + + /// Adds a callback receiving the requested popup open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + #[must_use] /// Returns source indices whose labels contain the current query, ignoring case. pub fn visible_indices(&self) -> Vec { @@ -154,11 +182,39 @@ impl Combobox { let visible = self.visible_indices(); let active = self.active(&visible); let list_key = format!("{}::list", self.key); - let mut input = Input::new(&self.key, &self.query, &self.label, theme.input()) + let mut input_builder = Input::new(&self.key, &self.query, &self.label, theme.input()) .kind(InputKind::Search) .label(&self.label) - .enabled(self.enabled) - .build(); + .enabled(self.enabled); + for handler in &self.input_handlers { + input_builder = input_builder.on_input(*handler); + } + let mut input = input_builder.build(); + if self.enabled { + for handler in &self.open_handlers { + input = input + .on(handler.direct_listener_value(EventType::Click, true)) + .on(handler + .direct_listener_value(EventType::Key, true) + .filter(EventFilter::VerticalArrowPressed)); + if open { + input = input + .on(handler + .direct_listener_value(EventType::Key, false) + .filter(EventFilter::EscapePressed)) + .on(handler + .direct_listener_value(EventType::Key, false) + .filter(EventFilter::TabPressed)); + } + } + if open && let Some(index) = active { + for handler in &self.select_handlers { + input = input.on(handler + .direct_listener_value(EventType::Key, index) + .filter(EventFilter::EnterPressed)); + } + } + } let semantics = input.semantics.as_mut().expect("input semantics"); semantics.role = Role::ComboBox; semantics.popup = Some(argui_ui::PopupKind::ListBox); @@ -192,6 +248,9 @@ impl Combobox { let semantics = row.semantics.as_mut().expect("option semantics"); semantics.role = Role::Option; semantics.state.selected = self.selected == Some(*index); + for handler in &self.select_handlers { + row = row.on(handler.direct_listener_value(EventType::Click, *index)); + } row }) .collect(); @@ -229,6 +288,11 @@ impl Combobox { .as_mut() .expect("scroll area interaction") .focus_policy = FocusPolicy::None; + for handler in &self.open_handlers { + panel = panel + .on(handler.direct_listener_value(EventType::PointerOutside, false)) + .on(handler.direct_listener_value(EventType::Dismiss, false)); + } panel }); Element::column(std::iter::once(input).chain(panel)) diff --git a/crates/argui-widgets/src/command_palette.rs b/crates/argui-widgets/src/command_palette.rs index 6f4b414f..3cf15fe7 100644 --- a/crates/argui-widgets/src/command_palette.rs +++ b/crates/argui-widgets/src/command_palette.rs @@ -1,12 +1,13 @@ use crate::{Input, InputKind, Menu, MenuItem, MenuResponse, WidgetTheme}; use argui_core::{Key, KeyState}; -use argui_ui::{Element, UiEvent, UiEventKind}; +use argui_ui::{Element, UiEvent, UiEventKind, ValueHandler}; /// Searchable action menu. Query and visibility stay owned by the application. #[derive(Clone, Debug)] pub struct CommandPalette { menu: Menu, query: String, + input_handlers: Vec>, } impl CommandPalette { @@ -30,6 +31,7 @@ impl CommandPalette { Self { menu: Menu::new(key, "Commands", open, items), query, + input_handlers: Vec::new(), } } #[must_use] @@ -38,6 +40,27 @@ impl CommandPalette { self.menu = self.menu.presence(presence); self } + + /// Adds a callback receiving the edited search query. + #[must_use] + pub fn on_input(mut self, handler: ValueHandler) -> Self { + self.input_handlers.push(handler); + self + } + + /// Adds a callback receiving the stable ID of an activated command. + #[must_use] + pub fn on_action(mut self, handler: ValueHandler) -> Self { + self.menu = self.menu.on_action(handler); + self + } + + /// Adds a callback receiving the requested palette open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.menu = self.menu.on_open_change(handler); + self + } #[must_use] /// Returns the state key for the search input. pub fn query_key(&self) -> String { @@ -46,18 +69,21 @@ impl CommandPalette { #[must_use] /// Builds the palette around `trigger`, using `theme` for its controls. pub fn build(&self, trigger: Element, theme: &WidgetTheme) -> Element { + let mut input = Input::new( + self.query_key(), + &self.query, + "Search commands…", + theme.input(), + ) + .kind(InputKind::Search) + .label("Search commands"); + for handler in &self.input_handlers { + input = input.on_input(*handler); + } self.menu.with_content( trigger, Element::column([ - Input::new( - self.query_key(), - &self.query, - "Search commands…", - theme.input(), - ) - .kind(InputKind::Search) - .label("Search commands") - .build(), + input.build(), if self.menu.items.is_empty() { Element::text("No matching commands").text_style(theme.ghost_button().label) } else { diff --git a/crates/argui-widgets/src/context_menu.rs b/crates/argui-widgets/src/context_menu.rs index b4b150cf..1f69d6ea 100644 --- a/crates/argui-widgets/src/context_menu.rs +++ b/crates/argui-widgets/src/context_menu.rs @@ -1,6 +1,6 @@ use crate::{Menu, MenuResponse, WidgetTheme}; use argui_core::{Key, KeyState, Point, Rect, Size}; -use argui_ui::{Element, FloatingPlacement, PortalTarget, UiEvent, UiEventKind}; +use argui_ui::{Element, FloatingPlacement, PortalTarget, UiEvent, UiEventKind, ValueHandler}; /// Context-menu presentation reuses the same entries and keyboard policy as Menu. pub struct ContextMenu { @@ -9,6 +9,20 @@ pub struct ContextMenu { } impl ContextMenu { + /// Adds a handler that receives the stable id of an activated menu item. + #[must_use] + pub fn on_action(mut self, handler: ValueHandler) -> Self { + self.menu = self.menu.on_action(handler); + self + } + + /// Adds a handler receiving the requested menu open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.menu = self.menu.on_open_change(handler); + self + } + /// Sets the surface policy used by the context menu's overlay. #[must_use] pub fn surface(mut self, surface: argui_ui::OverlaySurface) -> Self { diff --git a/crates/argui-widgets/src/data_table.rs b/crates/argui-widgets/src/data_table.rs index 6afe4d67..87ec3cd0 100644 --- a/crates/argui-widgets/src/data_table.rs +++ b/crates/argui-widgets/src/data_table.rs @@ -3,8 +3,9 @@ use argui_core::{Key, KeyState}; use argui_paint::{Border, BorderWidths, CornerRadii, QuadStyle}; use argui_text::{TextStyle, TextWrap}; use argui_ui::{ - Element, FocusPolicy, GestureSet, GridPosition, Interaction, Role, SemanticState, Semantics, - SortDirection, TapGesture, UiEvent, UiEventKind, VirtualList, length, + Element, EventFilter, EventType, FocusPolicy, GestureSet, GridPosition, Interaction, Role, + SemanticState, Semantics, SortDirection, TapGesture, UiEvent, UiEventKind, ValueHandler, + VirtualList, length, }; mod model; @@ -24,7 +25,7 @@ pub enum DataTableAction { CommitEdit { next: Option }, Sort(Vec), SelectPage(bool), - ResizeColumn { column: String, event: UiEvent }, + ResizeColumn { column: String, event: Box }, } pub struct DataTable<'a, R> { @@ -35,6 +36,8 @@ pub struct DataTable<'a, R> { pub offset: f32, pub select_page_label: String, icons: Option<&'a crate::WidgetAssets>, + select_handlers: Vec>, + activate_handlers: Vec>>, } impl<'a, R> DataTable<'a, R> { @@ -57,8 +60,24 @@ impl<'a, R> DataTable<'a, R> { offset, select_page_label: "Select page".into(), icons: None, + select_handlers: Vec::new(), + activate_handlers: Vec::new(), } } + + /// Adds a handler that receives the stable id of a selected row. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + + /// Adds a handler receiving `[row_id, column_id]` for an activated cell. + #[must_use] + pub fn on_activate(mut self, handler: ValueHandler>) -> Self { + self.activate_handlers.push(handler); + self + } /// Supplies vector assets for built-in table controls. /// `icons` provides the assets used to render those controls. pub fn icons(mut self, icons: &'a crate::WidgetAssets) -> Self { @@ -278,6 +297,19 @@ impl<'a, R> DataTable<'a, R> { if active { cell = cell.border(Border::all(1.0, theme.ring)); } + for handler in &self.select_handlers { + cell = cell + .on(handler + .direct_listener_value(EventType::Click, address.row.clone())); + } + for handler in &self.activate_handlers { + cell = cell.on(handler + .direct_listener_value( + EventType::Click, + vec![address.row.clone(), address.column.clone()], + ) + .filter(EventFilter::DoubleClick)); + } cell })) .min_height(length(self.heights.estimated_extent())) @@ -362,7 +394,7 @@ impl<'a, R> DataTable<'a, R> { { return Some(DataTableAction::ResizeColumn { column: column.into(), - event: event.clone(), + event: Box::new(event.clone()), }); } if let Some(ToggleAction::SetChecked(checked)) = ToggleBehavior::new( diff --git a/crates/argui-widgets/src/date_picker.rs b/crates/argui-widgets/src/date_picker.rs index 85a52d57..5b60af0b 100644 --- a/crates/argui-widgets/src/date_picker.rs +++ b/crates/argui-widgets/src/date_picker.rs @@ -3,7 +3,9 @@ use crate::{ Input, IsoCalendarLocale, Popover, PopoverAction, PopoverBehavior, WidgetTheme, }; use argui_core::{Key, KeyState}; -use argui_ui::{Element, FocusTarget, LiveRegion, Role, Semantics, UiEvent, UiEventKind}; +use argui_ui::{ + Element, FocusTarget, LiveRegion, Role, Semantics, UiEvent, UiEventKind, ValueHandler, +}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct DatePickerState { @@ -89,6 +91,9 @@ pub struct DatePicker<'a> { pub unavailable_message: &'a str, icon: Option, surface: Option, + input_handlers: Vec>, + select_handlers: Vec>, + open_handlers: Vec>, } impl<'a> DatePicker<'a> { @@ -110,9 +115,33 @@ impl<'a> DatePicker<'a> { unavailable_message: "This date is unavailable", icon: None, surface: None, + input_handlers: Vec::new(), + select_handlers: Vec::new(), + open_handlers: Vec::new(), } } + /// Adds a handler that receives each edited draft string. + #[must_use] + pub fn on_input(mut self, handler: ValueHandler) -> Self { + self.input_handlers.push(handler); + self + } + + /// Adds a handler that receives a selected date in ISO `YYYY-MM-DD` form. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + + /// Adds a handler receiving the requested popup open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + #[must_use] /// Sets the overlay surface policy for the calendar popup. pub fn surface(mut self, surface: argui_ui::OverlaySurface) -> Self { @@ -146,25 +175,32 @@ impl<'a> DatePicker<'a> { maximum: self.constraints.maximum, disabled: self.constraints.disabled, }; + for handler in &self.select_handlers { + calendar = calendar.on_select(*handler); + } calendar } /// Builds the date input and calendar popup using `theme` for styling. pub fn build(&self, theme: &WidgetTheme) -> Element { let error_key = format!("{}::error", self.key); - let mut input = Input::new( + let mut input_builder = Input::new( self.input_key(), &self.state.draft, self.locale.format(self.today), theme.input(), ) .label(&self.label) - .invalid(self.state.error.is_some()) - .build() - .width(argui_ui::length(0.0)) - .grow(1.0) - .shrink(1.0) - .min_width(argui_ui::length(0.0)); + .invalid(self.state.error.is_some()); + for handler in &self.input_handlers { + input_builder = input_builder.on_input(*handler); + } + let mut input = input_builder + .build() + .width(argui_ui::length(0.0)) + .grow(1.0) + .shrink(1.0) + .min_width(argui_ui::length(0.0)); if self.state.open { input = input.controls([format!("{}::content", self.popup_key())]); } @@ -196,6 +232,9 @@ impl<'a> DatePicker<'a> { if let Some(surface) = self.surface { popup = popup.surface(surface); } + for handler in &self.open_handlers { + popup = popup.on_open_change(*handler); + } let popup = popup.build(theme); let mut children = vec![ Element::row([input, popup]) diff --git a/crates/argui-widgets/src/dialog.rs b/crates/argui-widgets/src/dialog.rs index e1f06c62..e28e95b2 100644 --- a/crates/argui-widgets/src/dialog.rs +++ b/crates/argui-widgets/src/dialog.rs @@ -1,8 +1,8 @@ use argui_core::Color; use argui_paint::{Border, CornerRadii, Filter, PaintStyle, QuadStyle}; use argui_ui::{ - AlignItems, Display, Element, JustifyContent, Sides, ViewportPlacement, WindowLayer, length, - percent, + AlignItems, Display, Element, EventFilter, EventHandler, EventType, JustifyContent, Sides, + ValueHandler, ViewportPlacement, WindowLayer, length, percent, }; use crate::{DialogBehavior, DialogPart, WidgetTheme}; @@ -32,6 +32,8 @@ pub struct Dialog { placement: DialogPlacement, initial_focus: Option, alert: bool, + open_handlers: Vec>, + dismiss_handlers: Vec, } impl Dialog { @@ -60,6 +62,8 @@ impl Dialog { placement: DialogPlacement::Center, initial_focus: None, alert: false, + open_handlers: Vec::new(), + dismiss_handlers: Vec::new(), } } @@ -119,6 +123,20 @@ impl Dialog { self } + /// Adds a callback receiving the requested controlled open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + + /// Adds a callback for backdrop, close-control, or Escape dismissal. + #[must_use] + pub fn on_dismiss(mut self, handler: EventHandler) -> Self { + self.dismiss_handlers.push(handler); + self + } + #[must_use] /// Builds the dialog using `theme` for its default backdrop and panel styling. pub fn build(self, theme: &WidgetTheme) -> Element { @@ -126,7 +144,10 @@ impl Dialog { if let Some(focus) = self.initial_focus { behavior = behavior.initial_focus(focus); } - let trigger = behavior.decorate(DialogPart::Trigger, self.trigger); + let mut trigger = behavior.decorate(DialogPart::Trigger, self.trigger); + for handler in &self.open_handlers { + trigger = trigger.on(handler.direct_listener_value(EventType::Click, true)); + } let overlay = self.open.then(|| { let backdrop_blur = self .backdrop_blur @@ -141,7 +162,13 @@ impl Dialog { if backdrop_blur > 0.0 { backdrop = backdrop.backdrop_filter(Filter::Blur(backdrop_blur)); } - let backdrop = behavior.decorate(DialogPart::Backdrop, backdrop); + let mut backdrop = behavior.decorate(DialogPart::Backdrop, backdrop); + for handler in &self.open_handlers { + backdrop = backdrop.on(handler.direct_listener_value(EventType::Click, false)); + } + for handler in &self.dismiss_handlers { + backdrop = backdrop.on(handler.direct_listener(EventType::Click)); + } let panel_paint = self.panel_paint.unwrap_or_else(|| { PaintStyle::new( QuadStyle::solid(theme.popover) @@ -188,7 +215,7 @@ impl Dialog { } }; let panel = behavior.decorate(DialogPart::Panel, panel); - behavior.decorate( + let mut overlay = behavior.decorate( DialogPart::Overlay, Element::container([backdrop, panel]) .width(percent(1.0)) @@ -198,7 +225,26 @@ impl Dialog { .align_items(align) .justify_content(justify) .viewport_portal(WindowLayer::Modal, ViewportPlacement::fill()), - ) + ); + for handler in &self.open_handlers { + overlay = overlay + .on(handler + .listener_value(EventType::Click, false) + .target_key(behavior.close_key())) + .on(handler + .listener_value(EventType::Key, false) + .filter(EventFilter::EscapePressed)); + } + for handler in &self.dismiss_handlers { + overlay = overlay + .on(handler + .listener(EventType::Click) + .target_key(behavior.close_key())) + .on(handler + .listener(EventType::Key) + .filter(EventFilter::EscapePressed)); + } + overlay }); behavior.decorate( DialogPart::Root, diff --git a/crates/argui-widgets/src/drawer.rs b/crates/argui-widgets/src/drawer.rs index cac5f559..8ed1db89 100644 --- a/crates/argui-widgets/src/drawer.rs +++ b/crates/argui-widgets/src/drawer.rs @@ -1,8 +1,8 @@ use crate::{Button, DialogAction, Sheet, SheetSide, WidgetTheme}; use argui_core::Transform2D; use argui_ui::{ - Element, GestureCapture, GestureKind, GesturePhase, GestureSet, Interaction, PanAxis, - PanGesture, UiEvent, UiEventKind, + Element, EventHandler, GestureCapture, GestureKind, GesturePhase, GestureSet, Interaction, + PanAxis, PanGesture, UiEvent, UiEventKind, ValueHandler, }; #[derive(Clone, Copy, Debug, PartialEq)] @@ -74,6 +74,20 @@ impl Drawer { }) } + /// Adds a callback receiving the requested controlled open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.sheet = self.sheet.on_open_change(handler); + self + } + + /// Adds a callback for explicit or gesture dismissal. + #[must_use] + pub fn on_dismiss(mut self, handler: EventHandler) -> Self { + self.sheet = self.sheet.on_dismiss(handler); + self + } + #[must_use] /// Builds the drawer using `theme` for its trigger and panel styling. pub fn build(self, theme: &WidgetTheme) -> Element { diff --git a/crates/argui-widgets/src/hover_card.rs b/crates/argui-widgets/src/hover_card.rs index 7a9a66e8..b5483dac 100644 --- a/crates/argui-widgets/src/hover_card.rs +++ b/crates/argui-widgets/src/hover_card.rs @@ -1,6 +1,6 @@ use crate::{Popover, WidgetTheme}; use argui_core::{Key, KeyState, PointerKind, PointerPhase}; -use argui_ui::{Element, UiEvent, UiEventKind}; +use argui_ui::{Element, UiEvent, UiEventKind, ValueHandler}; use std::time::Duration; /// Delayed rich preview. It opens on hover or focus without moving focus into its panel. @@ -11,6 +11,7 @@ pub struct HoverCard { pub trigger: Element, pub content: Element, pub open: bool, + open_handlers: Vec>, } impl HoverCard { @@ -30,14 +31,26 @@ impl HoverCard { trigger, content, open, + open_handlers: Vec::new(), } } + /// Adds a callback receiving immediate click and dismissal open-state requests. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + #[must_use] /// Builds the hover card using `theme` for its panel styling. pub fn build(self, theme: &WidgetTheme) -> Element { - let mut root = - Popover::new(&self.key, self.label, self.open, self.trigger, self.content).build(theme); + let mut popover = + Popover::new(&self.key, self.label, self.open, self.trigger, self.content); + for handler in self.open_handlers { + popover = popover.on_open_change(handler); + } + let mut root = popover.build(theme); if self.open { root.children[0] = root.children[0] .clone() diff --git a/crates/argui-widgets/src/input.rs b/crates/argui-widgets/src/input.rs index a3ea9a2b..2ad5f235 100644 --- a/crates/argui-widgets/src/input.rs +++ b/crates/argui-widgets/src/input.rs @@ -1,8 +1,8 @@ use argui_paint::{Color, PaintStyle}; use argui_text::{TextColor, TextOverflow, TextStyle, TextWrap}; use argui_ui::{ - AlignItems, CaretStyle, Element, LayoutStyle, Role, StateSelector, StylePatch, StyleTransition, - TextEditorSpec, TextInputFilter, VisualState, percent, + AlignItems, CaretStyle, Element, EventType, LayoutStyle, Role, StateSelector, StylePatch, + StyleTransition, TextEditorSpec, TextInputFilter, ValueHandler, VisualState, percent, }; #[cfg(feature = "textarea")] @@ -117,6 +117,8 @@ pub struct Input { description: Option, invalid: bool, leading: Option<(Element, f32)>, + input_handlers: Vec>, + submit_handlers: Vec>, } #[cfg(feature = "input")] @@ -144,6 +146,8 @@ impl Input { description: None, invalid: false, leading: None, + input_handlers: Vec::new(), + submit_handlers: Vec::new(), } } @@ -196,6 +200,26 @@ impl Input { self } + /// Adds a callback receiving the newly edited controlled value. + /// + /// `handler` is normally created with `Context::input_callback`. Handlers + /// run in declaration order through the normal input event pipeline. + #[must_use] + pub fn on_input(mut self, handler: ValueHandler) -> Self { + self.input_handlers.push(handler); + self + } + + /// Adds a callback receiving the submitted value. + /// + /// `handler` is normally created with `Context::submit_callback`. Handlers + /// run in declaration order through the normal submit event pipeline. + #[must_use] + pub fn on_submit(mut self, handler: ValueHandler) -> Self { + self.submit_handlers.push(handler); + self + } + #[must_use] /// Builds the configured input element. pub fn build(self) -> Element { @@ -232,6 +256,8 @@ impl Input { label: self.label, description: self.description, invalid: self.invalid, + input_handlers: self.input_handlers, + submit_handlers: self.submit_handlers, }, self.leading, ) @@ -249,6 +275,8 @@ pub struct TextArea { scroll: ScrollConfig, enabled: bool, read_only: bool, + input_handlers: Vec>, + submit_handlers: Vec>, } #[cfg(feature = "textarea")] @@ -276,6 +304,8 @@ impl TextArea { scroll: ScrollConfig::default().propagation(ScrollPropagation::Contain), enabled: true, read_only: false, + input_handlers: Vec::new(), + submit_handlers: Vec::new(), } } @@ -307,6 +337,24 @@ impl TextArea { self } + /// Adds a callback receiving the newly edited multiline value. + /// + /// `handler` is normally created with `Context::input_callback`. + #[must_use] + pub fn on_input(mut self, handler: ValueHandler) -> Self { + self.input_handlers.push(handler); + self + } + + /// Adds a callback receiving the submitted multiline value. + /// + /// `handler` is normally created with `Context::submit_callback`. + #[must_use] + pub fn on_submit(mut self, handler: ValueHandler) -> Self { + self.submit_handlers.push(handler); + self + } + #[must_use] /// Builds the configured text area. pub fn build(self) -> Element { @@ -324,6 +372,8 @@ impl TextArea { label: None, description: None, invalid: false, + input_handlers: self.input_handlers, + submit_handlers: self.submit_handlers, }, None, ) @@ -349,6 +399,8 @@ struct EditorSpec { label: Option, description: Option, invalid: bool, + input_handlers: Vec>, + submit_handlers: Vec>, } fn editor(spec: EditorSpec, leading: Option<(Element, f32)>) -> Element { @@ -382,7 +434,13 @@ fn editor(spec: EditorSpec, leading: Option<(Element, f32)>) -> Element { spec.style.focused, ) .transition(spec.style.transition); - let editor = behavior.decorate(TextFieldPart::Editor, element); + let mut editor = behavior.decorate(TextFieldPart::Editor, element); + for handler in spec.input_handlers { + editor = editor.on(handler.direct_listener(EventType::Input)); + } + for handler in spec.submit_handlers { + editor = editor.on(handler.direct_listener(EventType::Submit)); + } let Some((leading, slot_width)) = leading else { return behavior.decorate(TextFieldPart::Root, editor); }; diff --git a/crates/argui-widgets/src/input_otp.rs b/crates/argui-widgets/src/input_otp.rs index 598b28fe..e76d0122 100644 --- a/crates/argui-widgets/src/input_otp.rs +++ b/crates/argui-widgets/src/input_otp.rs @@ -1,6 +1,6 @@ use crate::{Input, WidgetTheme}; use argui_text::{FontFamily, LetterSpacing}; -use argui_ui::{Element, ElementKind, TextInputFilter, UiEvent, UiEventKind, length}; +use argui_ui::{Element, ElementKind, TextInputFilter, UiEvent, UiEventKind, ValueHandler, length}; /// One accessible editor for a numeric verification code. Native editing handles selection, /// arrows, deletion, clipboard and undo; the engine filter enforces length before mutation. @@ -11,6 +11,7 @@ pub struct InputOtp { pub value: String, pub length: usize, pub enabled: bool, + input_handlers: Vec>, } impl InputOtp { @@ -31,6 +32,7 @@ impl InputOtp { value: value.into(), length: length.clamp(1, 16), enabled: true, + input_handlers: Vec::new(), } } @@ -54,6 +56,15 @@ impl InputOtp { && self.value.bytes().all(|byte| byte.is_ascii_digit()) } + /// Adds a callback receiving each valid edited one-time-password value. + /// + /// `handler` is normally created with `Context::input_callback`. + #[must_use] + pub fn on_input(mut self, handler: ValueHandler) -> Self { + self.input_handlers.push(handler); + self + } + #[must_use] /// Builds the input slots using `theme` for their appearance. pub fn build(&self, theme: &WidgetTheme) -> Element { @@ -70,11 +81,13 @@ impl InputOtp { style.text.letter_spacing = LetterSpacing::Px(12.0); style.placeholder = style.text.clone(); style.placeholder.color = theme.muted_foreground; - let mut input = Input::new(&self.key, value, "·".repeat(count), style) + let mut builder = Input::new(&self.key, value, "·".repeat(count), style) .label(&self.label) - .enabled(self.enabled) - .build() - .width(length(count as f32 * 26.0 + 26.0)); + .enabled(self.enabled); + for handler in self.input_handlers.iter().copied() { + builder = builder.on_input(handler); + } + let mut input = builder.build().width(length(count as f32 * 26.0 + 26.0)); if let ElementKind::TextEditor { filter, .. } = &mut input.kind { *filter = TextInputFilter::Digits { max_length: count as u16, diff --git a/crates/argui-widgets/src/list.rs b/crates/argui-widgets/src/list.rs index 521fee3d..0da69044 100644 --- a/crates/argui-widgets/src/list.rs +++ b/crates/argui-widgets/src/list.rs @@ -1,8 +1,8 @@ use argui_core::{Key, KeyState}; use argui_paint::{Border, QuadStyle}; use argui_ui::{ - Element, GestureSet, Interaction, Role, SemanticAction, SemanticState, Semantics, TapGesture, - UiEvent, UiEventKind, UserSelect, VisualState, + Element, EventFilter, EventType, GestureSet, Interaction, Role, SemanticAction, SemanticState, + Semantics, TapGesture, UiEvent, UiEventKind, UserSelect, ValueHandler, VisualState, }; use crate::{Collection, ListState, WidgetTheme}; @@ -16,6 +16,8 @@ pub struct List<'a> { state: Option<&'a ListState>, multiple: bool, page_size: Option, + select_handlers: Vec>, + activate_handlers: Vec>, } impl<'a> List<'a> { @@ -30,6 +32,8 @@ impl<'a> List<'a> { state: None, multiple: false, page_size: None, + select_handlers: Vec::new(), + activate_handlers: Vec::new(), } } @@ -55,6 +59,26 @@ impl<'a> List<'a> { self } + /// Adds a handler that receives the stable id of a selected row. + /// + /// `handler` is called for pointer, keyboard, and accessibility selection. + /// Disabled rows never deliver the handler. Additional handlers are additive. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + + /// Adds a handler that receives the stable id of an activated row. + /// + /// `handler` is called by double-click activation. Additional handlers are + /// delivered in registration order. + #[must_use] + pub fn on_activate(mut self, handler: ValueHandler) -> Self { + self.activate_handlers.push(handler); + self + } + /// Interpret printable input using an explicitly retained search buffer and clock. /// Searches enabled items using a retained typeahead buffer and returns updated selection. /// @@ -130,6 +154,74 @@ impl<'a> List<'a> { { root = root.active_descendant(self.row_key(index)); } + if let Some(active) = self + .state + .and_then(|state| state.active.as_deref()) + .or_else(|| { + self.collection + .items() + .iter() + .find(|item| item.enabled) + .map(|item| item.id.as_str()) + }) + { + for handler in &self.select_handlers { + for filter in [EventFilter::EnterPressed, EventFilter::SpacePressed] { + root = root.on(handler + .direct_listener_value(EventType::Key, active.to_owned()) + .filter(filter)); + } + } + } + for (filter, forward) in [ + (EventFilter::ArrowUpPressed, false), + (EventFilter::ArrowDownPressed, true), + (EventFilter::HomePressed, true), + (EventFilter::EndPressed, false), + (EventFilter::PageUpPressed, false), + (EventFilter::PageDownPressed, true), + ] { + let active = self + .state + .and_then(|state| state.active.as_deref()) + .and_then(|id| self.collection.index_of(id)); + let next = if filter == EventFilter::HomePressed { + self.collection.enabled_from(0, false) + } else if filter == EventFilter::EndPressed { + self.collection + .len() + .checked_sub(1) + .and_then(|last| self.collection.enabled_from(last, true)) + } else { + let distance = if matches!( + filter, + EventFilter::PageUpPressed | EventFilter::PageDownPressed + ) { + self.page_size.unwrap_or(1) + } else { + 1 + }; + let start = if forward { + active + .unwrap_or(0) + .saturating_add(distance) + .min(self.collection.len().saturating_sub(1)) + } else { + active.unwrap_or(0).saturating_sub(distance) + }; + self.collection.enabled_from(start, !forward) + }; + if let Some(value) = next + .and_then(|index| self.collection.get(index)) + .map(|item| item.id.clone()) + { + for handler in &self.select_handlers { + root = root.on(handler + .direct_listener_value(EventType::Key, value.clone()) + .filter(filter)); + } + } + } root } @@ -152,8 +244,9 @@ impl<'a> List<'a> { if active { semantics = semantics.action(SemanticAction::Focus); } - element - .keyed(self.row_key(index)) + let row_key = self.row_key(index); + let mut element = element + .keyed(&row_key) .user_select(UserSelect::None) .interaction( Interaction::default() @@ -180,7 +273,21 @@ impl<'a> List<'a> { 1.0, if active { theme.ring } else { theme.border }, )) - .when(VisualState::Hovered, QuadStyle::solid(theme.muted).into()) + .when(VisualState::Hovered, QuadStyle::solid(theme.muted).into()); + if item.enabled { + for handler in &self.select_handlers { + element = element.on(handler + .direct_listener_value(EventType::Click, item.id.clone()) + .target_key(&row_key)); + } + for handler in &self.activate_handlers { + element = element.on(handler + .direct_listener_value(EventType::Click, item.id.clone()) + .target_key(&row_key) + .filter(EventFilter::DoubleClick)); + } + } + element } #[must_use] diff --git a/crates/argui-widgets/src/menu.rs b/crates/argui-widgets/src/menu.rs index b9347a06..c06bfec9 100644 --- a/crates/argui-widgets/src/menu.rs +++ b/crates/argui-widgets/src/menu.rs @@ -1,8 +1,8 @@ use crate::{Button, Popover, PopoverAction, PopoverBehavior, WidgetTheme}; use argui_core::{Key, KeyState}; use argui_ui::{ - ActionInvocation, Element, FocusTarget, JustifyContent, Role, Semantics, UiEvent, UiEventKind, - percent, + ActionInvocation, Element, EventType, FocusTarget, JustifyContent, Role, Semantics, UiEvent, + UiEventKind, ValueHandler, percent, }; mod intent; @@ -45,6 +45,8 @@ pub struct Menu { pub path: Vec, pub rtl: bool, icons: Option, + action_handlers: Vec>, + open_handlers: Vec>, } impl Menu { @@ -83,8 +85,24 @@ impl Menu { path: Vec::new(), rtl: false, icons: None, + action_handlers: Vec::new(), + open_handlers: Vec::new(), } } + + /// Adds a callback receiving the stable ID of an activated leaf item. + #[must_use] + pub fn on_action(mut self, handler: ValueHandler) -> Self { + self.action_handlers.push(handler); + self + } + + /// Adds a callback receiving the requested controlled open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } #[must_use] /// Sets the overlay surface policy. pub fn surface(mut self, surface: argui_ui::OverlaySurface) -> Self { @@ -129,6 +147,9 @@ impl Menu { if let Some(presence) = &self.presence { popover = popover.presence(presence); } + for handler in &self.open_handlers { + popover = popover.on_open_change(*handler); + } let mut root = popover.build(theme); if let Some(content) = root.children.get_mut(1) { content.focus_scope = Some(Self::focus_scope()); @@ -273,6 +294,19 @@ impl Menu { unreachable!("structural entries rendered above") } } + if item.enabled() + && matches!( + item.kind, + MenuItemKind::Action(_) + | MenuItemKind::Checkbox(_) + | MenuItemKind::Radio { .. } + ) + { + for handler in &self.action_handlers { + element = element + .on(handler.direct_listener_value(EventType::Click, item.id.clone())); + } + } element })) .gap(2.0) diff --git a/crates/argui-widgets/src/menubar.rs b/crates/argui-widgets/src/menubar.rs index 06801957..45968346 100644 --- a/crates/argui-widgets/src/menubar.rs +++ b/crates/argui-widgets/src/menubar.rs @@ -1,6 +1,8 @@ use crate::{Button, Menu, MenuResponse, WidgetTheme}; use argui_core::{Key, KeyState}; -use argui_ui::{Element, FocusPolicy, FocusTarget, Role, Semantics, UiEvent, UiEventKind}; +use argui_ui::{ + Element, FocusPolicy, FocusTarget, Role, Semantics, UiEvent, UiEventKind, ValueHandler, +}; #[derive(Clone, Debug, PartialEq)] pub enum MenubarResponse { @@ -15,9 +17,53 @@ pub struct Menubar<'a> { pub menus: &'a [Menu], pub active: Option<&'a str>, pub rtl: bool, + action_handlers: Vec>, + open_handlers: Vec>, } -impl Menubar<'_> { +impl<'a> Menubar<'a> { + /// Creates a controlled menubar from `menus`, identified and named by `key` and `label`. + #[must_use] + pub fn new(key: &'a str, label: &'a str, menus: &'a [Menu]) -> Menubar<'a> { + Menubar { + key, + label, + menus, + active: None, + rtl: false, + action_handlers: Vec::new(), + open_handlers: Vec::new(), + } + } + + /// Sets the stable key of the currently active top-level menu. + #[must_use] + pub fn active(mut self, active: Option<&'a str>) -> Self { + self.active = active; + self + } + + /// Sets right-to-left keyboard and visual navigation when `rtl` is true. + #[must_use] + pub fn rtl(mut self, rtl: bool) -> Self { + self.rtl = rtl; + self + } + + /// Adds a handler that receives the stable id of an activated menu item. + #[must_use] + pub fn on_action(mut self, handler: ValueHandler) -> Self { + self.action_handlers.push(handler); + self + } + + /// Adds a handler receiving requested nested-menu open states. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + /// Builds the configured menus in a horizontal menubar using `theme` for their controls. /// /// # Panics @@ -27,6 +73,12 @@ impl Menubar<'_> { Element::row(self.menus.iter().enumerate().map(|(index, menu)| { let mut menu = menu.clone(); menu.rtl = self.rtl; + for handler in &self.action_handlers { + menu = menu.on_action(*handler); + } + for handler in &self.open_handlers { + menu = menu.on_open_change(*handler); + } let trigger = Button::new(&menu.key, &menu.label, theme.ghost_button()).build(); let mut built = menu.build(trigger, theme); let trigger = &mut built.children[0]; diff --git a/crates/argui-widgets/src/message_scroller.rs b/crates/argui-widgets/src/message_scroller.rs index d35b9c5e..592723d1 100644 --- a/crates/argui-widgets/src/message_scroller.rs +++ b/crates/argui-widgets/src/message_scroller.rs @@ -1,6 +1,6 @@ use crate::{Button, ButtonBehavior, ScrollArea, WidgetTheme}; use argui_core::Point; -use argui_ui::{Element, ScrollAlignment, ScrollRequest, UiEvent, UiEventKind}; +use argui_ui::{Element, EventHandler, ScrollAlignment, ScrollRequest, UiEvent, UiEventKind}; /// Retained reading position. Supply measured maximum offsets after layout changes. #[derive(Clone, Debug, PartialEq)] @@ -102,6 +102,8 @@ pub struct MessageScroller { pub loading: bool, pub earlier_label: String, pub latest_label: String, + earlier_handlers: Vec, + latest_handlers: Vec, } impl MessageScroller { @@ -118,9 +120,25 @@ impl MessageScroller { loading: false, earlier_label: "Load earlier messages".into(), latest_label: "Jump to latest".into(), + earlier_handlers: Vec::new(), + latest_handlers: Vec::new(), } } + /// Adds a handler invoked when available earlier history is requested. + #[must_use] + pub fn on_load_earlier(mut self, handler: EventHandler) -> Self { + self.earlier_handlers.push(handler); + self + } + + /// Adds a handler invoked when the user requests the latest message. + #[must_use] + pub fn on_latest(mut self, handler: EventHandler) -> Self { + self.latest_handlers.push(handler); + self + } + #[must_use] /// Returns a message-scroller action when `event` targets its controls. pub fn action(&self, event: &UiEvent) -> Option { @@ -143,13 +161,18 @@ impl MessageScroller { /// Builds the message scroller and its controls using `theme`. pub fn build(self, theme: &WidgetTheme) -> Element { let history = self.has_earlier.then(|| { - Button::new( + let mut button = Button::new( format!("{}::earlier", self.key), - self.earlier_label, + &self.earlier_label, theme.ghost_button(), ) - .enabled(!self.loading) - .build() + .enabled(!self.loading); + if !self.loading { + for handler in &self.earlier_handlers { + button = button.on_click(*handler); + } + } + button.build() }); let viewport = ScrollArea::new( &self.key, @@ -159,12 +182,15 @@ impl MessageScroller { ) .build(theme); let latest = (!self.state.following).then(|| { - Button::new( + let mut button = Button::new( format!("{}::latest", self.key), format!("{} ({})", self.latest_label, self.state.unread), theme.outline_button(), - ) - .build() + ); + for handler in &self.latest_handlers { + button = button.on_click(*handler); + } + button.build() }); Element::column(std::iter::once(viewport).chain(latest)).gap(8.0) } diff --git a/crates/argui-widgets/src/native_select.rs b/crates/argui-widgets/src/native_select.rs index 828f2492..29cbfa77 100644 --- a/crates/argui-widgets/src/native_select.rs +++ b/crates/argui-widgets/src/native_select.rs @@ -1,5 +1,5 @@ use crate::{Select, SelectAction, SelectBehavior, SelectOption, Typeahead, WidgetTheme}; -use argui_ui::{Element, UiEvent}; +use argui_ui::{Element, UiEvent, ValueHandler}; /// Compact form select drawn by Argui on every platform; no system control or WebView. /// Uses the same controlled selection, keyboard and typeahead contract as Select. @@ -13,6 +13,8 @@ pub struct NativeSelect { pub open: bool, pub enabled: bool, pub required: bool, + select_handlers: Vec>, + open_handlers: Vec>, } impl NativeSelect { @@ -34,6 +36,8 @@ impl NativeSelect { open: false, enabled: true, required: false, + select_handlers: Vec::new(), + open_handlers: Vec::new(), } } @@ -63,6 +67,20 @@ impl NativeSelect { .flatten() } + /// Adds a callback for selection of an enabled option's source index. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + + /// Adds a callback for a requested popup open-state change. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + #[must_use] /// Builds the compact select using `theme` for its controls and popup. /// @@ -70,7 +88,7 @@ impl NativeSelect { /// /// Panics if the underlying select does not contain its expected trigger semantics. pub fn build(&self, theme: &WidgetTheme) -> Element { - let mut root = + let mut select = Select::new(&self.key, &self.label, self.options.clone(), self.selected) .open(self.open && self.enabled) .highlighted(self.highlighted) @@ -79,8 +97,14 @@ impl NativeSelect { color: theme.foreground, ..Default::default() }, - )) - .build(theme); + )); + for handler in &self.select_handlers { + select = select.on_select(*handler); + } + for handler in &self.open_handlers { + select = select.on_open_change(*handler); + } + let mut root = select.build(theme); let trigger = &mut root.children[0]; let semantics = trigger.semantics.as_mut().expect("select trigger"); semantics.role = argui_ui::Role::ComboBox; diff --git a/crates/argui-widgets/src/navigation_menu.rs b/crates/argui-widgets/src/navigation_menu.rs index 770b54a6..12ec54df 100644 --- a/crates/argui-widgets/src/navigation_menu.rs +++ b/crates/argui-widgets/src/navigation_menu.rs @@ -1,6 +1,9 @@ use crate::{Button, ButtonBehavior, Popover, WidgetTheme, choice_navigation::navigate}; use argui_core::{Key, KeyState}; -use argui_ui::{Element, FocusPolicy, Orientation, Role, Semantics, UiEvent, UiEventKind}; +use argui_ui::{ + Element, EventFilter, EventType, FocusPolicy, Orientation, Role, Semantics, UiEvent, + UiEventKind, ValueHandler, +}; #[derive(Clone, Debug)] pub struct NavigationItem { @@ -40,6 +43,8 @@ pub struct NavigationMenu { pub items: Vec, pub open: Option, pub rtl: bool, + action_handlers: Vec>, + open_handlers: Vec>, } impl NavigationMenu { @@ -57,9 +62,25 @@ impl NavigationMenu { items: items.into_iter().collect(), open: None, rtl: false, + action_handlers: Vec::new(), + open_handlers: Vec::new(), } } + /// Adds a handler receiving the stable id of an activated navigation link. + #[must_use] + pub fn on_action(mut self, handler: ValueHandler) -> Self { + self.action_handlers.push(handler); + self + } + + /// Adds a handler receiving the requested panel id, or an empty string when closing. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + #[must_use] /// Returns the element key for the item identified by `id`. pub fn item_key(&self, id: &str) -> String { @@ -136,10 +157,39 @@ impl NavigationMenu { ) .enabled(item.enabled) .build(); + let mut trigger = trigger; + if item.enabled { + let handlers = if item.panel.is_some() { + &self.open_handlers + } else { + &self.action_handlers + }; + let value = if item.panel.is_some() && self.open.as_deref() == Some(&item.id) { + String::new() + } else { + item.id.clone() + }; + for handler in handlers { + trigger = + trigger.on(handler.direct_listener_value(EventType::Click, value.clone())); + } + } if let Some(panel) = &item.panel { let open = item.enabled && self.open.as_deref() == Some(&item.id); let mut root = Popover::new(&key, &item.label, open, trigger, panel.clone()).build(theme); + if open && let Some(content) = root.children.get_mut(1) { + for handler in &self.open_handlers { + *content = content + .clone() + .on(handler + .direct_listener_value(EventType::PointerOutside, String::new())) + .on(handler.direct_listener_value(EventType::Dismiss, String::new())) + .on(handler + .listener_value(EventType::Key, String::new()) + .filter(EventFilter::EscapePressed)); + } + } // Popover owns trigger semantics; preserve the navigation item's disabled state. let trigger = &mut root.children[0]; trigger.interaction.as_mut().expect("trigger").enabled = item.enabled; diff --git a/crates/argui-widgets/src/pagination.rs b/crates/argui-widgets/src/pagination.rs index c8ba5f2a..2f65485f 100644 --- a/crates/argui-widgets/src/pagination.rs +++ b/crates/argui-widgets/src/pagination.rs @@ -1,5 +1,8 @@ use argui_text::{TextStyle, TextWrap}; -use argui_ui::{AlignItems, Element, FlexWrap, JustifyContent, Role, Semantics, UiEvent, length}; +use argui_ui::{ + AlignItems, Element, EventType, FlexWrap, JustifyContent, Role, Semantics, UiEvent, + ValueHandler, length, +}; use crate::{Button, ButtonBehavior, WidgetTheme}; @@ -33,6 +36,7 @@ pub struct Pagination { total: usize, enabled: bool, labels: PaginationLabels, + select_handlers: Vec>, } impl Pagination { @@ -45,6 +49,7 @@ impl Pagination { total, enabled: true, labels: PaginationLabels::default(), + select_handlers: Vec::new(), } } @@ -68,6 +73,13 @@ impl Pagination { self } + /// Adds a callback receiving the requested one-based page. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + #[must_use] /// Returns the interaction key for `page`. pub fn page_key(&self, page: usize) -> String { @@ -141,12 +153,17 @@ impl Pagination { #[must_use] /// Builds the pagination controls using `theme` for their appearance. pub fn build(&self, theme: &WidgetTheme) -> Element { - let mut children = vec![self.navigation_button( + let previous_enabled = self.enabled && self.page > 1; + let mut previous = self.navigation_button( self.previous_key(), &self.labels.previous, - self.enabled && self.page > 1, + previous_enabled, theme, - )]; + ); + if previous_enabled { + previous = self.attach_handlers(previous, self.page - 1); + } + let mut children = vec![previous]; for entry in self.entries() { if let Some(page) = entry { let current = page == self.page; @@ -182,6 +199,9 @@ impl Pagination { if current && let Some(semantics) = &mut button.semantics { semantics.description = Some(self.labels.current.clone()); } + if self.enabled && !current { + button = self.attach_handlers(button, page); + } children.push(button); } else { children.push( @@ -197,12 +217,13 @@ impl Pagination { ); } } - children.push(self.navigation_button( - self.next_key(), - &self.labels.next, - self.enabled && self.page < self.total, - theme, - )); + let next_enabled = self.enabled && self.page < self.total; + let mut next = + self.navigation_button(self.next_key(), &self.labels.next, next_enabled, theme); + if next_enabled { + next = self.attach_handlers(next, self.page + 1); + } + children.push(next); Element::row(children) .keyed(self.key.clone()) .semantics(Semantics::new(Role::Group).label(self.labels.navigation.clone())) @@ -224,4 +245,11 @@ impl Pagination { } Button::new(key, label, style).enabled(enabled).build() } + + fn attach_handlers(&self, mut element: Element, page: usize) -> Element { + for handler in &self.select_handlers { + element = element.on(handler.direct_listener_value(EventType::Click, page)); + } + element + } } diff --git a/crates/argui-widgets/src/popover.rs b/crates/argui-widgets/src/popover.rs index 3ab3d23a..8e00e9ab 100644 --- a/crates/argui-widgets/src/popover.rs +++ b/crates/argui-widgets/src/popover.rs @@ -1,7 +1,8 @@ use argui_paint::{Border, CornerRadii, LayerStyle, PaintStyle, QuadStyle}; use argui_ui::{ - AnchorWidth, Axes, DismissPolicy, Element, FloatingPlacement, FocusScope, InitialFocus, - Overflow, Placement, ScrollConfig, Sides, WindowLayer, length, + AnchorWidth, Axes, DismissPolicy, Element, EventFilter, EventHandler, EventType, + FloatingPlacement, FocusScope, InitialFocus, Overflow, Placement, ScrollConfig, Sides, + ValueHandler, WindowLayer, length, }; use crate::{PopoverBehavior, PopoverPart, WidgetTheme}; @@ -24,6 +25,8 @@ pub struct Popover { layer: Option, trap_focus: bool, presence: Option, + open_handlers: Vec>, + dismiss_handlers: Vec, } impl Popover { @@ -56,6 +59,8 @@ impl Popover { layer: None, trap_focus: false, presence: None, + open_handlers: Vec::new(), + dismiss_handlers: Vec::new(), } } @@ -131,11 +136,28 @@ impl Popover { self } + /// Adds a callback receiving the requested controlled open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + + /// Adds a callback for pointer-outside, native, or Escape dismissal. + #[must_use] + pub fn on_dismiss(mut self, handler: EventHandler) -> Self { + self.dismiss_handlers.push(handler); + self + } + #[must_use] /// Builds the popover using `theme` for default panel and backdrop styling. pub fn build(self, theme: &WidgetTheme) -> Element { let behavior = PopoverBehavior::new(&self.key, &self.label, self.open); - let trigger = behavior.decorate(PopoverPart::Trigger, self.trigger); + let mut trigger = behavior.decorate(PopoverPart::Trigger, self.trigger); + for handler in &self.open_handlers { + trigger = trigger.on(handler.direct_listener_value(EventType::Click, !self.open)); + } let content = (self.open || self.presence.as_ref().is_some_and(crate::Presence::visible)) .then(|| { let radius = self.radius.max(0.0); @@ -175,7 +197,23 @@ impl Popover { } else { content.focus_scope(FocusScope::restoring()) }; - let content = behavior.decorate(PopoverPart::Content, content); + let mut content = behavior.decorate(PopoverPart::Content, content); + for handler in &self.open_handlers { + content = content + .on(handler.direct_listener_value(EventType::PointerOutside, false)) + .on(handler.direct_listener_value(EventType::Dismiss, false)) + .on(handler + .listener_value(EventType::Key, false) + .filter(EventFilter::EscapePressed)); + } + for handler in &self.dismiss_handlers { + content = content + .on(handler.direct_listener(EventType::PointerOutside)) + .on(handler.direct_listener(EventType::Dismiss)) + .on(handler + .listener(EventType::Key) + .filter(EventFilter::EscapePressed)); + } match &self.presence { Some(presence) => presence.decorate(content), None => content, diff --git a/crates/argui-widgets/src/questionnaire.rs b/crates/argui-widgets/src/questionnaire.rs index 60f92d17..39fdb9b5 100644 --- a/crates/argui-widgets/src/questionnaire.rs +++ b/crates/argui-widgets/src/questionnaire.rs @@ -1,7 +1,7 @@ use crate::{ Button, ButtonBehavior, ChoiceMode, Input, Toggle, Typography, TypographyVariant, WidgetTheme, }; -use argui_ui::{Element, UiEvent, UiEventKind}; +use argui_ui::{Element, EventHandler, EventType, UiEvent, UiEventKind, ValueHandler}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Clone, Debug, Eq, PartialEq)] @@ -114,6 +114,10 @@ pub struct Questionnaire { pub submit_label: String, pub answer_label: String, pub error_label: String, + input_handlers: Vec>, + choice_handlers: Vec>>, + navigate_handlers: Vec>, + submit_handlers: Vec, } impl Questionnaire { @@ -131,9 +135,43 @@ impl Questionnaire { submit_label: "Submit".into(), answer_label: "Your answer".into(), error_label: "Choose or enter a valid answer.".into(), + input_handlers: Vec::new(), + choice_handlers: Vec::new(), + navigate_handlers: Vec::new(), + submit_handlers: Vec::new(), } } + /// Adds a handler receiving the active question's edited freeform answer. + #[must_use] + pub fn on_input(mut self, handler: ValueHandler) -> Self { + self.input_handlers.push(handler); + self + } + + /// Adds a handler receiving `[question_id, choice_id, selected]` for a choice change. + #[must_use] + pub fn on_choice(mut self, handler: ValueHandler>) -> Self { + self.choice_handlers.push(handler); + self + } + + /// Adds a handler receiving the requested controlled question index. + #[must_use] + pub fn on_navigate(mut self, handler: ValueHandler) -> Self { + self.navigate_handlers.push(handler); + self + } + + /// Adds a handler invoked when a valid final submission is requested. + /// + /// The callback reads the controlled answers from application state. + #[must_use] + pub fn on_submit(mut self, handler: EventHandler) -> Self { + self.submit_handlers.push(handler); + self + } + /// Reject duplicate identifiers and invalid retained answers before submission. #[must_use] /// Returns question identities that prevent submission due to invalid answers or duplicate ids. @@ -288,19 +326,32 @@ impl Questionnaire { answer.choices.contains(&choice.id), ); toggle.outline = true; - toggle.build(theme) + let mut element = toggle.build(theme); + for handler in &self.choice_handlers { + element = element.on(handler.direct_listener_value( + EventType::Click, + vec![ + question.id.clone(), + choice.id.clone(), + (!answer.choices.contains(&choice.id)).to_string(), + ], + )); + } + element })); if question.freeform { - let mut input = Input::new( + let mut builder = Input::new( format!("{prefix}::input"), &answer.text, &self.answer_label, theme.input(), ) .label(&self.answer_label) - .invalid(self.state.invalid.contains(&question.id)) - .build() - .labelled_by([title_key.clone()]); + .invalid(self.state.invalid.contains(&question.id)); + for handler in &self.input_handlers { + builder = builder.on_input(*handler); + } + let mut input = builder.build().labelled_by([title_key.clone()]); if self.state.invalid.contains(&question.id) { input = input.described_by([error_key.clone()]); } @@ -318,40 +369,62 @@ impl Questionnaire { ), ); } + let mut previous = Button::new( + format!("{}::previous", self.key), + &self.previous_label, + theme.outline_button(), + ) + .enabled(index > 0) + .build(); + if index > 0 { + for handler in &self.navigate_handlers { + previous = previous.on(handler.direct_listener_value(EventType::Click, index - 1)); + } + } + let next_index = (index + 1).min(self.questions.len().saturating_sub(1)); + let mut skip = Button::new( + format!("{}::skip", self.key), + &self.skip_label, + theme.ghost_button(), + ) + .enabled(!question.required) + .build(); + if !question.required { + for handler in &self.navigate_handlers { + skip = skip.on(handler.direct_listener_value(EventType::Click, next_index)); + } + } + let next = if index + 1 < self.questions.len() { + let mut next = Button::new( + format!("{}::next", self.key), + &self.next_label, + theme.button(), + ) + .build(); + if question.accepts(&answer) { + for handler in &self.navigate_handlers { + next = next.on(handler.direct_listener_value(EventType::Click, index + 1)); + } + } + next + } else { + let mut submit = Button::new( + format!("{}::submit", self.key), + &self.submit_label, + theme.button(), + ) + .build(); + if self.invalid_questions().is_empty() { + for handler in &self.submit_handlers { + submit = submit.on(handler.direct_listener(EventType::Click)); + } + } + submit + }; children.push( - Element::row([ - Button::new( - format!("{}::previous", self.key), - &self.previous_label, - theme.outline_button(), - ) - .enabled(index > 0) - .build(), - Button::new( - format!("{}::skip", self.key), - &self.skip_label, - theme.ghost_button(), - ) - .enabled(!question.required) - .build(), - if index + 1 < self.questions.len() { - Button::new( - format!("{}::next", self.key), - &self.next_label, - theme.button(), - ) - .build() - } else { - Button::new( - format!("{}::submit", self.key), - &self.submit_label, - theme.button(), - ) - .build() - }, - ]) - .gap(8.0) - .flex_wrap(argui_ui::FlexWrap::Wrap), + Element::row([previous, skip, next]) + .gap(8.0) + .flex_wrap(argui_ui::FlexWrap::Wrap), ); Element::column(children) .keyed(&self.key) diff --git a/crates/argui-widgets/src/range.rs b/crates/argui-widgets/src/range.rs index 47a3fd50..a38fe7d2 100644 --- a/crates/argui-widgets/src/range.rs +++ b/crates/argui-widgets/src/range.rs @@ -189,6 +189,18 @@ impl RangeBehavior { self.config } + /// Returns the current controlled value before event processing. + #[must_use] + pub const fn value(&self) -> f32 { + self.value + } + + /// Returns whether this range accepts interaction. + #[must_use] + pub const fn is_enabled(&self) -> bool { + self.enabled + } + #[must_use] /// Returns the layout key used for the range track. pub fn track_key(&self) -> String { diff --git a/crates/argui-widgets/src/select.rs b/crates/argui-widgets/src/select.rs index 97de6de7..db370fbc 100644 --- a/crates/argui-widgets/src/select.rs +++ b/crates/argui-widgets/src/select.rs @@ -1,9 +1,9 @@ use argui_paint::{Border, CornerRadii, PaintStyle, QuadStyle}; use argui_text::{TextStyle, TextWrap}; use argui_ui::{ - AlignItems, AnchorWidth, Axes, DismissPolicy, Element, FloatingPlacement, FocusScope, - InitialFocus, JustifyContent, Overflow, Placement, ScrollConfig, StylePatch, StyleTransition, - VisualState, WindowLayer, length, percent, + AlignItems, AnchorWidth, Axes, DismissPolicy, Element, EventFilter, EventType, + FloatingPlacement, FocusScope, InitialFocus, JustifyContent, Overflow, Placement, ScrollConfig, + StylePatch, StyleTransition, ValueHandler, VisualState, WindowLayer, length, percent, }; use crate::{SelectBehavior, SelectPart, WidgetTheme}; @@ -44,6 +44,8 @@ pub struct Select { open: bool, trailing: Option, presence: Option, + select_handlers: Vec>, + open_handlers: Vec>, } impl Select { @@ -66,6 +68,8 @@ impl Select { open: false, trailing: None, presence: None, + select_handlers: Vec::new(), + open_handlers: Vec::new(), } } @@ -105,6 +109,20 @@ impl Select { self } + /// Adds a callback for selection of an enabled option's source index. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + + /// Adds a callback for a requested popup open-state change. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + #[must_use] /// Builds the select trigger and popup using `theme` for their styling. pub fn build(self, theme: &WidgetTheme) -> Element { @@ -125,7 +143,7 @@ impl Select { Element::text(selected_label).text_style(label_style(theme.foreground)), )]; trigger_children.extend(self.trailing.clone()); - let trigger = behavior.decorate( + let mut trigger = behavior.decorate( SelectPart::Trigger, Element::row(trigger_children) .width(percent(1.0)) @@ -140,6 +158,9 @@ impl Select { VisualState::Hovered.into(), )), ); + for handler in &self.open_handlers { + trigger = trigger.on(handler.direct_listener_value(EventType::Click, !self.open)); + } let overlay = (self.open || self.presence.as_ref().is_some_and(crate::Presence::visible)) .then(|| { let overlay = self.overlay(theme); @@ -165,7 +186,7 @@ impl Select { .open(self.open); let options = self.options.iter().enumerate().map(|(index, option)| { let highlighted = self.highlighted == index; - behavior.decorate( + let mut row = behavior.decorate( SelectPart::Option(index), Element::container([behavior.decorate( SelectPart::Value, @@ -196,9 +217,15 @@ impl Select { StyleTransition::default(), VisualState::Hovered.into(), )), - ) + ); + if option.enabled { + for handler in &self.select_handlers { + row = row.on(handler.direct_listener_value(EventType::Click, index)); + } + } + row }); - let list = Element::column(options) + let mut list = Element::column(options) .width(length(260.0)) .max_height(length(280.0)) .padding(argui_ui::Sides::length(5.0)) @@ -231,6 +258,14 @@ impl Select { ))) .z_index(1_000) .layer(theme.overlay_layer(8.0, theme.overlay_blur)); + for handler in &self.open_handlers { + list = list + .on(handler.direct_listener_value(EventType::PointerOutside, false)) + .on(handler.direct_listener_value(EventType::Dismiss, false)) + .on(handler + .listener_value(EventType::Key, false) + .filter(EventFilter::EscapePressed)); + } behavior.decorate( SelectPart::List, if let Some(surface) = self.surface { diff --git a/crates/argui-widgets/src/selection.rs b/crates/argui-widgets/src/selection.rs index 4477e5bc..f1e52806 100644 --- a/crates/argui-widgets/src/selection.rs +++ b/crates/argui-widgets/src/selection.rs @@ -3,8 +3,8 @@ use argui_core::Transform2D; use argui_paint::{Border, CornerRadii, PaintStyle, QuadStyle}; use argui_text::{TextStyle, TextWrap}; use argui_ui::{ - AlignItems, Dimensions, Display, Element, FlexDirection, JustifyContent, LayoutStyle, - StylePatch, StyleTransition, VisualState, auto, length, + AlignItems, Dimensions, Display, Element, EventType, FlexDirection, JustifyContent, + LayoutStyle, StylePatch, StyleTransition, ValueHandler, VisualState, auto, length, }; use crate::WidgetTheme; @@ -29,6 +29,7 @@ pub struct Checkbox { checked: argui_ui::CheckedState, enabled: bool, indicator: Option, + change_handlers: Vec>, } #[cfg(feature = "checkbox")] @@ -47,6 +48,7 @@ impl Checkbox { checked, enabled: true, indicator: None, + change_handlers: Vec::new(), } } @@ -65,6 +67,15 @@ impl Checkbox { self } + /// Adds a callback receiving the next checked state. + /// + /// `handler` is normally created with `Context::value_callback`. + #[must_use] + pub fn on_change(mut self, handler: ValueHandler) -> Self { + self.change_handlers.push(handler); + self + } + #[must_use] /// Builds the checkbox using `theme` for its track and mark colors. pub fn build(self, theme: &WidgetTheme) -> Element { @@ -108,7 +119,12 @@ impl Checkbox { .semantic_hidden(true); let behavior = ToggleBehavior::new(&self.key, &self.label, Role::CheckBox, self.checked) .enabled(self.enabled); - control_row(behavior, self.label, box_element, theme) + let next = self.checked.toggled(); + let mut root = control_row(behavior, self.label, box_element, theme); + for handler in self.change_handlers { + root = root.on(handler.direct_listener_value(EventType::Click, next)); + } + root } } @@ -119,6 +135,7 @@ pub struct Switch { label: String, checked: bool, enabled: bool, + change_handlers: Vec>, } #[cfg(feature = "switch")] @@ -132,6 +149,7 @@ impl Switch { label: label.into(), checked, enabled: true, + change_handlers: Vec::new(), } } @@ -143,6 +161,15 @@ impl Switch { self } + /// Adds a callback receiving the next switch state. + /// + /// `handler` is normally created with `Context::value_callback`. + #[must_use] + pub fn on_change(mut self, handler: ValueHandler) -> Self { + self.change_handlers.push(handler); + self + } + #[must_use] /// Builds the switch using `theme` for its track and thumb colors. pub fn build(self, theme: &WidgetTheme) -> Element { @@ -189,7 +216,11 @@ impl Switch { }, ) .enabled(self.enabled); - control_row(behavior, self.label, track, theme) + let mut root = control_row(behavior, self.label, track, theme); + for handler in self.change_handlers { + root = root.on(handler.direct_listener_value(EventType::Click, !self.checked)); + } + root } } @@ -228,6 +259,7 @@ pub struct RadioGroup { options: Vec, selected: Option, orientation: Orientation, + select_handlers: Vec>, } #[cfg(feature = "radio-group")] @@ -247,6 +279,7 @@ impl RadioGroup { options: options.into_iter().collect(), selected, orientation: Orientation::Vertical, + select_handlers: Vec::new(), } } @@ -258,6 +291,16 @@ impl RadioGroup { self } + /// Adds a callback receiving the selected source option index. + /// + /// `handler` is normally created with `Context::value_callback`. Disabled + /// options never deliver the handler. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + #[must_use] /// Builds the radio group using `theme` for option styling. pub fn build(self, theme: &WidgetTheme) -> Element { @@ -293,7 +336,7 @@ impl RadioGroup { }, )) .radius(CornerRadii::all(999.0)); - behavior.decorate( + let mut option = behavior.decorate( RadioGroupPart::Option(index), control_row_content( option.label.clone(), @@ -301,7 +344,11 @@ impl RadioGroup { option.enabled, theme, ), - ) + ); + for handler in self.select_handlers.iter().copied() { + option = option.on(handler.direct_listener_value(EventType::Click, index)); + } + option }); let content = match self.orientation { Orientation::Horizontal => Element::row(children.collect::>()), diff --git a/crates/argui-widgets/src/sheet.rs b/crates/argui-widgets/src/sheet.rs index 8c2d17af..c74d0b96 100644 --- a/crates/argui-widgets/src/sheet.rs +++ b/crates/argui-widgets/src/sheet.rs @@ -1,5 +1,5 @@ use crate::{Dialog, DialogAction, DialogBehavior, DialogPlacement, WidgetTheme}; -use argui_ui::{Element, UiEvent}; +use argui_ui::{Element, EventHandler, UiEvent, ValueHandler}; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum SheetSide { @@ -20,6 +20,8 @@ pub struct Sheet { pub content: Element, pub side: SheetSide, pub width: f32, + open_handlers: Vec>, + dismiss_handlers: Vec, } impl Sheet { @@ -41,6 +43,8 @@ impl Sheet { content, side: SheetSide::Right, width: 400.0, + open_handlers: Vec::new(), + dismiss_handlers: Vec::new(), } } @@ -50,10 +54,24 @@ impl Sheet { DialogBehavior::new(&self.key, &self.label, self.open).action(event) } + /// Adds a callback receiving the requested controlled open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + + /// Adds a callback for backdrop, close-control, or Escape dismissal. + #[must_use] + pub fn on_dismiss(mut self, handler: EventHandler) -> Self { + self.dismiss_handlers.push(handler); + self + } + #[must_use] /// Builds the sheet using `theme` for the panel and backdrop. pub fn build(self, theme: &WidgetTheme) -> Element { - Dialog::new(self.key, self.label, self.open, self.trigger, self.content) + let mut dialog = Dialog::new(self.key, self.label, self.open, self.trigger, self.content) .panel_width(self.width) .viewport_margin(0.0) .placement(match self.side { @@ -61,7 +79,13 @@ impl Sheet { SheetSide::Right => DialogPlacement::Right, SheetSide::Top => DialogPlacement::Top, SheetSide::Bottom => DialogPlacement::Bottom, - }) - .build(theme) + }); + for handler in self.open_handlers { + dialog = dialog.on_open_change(handler); + } + for handler in self.dismiss_handlers { + dialog = dialog.on_dismiss(handler); + } + dialog.build(theme) } } diff --git a/crates/argui-widgets/src/sidebar.rs b/crates/argui-widgets/src/sidebar.rs index 2ae30f0b..00f4b3cc 100644 --- a/crates/argui-widgets/src/sidebar.rs +++ b/crates/argui-widgets/src/sidebar.rs @@ -1,5 +1,5 @@ use crate::{Button, ButtonBehavior, DialogAction, Sheet, SheetSide, WidgetTheme}; -use argui_ui::{Element, Role, Semantics, UiEvent, length}; +use argui_ui::{Element, EventType, Role, Semantics, UiEvent, ValueHandler, length}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SidebarAction { @@ -20,6 +20,8 @@ pub struct Sidebar { pub mobile: bool, pub open: bool, pub toggle_label: String, + collapsed_handlers: Vec>, + open_handlers: Vec>, } impl Sidebar { @@ -38,9 +40,25 @@ impl Sidebar { mobile: false, open: false, toggle_label: "Toggle navigation".into(), + collapsed_handlers: Vec::new(), + open_handlers: Vec::new(), } } + /// Adds a handler receiving the requested desktop collapsed state. + #[must_use] + pub fn on_collapsed_change(mut self, handler: ValueHandler) -> Self { + self.collapsed_handlers.push(handler); + self + } + + /// Adds a handler receiving the requested mobile-sheet open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + #[must_use] /// Returns an open or close action when `event` targets the sidebar. pub fn action(&self, event: &UiEvent) -> Option { @@ -86,6 +104,12 @@ impl Sidebar { } else { !self.collapsed }); + if !self.mobile { + for handler in &self.collapsed_handlers { + toggle = + toggle.on(handler.direct_listener_value(EventType::Click, !self.collapsed)); + } + } let compact = self.collapsed && !self.mobile; let content = if compact { self.rail.unwrap_or_else(|| Element::container([])) @@ -116,6 +140,9 @@ impl Sidebar { Element::column([close, content]).gap(12.0), ); sheet.side = SheetSide::Left; + for handler in &self.open_handlers { + sheet = sheet.on_open_change(*handler); + } sheet.build(theme) } else { Element::column([toggle, content]) diff --git a/crates/argui-widgets/src/slider.rs b/crates/argui-widgets/src/slider.rs index f46d1750..6dedea7e 100644 --- a/crates/argui-widgets/src/slider.rs +++ b/crates/argui-widgets/src/slider.rs @@ -1,15 +1,17 @@ use argui_core::Transform2D; use argui_paint::{Border, CornerRadii, QuadStyle}; use argui_ui::{ - AlignItems, Element, LengthPercentageAuto, Sides, StylePatch, StyleTransition, VisualState, - auto, length, percent, + AlignItems, ContinuousValuePhase, Element, EventType, LengthPercentageAuto, RangeHandlerValue, + Sides, StylePatch, StyleTransition, ValueHandler, VisualState, auto, length, percent, }; -use crate::{RangeBehavior, RangeConfig, RangePart, WidgetTheme}; +use crate::{RangeAxis, RangeBehavior, RangeConfig, RangeDirection, RangePart, WidgetTheme}; #[derive(Clone, Debug)] pub struct Slider { behavior: RangeBehavior, + change_handlers: Vec>, + commit_handlers: Vec>, } impl Slider { @@ -24,6 +26,8 @@ impl Slider { ) -> Self { Self { behavior: RangeBehavior::new(key, label, value, config), + change_handlers: Vec::new(), + commit_handlers: Vec::new(), } } @@ -34,6 +38,20 @@ impl Slider { self } + /// Adds a callback receiving each next controlled value during interaction. + #[must_use] + pub fn on_change(mut self, handler: ValueHandler) -> Self { + self.change_handlers.push(handler); + self + } + + /// Adds a callback receiving the final controlled value for an interaction. + #[must_use] + pub fn on_commit(mut self, handler: ValueHandler) -> Self { + self.commit_handlers.push(handler); + self + } + #[must_use] /// Builds the slider using `theme` for its track and thumb. pub fn build(self, theme: &WidgetTheme) -> Element { @@ -64,7 +82,7 @@ impl Slider { .background(theme.muted) .radius(CornerRadii::all(999.0)), ); - let control = self.behavior.decorate( + let mut control = self.behavior.decorate( RangePart::Control, Element::row([track, thumb]) .width(percent(1.0)) @@ -80,6 +98,33 @@ impl Slider { ) .transition(StyleTransition::default()), ); + if self.behavior.is_enabled() { + 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, control) } } diff --git a/crates/argui-widgets/src/split_pane.rs b/crates/argui-widgets/src/split_pane.rs index c5d2ba1b..868073a8 100644 --- a/crates/argui-widgets/src/split_pane.rs +++ b/crates/argui-widgets/src/split_pane.rs @@ -1,9 +1,9 @@ use argui_core::{Key, KeyState}; use argui_ui::{ - AlignItems, CursorIcon, Element, GestureCapture, GestureDelivery, GestureKind, GesturePhase, - GestureSet, Interaction, JustifyContent, Orientation, PanAxis, PanGesture, Role, - SemanticAction, SemanticValue, Semantics, StylePatch, UiEvent, UiEventKind, UserSelect, - VisualState, length, percent, property, + AlignItems, CursorIcon, Element, EventType, GestureCapture, GestureDelivery, GestureKind, + GesturePhase, GestureSet, Interaction, JustifyContent, Orientation, PanAxis, PanGesture, Role, + SemanticAction, SemanticValue, Semantics, SplitHandlerValue, StylePatch, UiEvent, UiEventKind, + UserSelect, ValueHandler, VisualState, length, percent, property, }; use crate::WidgetTheme; @@ -25,6 +25,7 @@ pub struct SplitPane { reset: f32, start: Option, trailing: bool, + change_handlers: Vec>, } impl SplitPane { @@ -55,9 +56,17 @@ impl SplitPane { reset: size, start: None, trailing: false, + change_handlers: Vec::new(), } } + /// Adds a handler receiving the next controlled pane size in logical pixels. + #[must_use] + pub fn on_change(mut self, handler: ValueHandler) -> Self { + self.change_handlers.push(handler); + self + } + #[must_use] /// Places the resizable pane after the separator when `trailing` is true. pub fn trailing(mut self, trailing: bool) -> Self { @@ -139,7 +148,7 @@ impl SplitPane { length(2.0) }) .background(theme.border); - Element::container([line]) + let mut separator = Element::container([line]) .keyed(self.key.clone()) .width(if horizontal { length(6.0) @@ -204,7 +213,28 @@ impl SplitPane { .when( VisualState::FocusVisible, StylePatch::new().set(property::BackgroundColor, theme.primary), - ) + ); + let source = SplitHandlerValue::new( + self.size, + self.minimum, + self.maximum, + self.reset, + horizontal, + self.trailing, + ); + for handler in &self.change_handlers { + separator = separator + .on(handler + .direct_listener(EventType::Gesture) + .split_handler_value(source)) + .on(handler + .direct_listener(EventType::Key) + .split_handler_value(source)) + .on(handler + .direct_listener(EventType::Click) + .split_handler_value(source)); + } + separator } #[must_use] diff --git a/crates/argui-widgets/src/table.rs b/crates/argui-widgets/src/table.rs index 40e3bd55..b863b729 100644 --- a/crates/argui-widgets/src/table.rs +++ b/crates/argui-widgets/src/table.rs @@ -1,5 +1,5 @@ use crate::{List, ListState, WidgetTheme}; -use argui_ui::{Element, Role, Semantics, length}; +use argui_ui::{Element, Role, Semantics, ValueHandler, length}; #[derive(Clone, Debug)] pub struct TableColumn { @@ -54,6 +54,20 @@ impl<'a> Table<'a> { self } + /// Adds a handler that receives the stable id of a selected row. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.list = self.list.on_select(handler); + self + } + + /// Adds a handler that receives the stable id of an activated row. + #[must_use] + pub fn on_activate(mut self, handler: ValueHandler) -> Self { + self.list = self.list.on_activate(handler); + self + } + #[must_use] /// Sets the accessible name of the table; `label` is announced to assistive technology. pub fn label(mut self, label: impl Into) -> Self { diff --git a/crates/argui-widgets/src/tabs.rs b/crates/argui-widgets/src/tabs.rs index c5420bce..747c276d 100644 --- a/crates/argui-widgets/src/tabs.rs +++ b/crates/argui-widgets/src/tabs.rs @@ -1,6 +1,9 @@ use argui_paint::{Border, CornerRadii, PaintStyle, QuadStyle}; use argui_text::{TextStyle, TextWrap}; -use argui_ui::{AlignItems, Display, Element, JustifyContent, StyleTransition, VisualState, auto}; +use argui_ui::{ + AlignItems, Display, Element, EventType, JustifyContent, StyleTransition, ValueHandler, + VisualState, auto, +}; use crate::{TabsBehavior, TabsPart, WidgetTheme}; @@ -36,6 +39,7 @@ pub struct Tabs { key: String, tabs: Vec, selected: usize, + select_handlers: Vec>, } impl Tabs { @@ -51,9 +55,20 @@ impl Tabs { key: key.into(), tabs: tabs.into_iter().collect(), selected, + select_handlers: Vec::new(), } } + /// Adds a callback receiving the selected source tab index. + /// + /// `handler` is normally created with `Context::value_callback`. Disabled + /// tabs never deliver it. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + #[must_use] /// Builds the tab triggers and selected panel using `theme` for styling. pub fn build(self, theme: &WidgetTheme) -> Element { @@ -86,7 +101,7 @@ impl Tabs { ..TextStyle::default() }), ); - behavior.decorate( + let mut trigger = behavior.decorate( TabsPart::Trigger(index), Element::container([label]) .padding(argui_ui::sides(12.0, 7.0)) @@ -108,7 +123,11 @@ impl Tabs { StyleTransition::default(), VisualState::Hovered.into(), )), - ) + ); + for handler in self.select_handlers.iter().copied() { + trigger = trigger.on(handler.direct_listener_value(EventType::Click, index)); + } + trigger }); let tab_list = behavior.decorate( TabsPart::List, diff --git a/crates/argui-widgets/src/toast.rs b/crates/argui-widgets/src/toast.rs index 786b70f7..d2cc69f7 100644 --- a/crates/argui-widgets/src/toast.rs +++ b/crates/argui-widgets/src/toast.rs @@ -1,7 +1,9 @@ use crate::{Button, TablerIcon, WidgetAssets, WidgetTheme}; use argui_paint::{Border, CornerRadii}; use argui_text::{TextStyle, TextWrap}; -use argui_ui::{ActionInvocation, ActionState, Element, LiveRegion, Role, Semantics}; +use argui_ui::{ + ActionInvocation, ActionState, Element, EventType, LiveRegion, Role, Semantics, ValueHandler, +}; use argui_ui::{AlignItems, Sides, ViewportAlign, ViewportPlacement, WindowLayer, length}; use std::{collections::VecDeque, time::Duration}; @@ -210,15 +212,34 @@ pub struct ToastHost<'a> { pub key: &'a str, pub state: &'a ToastState, pub close_label: &'a str, + close_handlers: Vec>, } -impl ToastHost<'_> { +impl<'a> ToastHost<'a> { + /// Creates a toast host for controlled `state`, identified by `key`. + #[must_use] + pub fn new(key: &'a str, state: &'a ToastState, close_label: &'a str) -> Self { + Self { + key, + state, + close_label, + close_handlers: Vec::new(), + } + } + + /// Adds a handler receiving the stable id of a toast requested for closure. + #[must_use] + pub fn on_close(mut self, handler: ValueHandler) -> Self { + self.close_handlers.push(handler); + self + } + /// Returns the close-control key for the notification with `id`. pub fn close_key(&self, id: &str) -> String { format!("{}::close::{id}", self.key) } /// Returns the visible toast id when `event` activates its close control. - pub fn close_action<'a>(&self, event: &'a argui_ui::UiEvent) -> Option<&'a str> { + pub fn close_action<'event>(&self, event: &'event argui_ui::UiEvent) -> Option<&'event str> { if !matches!(event.kind, argui_ui::UiEventKind::Click(_)) { return None; } @@ -326,6 +347,21 @@ impl ToastHost<'_> { if !toast.actions.is_empty() { copy.push(Element::row(actions).gap(8.0)); } + let mut close = Button::icon( + self.close_key(&toast.id), + self.close_label, + icons + .icon(TablerIcon::Close, 16.0) + .vector_color(theme.muted_foreground), + theme.ghost_button(), + ) + .build() + .width(length(24.0)) + .height(length(24.0)) + .padding(Sides::length(0.0)); + for handler in &self.close_handlers { + close = close.on(handler.direct_listener_value(EventType::Click, toast.id.clone())); + } Element::row([ icons.icon(icon, 20.0).vector_color(color), Element::column(copy) @@ -333,18 +369,7 @@ impl ToastHost<'_> { .grow(1.0) .min_width(length(0.0)) .flex_basis(length(0.0)), - Button::icon( - self.close_key(&toast.id), - self.close_label, - icons - .icon(TablerIcon::Close, 16.0) - .vector_color(theme.muted_foreground), - theme.ghost_button(), - ) - .build() - .width(length(24.0)) - .height(length(24.0)) - .padding(Sides::length(0.0)), + close, ]) .align_items(AlignItems::START) .gap(12.0) diff --git a/crates/argui-widgets/src/toggle.rs b/crates/argui-widgets/src/toggle.rs index ac4cc876..61528481 100644 --- a/crates/argui-widgets/src/toggle.rs +++ b/crates/argui-widgets/src/toggle.rs @@ -1,4 +1,4 @@ -use argui_ui::{Element, UiEvent}; +use argui_ui::{Element, EventType, UiEvent, ValueHandler}; use crate::{Button, ButtonBehavior, WidgetTheme}; @@ -10,6 +10,7 @@ pub struct Toggle { pub pressed: bool, pub enabled: bool, pub outline: bool, + change_handlers: Vec>, } impl Toggle { @@ -23,6 +24,7 @@ impl Toggle { pressed, enabled: true, outline: false, + change_handlers: Vec::new(), } } @@ -35,6 +37,15 @@ impl Toggle { .map(|_| !self.pressed) } + /// Adds a callback receiving the next pressed state. + /// + /// `handler` is normally created with `Context::value_callback`. + #[must_use] + pub fn on_change(mut self, handler: ValueHandler) -> Self { + self.change_handlers.push(handler); + self + } + #[must_use] /// Builds the toggle using `theme` for its current-state styling. pub fn build(&self, theme: &WidgetTheme) -> Element { @@ -54,6 +65,9 @@ impl Toggle { .expect("button semantics") .state .pressed = Some(self.pressed); + for handler in self.change_handlers.iter().copied() { + button = button.on(handler.direct_listener_value(EventType::Click, !self.pressed)); + } button } } diff --git a/crates/argui-widgets/src/toggle_group.rs b/crates/argui-widgets/src/toggle_group.rs index 28aab613..786d2e2e 100644 --- a/crates/argui-widgets/src/toggle_group.rs +++ b/crates/argui-widgets/src/toggle_group.rs @@ -1,5 +1,7 @@ use crate::{ChoiceMode, Toggle, WidgetTheme, choice_navigation::navigate}; -use argui_ui::{Element, FocusPolicy, Orientation, Role, Semantics, UiEvent}; +use argui_ui::{ + Element, EventType, FocusPolicy, Orientation, Role, Semantics, UiEvent, ValueHandler, +}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum ToggleGroupAction { @@ -17,6 +19,7 @@ pub struct ToggleGroup { pub orientation: Orientation, pub rtl: bool, pub active: Option, + change_handlers: Vec>>, } impl ToggleGroup { @@ -36,6 +39,7 @@ impl ToggleGroup { orientation: Orientation::Horizontal, rtl: false, active: None, + change_handlers: Vec::new(), } } @@ -83,6 +87,35 @@ impl ToggleGroup { Some(ToggleGroupAction::Change(selected)) } + /// Adds a callback receiving the selected stable item keys. + /// + /// `handler` is normally created with `Context::value_callback`. Single + /// selection returns zero or one key; multiple selection returns all keys + /// that remain selected after activation. + #[must_use] + pub fn on_change(mut self, handler: ValueHandler>) -> Self { + self.change_handlers.push(handler); + self + } + + fn selection_after_activation(&self, index: usize) -> Vec { + let next_pressed = !self.items[index].pressed; + let mut selected: Vec<_> = if self.mode == ChoiceMode::Multiple { + self.items + .iter() + .enumerate() + .filter(|(candidate, item)| *candidate != index && item.pressed) + .map(|(_, item)| item.key.clone()) + .collect() + } else { + Vec::new() + }; + if next_pressed { + selected.push(self.items[index].key.clone()); + } + selected + } + #[must_use] /// Builds the toggle group using `theme` for its item styling. /// @@ -116,6 +149,11 @@ impl ToggleGroup { } else { FocusPolicy::Programmatic }; + let selected = self.selection_after_activation(index); + for handler in self.change_handlers.iter().copied() { + button = + button.on(handler.direct_listener_value(EventType::Click, selected.clone())); + } button }); let root = match self.orientation { diff --git a/crates/argui-widgets/src/tree_view.rs b/crates/argui-widgets/src/tree_view.rs index 52360653..d45b3a21 100644 --- a/crates/argui-widgets/src/tree_view.rs +++ b/crates/argui-widgets/src/tree_view.rs @@ -2,7 +2,8 @@ use std::collections::BTreeSet; use argui_core::{Key, KeyState}; use argui_ui::{ - Element, Role, SemanticAction, SemanticState, Semantics, UiEvent, UiEventKind, length, + Element, EventFilter, EventType, Role, SemanticAction, SemanticState, Semantics, UiEvent, + UiEventKind, ValueHandler, length, }; use crate::{Button, VList, WidgetTheme}; @@ -32,9 +33,54 @@ pub struct TreeView<'a> { pub collapsed: &'a BTreeSet, pub list: VList, pub disclosure: Option, + select_handlers: Vec>, + activate_handlers: Vec>, } impl TreeView<'_> { + /// Creates a controlled tree from preorder `nodes` and a virtual `list` viewport. + /// + /// `selected` identifies the active row and `collapsed` contains stable ids of + /// closed branches. The application owns both values. + #[must_use] + pub fn new<'a>( + nodes: &'a [TreeNode], + selected: Option<&'a str>, + collapsed: &'a BTreeSet, + list: VList, + ) -> TreeView<'a> { + TreeView { + nodes, + selected, + collapsed, + list, + disclosure: None, + select_handlers: Vec::new(), + activate_handlers: Vec::new(), + } + } + + /// Sets the optional disclosure `icon` shown for expandable branches. + #[must_use] + pub fn disclosure(mut self, icon: Option) -> Self { + self.disclosure = icon; + self + } + + /// Adds a handler that receives the stable id of a selected node. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + + /// Adds a handler that receives the stable id of a node activated by double click. + #[must_use] + pub fn on_activate(mut self, handler: ValueHandler) -> Self { + self.activate_handlers.push(handler); + self + } + /// Returns source indices visible after applying collapsed ancestors. #[must_use] pub fn visible_indices(&self) -> Vec { @@ -263,6 +309,63 @@ impl TreeView<'_> { .action(SemanticAction::Click) .action(SemanticAction::Focus), ); + for handler in &self.select_handlers { + row = row.on(handler.direct_listener_value(EventType::Click, node.key.clone())); + } + for handler in &self.activate_handlers { + row = row.on(handler + .direct_listener_value(EventType::Click, node.key.clone()) + .filter(EventFilter::DoubleClick)); + } + let visible = self.visible_indices(); + let position = visible.iter().position(|candidate| *candidate == index); + for (filter, target) in [ + ( + EventFilter::ArrowLeftPressed, + self.nodes[..index] + .iter() + .rev() + .find(|parent| parent.depth < node.depth) + .map(|parent| parent.key.clone()), + ), + ( + EventFilter::ArrowRightPressed, + self.has_children(index) + .then(|| self.nodes[index + 1].key.clone()), + ), + ( + EventFilter::ArrowUpPressed, + position.and_then(|position| { + visible + .get(position.saturating_sub(1)) + .map(|index| self.nodes[*index].key.clone()) + }), + ), + ( + EventFilter::ArrowDownPressed, + position.and_then(|position| { + visible + .get((position + 1).min(visible.len().saturating_sub(1))) + .map(|index| self.nodes[*index].key.clone()) + }), + ), + ( + EventFilter::HomePressed, + visible.first().map(|index| self.nodes[*index].key.clone()), + ), + ( + EventFilter::EndPressed, + visible.last().map(|index| self.nodes[*index].key.clone()), + ), + ] { + if let Some(target) = target { + for handler in &self.select_handlers { + row = row.on(handler + .direct_listener_value(EventType::Key, target.clone()) + .filter(filter)); + } + } + } row.interaction .as_mut() .expect("tree row button interaction") diff --git a/crates/argui-widgets/src/updater.rs b/crates/argui-widgets/src/updater.rs index b1cb8395..892b048a 100644 --- a/crates/argui-widgets/src/updater.rs +++ b/crates/argui-widgets/src/updater.rs @@ -1,6 +1,8 @@ use crate::{Button, Dialog, DialogAction, DialogBehavior, Progress, WidgetTheme}; use argui_text::TextStyle; -use argui_ui::{Element, LiveRegion, Role, Semantics, UiEvent, UiEventKind}; +use argui_ui::{ + Element, EventType, LiveRegion, Role, Semantics, UiEvent, UiEventKind, ValueHandler, +}; use argui_updater::{InstallOutcome, State}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -21,6 +23,8 @@ pub struct UpdateDialog<'a> { state: &'a State, open: bool, trigger: Element, + action_handlers: Vec>, + open_handlers: Vec>, } impl<'a> UpdateDialog<'a> { @@ -34,9 +38,25 @@ impl<'a> UpdateDialog<'a> { state, open, trigger, + action_handlers: Vec::new(), + open_handlers: Vec::new(), } } + /// Adds a handler receiving `check`, `download`, `cancel`, or `install`. + #[must_use] + pub fn on_action(mut self, handler: ValueHandler) -> Self { + self.action_handlers.push(handler); + self + } + + /// Adds a handler receiving the requested dialog open state. + #[must_use] + pub fn on_open_change(mut self, handler: ValueHandler) -> Self { + self.open_handlers.push(handler); + self + } + #[must_use] /// Returns the update action requested by `event`, or `None` if it is unrelated. pub fn action(&self, event: &UiEvent) -> Option { @@ -150,9 +170,21 @@ impl<'a> UpdateDialog<'a> { ); } let mut buttons = Vec::new(); - if let Some((label, _)) = self.primary() { - buttons - .push(Button::new(format!("{}::primary", self.key), label, theme.button()).build()); + if let Some((label, action)) = self.primary() { + let value = match action { + UpdateAction::Check => "check", + UpdateAction::Download => "download", + UpdateAction::Cancel => "cancel", + UpdateAction::Install => "install", + UpdateAction::Open | UpdateAction::Close => unreachable!("not a primary action"), + }; + let mut primary = + Button::new(format!("{}::primary", self.key), label, theme.button()).build(); + for handler in &self.action_handlers { + primary = + primary.on(handler.direct_listener_value(EventType::Click, value.to_owned())); + } + buttons.push(primary); } buttons.push( Button::new( @@ -167,13 +199,16 @@ impl<'a> UpdateDialog<'a> { .gap(8.0) .flex_wrap(argui_ui::FlexWrap::Wrap), ); - Dialog::new( + let mut dialog = Dialog::new( &self.key, "Application update", self.open, self.trigger, Element::column(children).gap(16.0), - ) - .build(theme) + ); + for handler in &self.open_handlers { + dialog = dialog.on_open_change(*handler); + } + dialog.build(theme) } } diff --git a/crates/argui-widgets/src/vlist.rs b/crates/argui-widgets/src/vlist.rs index eee8db1f..a303ca20 100644 --- a/crates/argui-widgets/src/vlist.rs +++ b/crates/argui-widgets/src/vlist.rs @@ -1,4 +1,4 @@ -use argui_ui::{Element, ScrollConfig, VirtualList}; +use argui_ui::{Element, ScrollConfig, ValueHandler, VirtualList}; use crate::WidgetTheme; @@ -12,6 +12,8 @@ pub struct VList { pub row_height: f32, pub effects: Vec, pub propagation: argui_ui::ScrollPropagation, + select_handlers: Vec>, + activate_handlers: Vec>, } impl VList { @@ -32,6 +34,8 @@ impl VList { offset: offset.max(0.0), effects: Vec::new(), propagation: argui_ui::ScrollPropagation::Chain, + select_handlers: Vec::new(), + activate_handlers: Vec::new(), } } @@ -50,6 +54,20 @@ impl VList { list } + /// Adds a handler that receives the stable id selected in [`Self::build_list`]. + #[must_use] + pub fn on_select(mut self, handler: ValueHandler) -> Self { + self.select_handlers.push(handler); + self + } + + /// Adds a handler that receives the stable id activated in [`Self::build_list`]. + #[must_use] + pub fn on_activate(mut self, handler: ValueHandler) -> Self { + self.activate_handlers.push(handler); + self + } + /// Builds selectable rows with the same behavior as a non-virtual List. /// `collection` supplies items, `state` supplies controlled selection, `multiple` enables multi-selection, `theme` styles rows, and `row` builds them. #[must_use] @@ -61,7 +79,13 @@ impl VList { theme: &WidgetTheme, mut row: impl FnMut(usize) -> Element, ) -> Element { - let list = crate::List::new(&self.key, collection).selection(state, multiple); + let mut list = crate::List::new(&self.key, collection).selection(state, multiple); + for handler in &self.select_handlers { + list = list.on_select(*handler); + } + for handler in &self.activate_handlers { + list = list.on_activate(*handler); + } let pinned = state .active .as_deref() diff --git a/crates/argui-widgets/tests/button.rs b/crates/argui-widgets/tests/button.rs index 8cf12b60..24a490e2 100644 --- a/crates/argui-widgets/tests/button.rs +++ b/crates/argui-widgets/tests/button.rs @@ -2,8 +2,9 @@ use argui_core::{Affine2D, Color, ColorScheme, Point, Rect, Size}; use argui_paint::{ClipChain, PaintStyle, QuadStyle}; use argui_text::{TextStyle, TextWrap}; use argui_ui::{ - CursorIcon, Element, ElementKind, HitRegion, KeyboardActivation, Role, UiEvent, UiEventKind, - UiTree, UserSelect, VisualState, + CursorIcon, Element, ElementKind, EventHandler, EventHandlerId, EventOwnerId, EventPhase, + EventType, HitRegion, KeyboardActivation, Role, UiEvent, UiEventKind, UiTree, UserSelect, + VisualState, }; use argui_widgets::{ Button, ButtonAction, ButtonBehavior, ButtonStyle, TablerIcon, WidgetAssets, shadcn, @@ -411,3 +412,37 @@ fn busy_button_is_not_activatable_even_when_enabled() { ); assert!(decorated.semantics.as_ref().unwrap().state.busy); } + +#[test] +fn direct_click_handlers_are_additive_target_only_and_use_normal_propagation() { + let themes = shadcn(Color::srgb(0.2, 0.5, 0.9)); + let theme = themes.resolve(ColorScheme::Dark); + let handler = |slot| EventHandler::from_identity(EventHandlerId::new(EventOwnerId(7), slot)); + let root = Button::new("save", "Save", theme.button()) + .on_click(handler(0)) + .on_click(handler(1)) + .build(); + assert_eq!(root.event_listeners.len(), 2); + assert!( + root.event_listeners + .iter() + .all(|listener| { listener.event == EventType::Click && listener.options.target_only }) + ); + + let mut tree = + UiTree::new(Element::container([root]).on(handler(2).listener(EventType::Click))); + let button = tree + .node_ids() + .iter() + .copied() + .find(|node| tree.key(*node) == Some("save")) + .unwrap(); + let deliveries = tree.event_deliveries( + button, + UiEventKind::Click(argui_ui::ClickEvent::accessibility()), + ); + assert_eq!(deliveries.len(), 3); + assert_eq!(deliveries[0].phase(), EventPhase::Target); + assert_eq!(deliveries[1].phase(), EventPhase::Target); + assert_eq!(deliveries[2].phase(), EventPhase::Bubble); +} diff --git a/crates/argui-widgets/tests/menubar.rs b/crates/argui-widgets/tests/menubar.rs index a4bd7625..47e6787a 100644 --- a/crates/argui-widgets/tests/menubar.rs +++ b/crates/argui-widgets/tests/menubar.rs @@ -40,13 +40,7 @@ fn menubar_wraps_in_both_directions_and_opens_first_and_last_entries() { }), ) }; - let bar = Menubar { - key: "bar", - label: "Menu", - menus: &menus, - active: None, - rtl: false, - }; + let bar = Menubar::new("bar", "Menu", &menus); for key in [Key::ArrowLeft, Key::ArrowRight] { assert_eq!( bar.response(&event(key)), @@ -88,13 +82,9 @@ fn open_menubar_moves_into_next_menu_and_uses_its_rtl_direction() { ); first.rtl = false; let menus = [first, second]; - let bar = Menubar { - key: "bar", - label: "Bar", - menus: &menus, - active: Some("first"), - rtl: true, - }; + let bar = Menubar::new("bar", "Bar", &menus) + .active(Some("first")) + .rtl(true); let event = UiEvent::new( UiTree::new(Element::container([])).node_ids()[0], Some("first::item::a".into()), diff --git a/crates/argui-widgets/tests/toast.rs b/crates/argui-widgets/tests/toast.rs index 135bde3b..7fbc7b0a 100644 --- a/crates/argui-widgets/tests/toast.rs +++ b/crates/argui-widgets/tests/toast.rs @@ -90,11 +90,7 @@ fn host_announces_errors_and_pauses_for_pointer_or_action_focus() { )); state.insert(error, sec(0)).unwrap(); state.insert(toast("queued"), sec(0)).unwrap(); - let host = ToastHost { - key: "host", - state: &state, - close_label: "Close", - }; + let host = ToastHost::new("host", &state, "Close"); let themes = shadcn(Color::WHITE); let tree = UiTree::new(host.build( themes.resolve(ColorScheme::Light), @@ -157,11 +153,7 @@ fn notifications_float_at_the_viewport_corner_and_keep_every_variant_readable() notification.variant = variant; notification.description = "Your changes have been saved.".into(); state.insert(notification, sec(0)).unwrap(); - let host = ToastHost { - key: "host", - state: &state, - close_label: "Close notification", - }; + let host = ToastHost::new("host", &state, "Close notification"); for scheme in [ColorScheme::Light, ColorScheme::Dark] { let theme = themes.resolve(scheme); let built = host.build(theme, &icons); diff --git a/crates/argui-widgets/tests/tree_view.rs b/crates/argui-widgets/tests/tree_view.rs index 844b4c40..5371bb64 100644 --- a/crates/argui-widgets/tests/tree_view.rs +++ b/crates/argui-widgets/tests/tree_view.rs @@ -8,13 +8,7 @@ mod tree_view { mod cache; fn view<'a>(nodes: &'a [TreeNode], collapsed: &'a BTreeSet) -> TreeView<'a> { - TreeView { - nodes, - selected: None, - collapsed, - list: VList::new("tree", 24.0, 120.0, 0.0), - disclosure: None, - } + TreeView::new(nodes, None, collapsed, VList::new("tree", 24.0, 120.0, 0.0)) } fn click(key: &str, count: u8) -> UiEvent { diff --git a/crates/argui-widgets/tests/tree_view/cache.rs b/crates/argui-widgets/tests/tree_view/cache.rs index a444cf68..0c95eaab 100644 --- a/crates/argui-widgets/tests/tree_view/cache.rs +++ b/crates/argui-widgets/tests/tree_view/cache.rs @@ -31,13 +31,12 @@ fn scrolling_reuses_rows_and_evicts_rows_outside_the_window() { let themes = shadcn(Color::WHITE); let theme = themes.resolve(ColorScheme::Dark); let mut cache = TreeViewCache::default(); - let mut tree = TreeView { - nodes: &nodes, - selected: None, - collapsed: &collapsed, - list: VList::new("tree", 28.0, 280.0, 0.0), - disclosure: None, - }; + let mut tree = TreeView::new( + &nodes, + None, + &collapsed, + VList::new("tree", 28.0, 280.0, 0.0), + ); let first = tree.build_cached(theme, &mut cache); tree.list.offset = 28.0; let second = tree.build_cached(theme, &mut cache); @@ -79,13 +78,13 @@ fn selection_collapse_data_theme_and_icons_invalidate_rows() { icon, theme: &_, cache: &mut _| { - TreeView { + TreeView::new( nodes, - collapsed, selected, - disclosure: icon, - list: VList::new("tree", 28.0, 280.0, 0.0), - } + collapsed, + VList::new("tree", 28.0, 280.0, 0.0), + ) + .disclosure(icon) .build_cached(theme, cache) }; let first = build(&nodes, &collapsed, None, None, theme, &mut cache); @@ -146,13 +145,12 @@ fn active_row_stays_mounted_and_is_the_only_tab_stop() { let theme = themes.resolve(ColorScheme::Light); let mut cache = TreeViewCache::default(); for selected in [Some("999"), Some("500"), None, Some("deleted")] { - let tree = TreeView { - nodes: &nodes, + let tree = TreeView::new( + &nodes, selected, - collapsed: &collapsed, - list: VList::new("tree", 28.0, 280.0, 5600.0), - disclosure: None, - }; + &collapsed, + VList::new("tree", 28.0, 280.0, 5600.0), + ); for built in [tree.build(theme), tree.build_cached(theme, &mut cache)] { let ui = argui_ui::UiTree::new(built); assert!(ui.node_ids().len() < 250); @@ -192,13 +190,12 @@ fn focus_survives_virtual_scroll_and_tab_leaves_the_tree_once() { let themes = shadcn(Color::WHITE); let theme = themes.resolve(ColorScheme::Light); let mut cache = TreeViewCache::default(); - let mut view = TreeView { - nodes: &nodes, - collapsed: &collapsed, - selected: Some("50"), - list: VList::new("tree", 28.0, 140.0, 1400.0), - disclosure: None, - }; + let mut view = TreeView::new( + &nodes, + Some("50"), + &collapsed, + VList::new("tree", 28.0, 140.0, 1400.0), + ); let build = |view: &TreeView<'_>, cache: &mut TreeViewCache| { Element::column([ view.build_cached(theme, cache), diff --git a/crates/argui/Cargo.toml b/crates/argui/Cargo.toml index d16d3fea..f05fa2ba 100644 --- a/crates/argui/Cargo.toml +++ b/crates/argui/Cargo.toml @@ -11,6 +11,30 @@ description = "A modular Rust UI library for fast, accessible native and WebAsse [features] default = [] +basic = [ + "tasks", + "widget-button", + "widget-checkbox", + "widget-dialog", + "widget-input", + "widget-popover", + "widget-radio-group", + "widget-select", + "widget-switch", + "widget-tabs", + "widget-textarea", +] +desktop = [ + "basic", + "desktop-backdrop", + "file-picker", + "native-popups", + "tray", + "updater", + "widget-file-picker", + "widget-updater", +] +web = ["basic", "webview"] hot-reload = ["argui-runtime/hot-reload"] i18n = ["dep:argui-i18n"] updater = ["dep:argui-updater", "argui-updater/native"] diff --git a/crates/argui/src/lib.rs b/crates/argui/src/lib.rs index 36dcc0f9..682e072d 100644 --- a/crates/argui/src/lib.rs +++ b/crates/argui/src/lib.rs @@ -1,5 +1,19 @@ //! The small public facade for Argui. +/// Common application-author imports. +/// +/// The prelude intentionally keeps widgets behind the [`widgets`] module so +/// feature-gated widget names cannot collide with application model types. +/// Library crates may prefer explicit imports from the facade modules. +pub mod prelude { + pub use crate::platform::{ApplicationConfig, WindowConfig}; + pub use crate::runtime::{Context, Render, WindowEnvironment, run_app}; + pub use crate::theme::{Theme, ThemeMode}; + pub use crate::ui::{Element, EventHandler, ValueHandler, length, percent, sides}; + #[cfg(feature = "argui-widgets")] + pub use crate::widgets::{self, WidgetTheme, default_theme}; +} + pub use argui_accessibility as accessibility; pub use argui_animation as animation; pub use argui_core as core; diff --git a/docs/README.md b/docs/README.md index 5d7562bc..af4fd2e9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,10 +7,11 @@ testing, and releases. Start with: -1. [Architecture](architecture.md) for the frame and event flow. -2. [Repository structure](repo/structure.md) to find the crate that owns a change. -3. [Development guide](contributing/development.md) for the edit and test loop. -4. [Code quality](contributing/code-quality.md) before opening a pull request. +1. [The 0.3 application API](simplified-api.md) for the public learning path. +2. [Architecture](architecture.md) for the frame and event flow. +3. [Repository structure](repo/structure.md) to find the crate that owns a change. +4. [Development guide](contributing/development.md) for the edit and test loop. +5. [Code quality](contributing/code-quality.md) before opening a pull request. ## Engine and application contracts @@ -21,7 +22,7 @@ Start with: | UI behavior | [Editing](ui/editing.md) · [Animation](ui/animation.md) · [Custom elements](ui/custom-elements.md) | | Rendering | [Primitives](rendering/primitives.md) · [Effects](rendering/effects.md) | | Optional capabilities | [Localization](i18n.md) · [Hot reload](hot-reload.md) | -| Widgets | [Catalogue](widgets/shadcn.md) · [Lists and tables](widgets/lists-tables.md) · [Overlays](widgets/overlays.md) | +| Widgets | [Interaction APIs](widgets/interaction-api.md) · [Builder inventory](widgets/builder-inventory.md) · [Catalogue](widgets/shadcn.md) · [Lists and tables](widgets/lists-tables.md) · [Overlays](widgets/overlays.md) | ## Platform integration @@ -35,7 +36,8 @@ Start with: ## Maintainer guides - [Performance](performance/optimizations.md): contracts, measurements, and - reproducible profiling commands. + reproducible profiling commands. The [feature profile measurements](performance/feature-profiles.md) + cover the facade's convenience aliases. - [DevTools](contributing/devtools.md): inspection and profiling behavior. - [Linux graphical testing](contributing/linux-testing.md): private displays and capture-based checks. diff --git a/docs/contributing/releases.md b/docs/contributing/releases.md index a8291920..04269483 100644 --- a/docs/contributing/releases.md +++ b/docs/contributing/releases.md @@ -1,13 +1,15 @@ # Releases The [CI workflow](../../.github/workflows/ci.yml) runs independent jobs for -security, Rust quality, desktop targets, mobile targets, package archives, and -the website. GitHub Pages depends only on the website job. Crate publication -waits for every required job. +security, static Rust quality, public API/SemVer compatibility, Rust coverage, +desktop targets, mobile targets, package archives, and the website. Keeping static checks and instrumented +coverage separate gives each expensive build its own timeout without weakening +the local combined gate. GitHub Pages depends only on the website job. Crate +publication waits for every required job. ## Prepare a version -All 23 public crates share one workspace version. Application and showcase +All 24 public crates share one workspace version. Application and showcase crates set `publish = false`. ```sh @@ -18,8 +20,9 @@ python3 scripts/release.py package `bump` updates the workspace version, internal requirements, and lockfile. `check` rejects mismatched or invalid manifests. `package` computes the -dependency order from Cargo metadata and compiles every crates.io archive with -all features in one temporary registry. +dependency order from Cargo metadata, creates every archive without resolving +older published workspace versions, and then compiles each extracted archive +against the previously staged 0.3 sources. Review the generated diff and run the final [quality gate](code-quality.md#final-gate). The final commit on the push must @@ -60,9 +63,9 @@ ownership, or a registry error stops the release. ## Protected main -The repository rules require the security, quality, desktop, mobile, package, -and website checks, a current branch, resolved review threads, and code-owner -approval. Force pushes and branch deletion are blocked. The checked-in policy is +The repository rules require the security, quality, public API, desktop, +mobile, package, and website checks, a current branch, resolved review threads, +and code-owner approval. Force pushes and branch deletion are blocked. The checked-in policy is [.github/main-ruleset.json](../../.github/main-ruleset.json). The current crate graph is documented in diff --git a/docs/hot-reload.md b/docs/hot-reload.md index 9b351a72..a646b586 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -6,7 +6,7 @@ optional Cargo feature and is absent from default builds. ```toml [dependencies] -argui = { version = "0.2.1", features = ["hot-reload"] } +argui = { version = "0.3.0", features = ["hot-reload"] } ``` Install the matching Dioxus CLI and start the executable with hot patching: diff --git a/docs/i18n.md b/docs/i18n.md index a210a976..4dfa9844 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -10,7 +10,7 @@ Enable the optional `i18n` facade feature to use `argui::i18n`: ```toml [dependencies] -argui = { version = "0.2.1", features = ["i18n"] } +argui = { version = "0.3.0", features = ["i18n"] } ``` Applications using the narrow crate can depend on `argui-i18n` directly. diff --git a/docs/migrations/0.3.md b/docs/migrations/0.3.md new file mode 100644 index 00000000..708b1c5b --- /dev/null +++ b/docs/migrations/0.3.md @@ -0,0 +1,89 @@ +# Migrating from 0.2 to 0.3 + +Argui 0.3 adds local typed widget handlers and the renderer-independent +`argui-testing` crate. The low-level listener and behavior/action layers remain +supported; migrate code where a reaction is naturally local. + +## Buttons + +Before: + +```rust,ignore +Button::new("save", "Save", theme.button()).build().on( + cx.listener(EventType::Click, |editor, event, cx| { + if event.target_key() == Some("save") { + editor.save(); + cx.notify(); + } + }), +) +``` + +After: + +```rust,ignore +Button::new("save", "Save", theme.button()) + .on_click(cx.callback(|editor| editor.save())) + .build() +``` + +Use `event_handler` instead of `callback` when the operation needs the raw event +or must control propagation. It does not invalidate automatically. + +## Forms + +Before, applications matched `UiEventKind::TextChanged`, `Submitted`, and click +targets. After, bind typed values directly: + +```rust,ignore +Input::new("name", &self.name, "Name", theme.input()) + .on_input(cx.input_callback(|form, value| form.name = value)) + .on_submit(cx.submit_callback(|form, _| form.save())) + .build() + +Checkbox::new("public", "Public", self.public) + .on_change(cx.value_callback(|form, value| form.public = value)) + .build(&theme) +``` + +## Overlays + +Keep open state controlled and consume the requested boolean: + +```rust,ignore +Dialog::new("settings", "Settings", self.open, trigger, content) + .on_open_change(cx.value_callback(|app, open| app.open = open)) + .on_dismiss(cx.callback(|app| app.record_dismissal())) + .build(&theme) +``` + +Nested overlays use target-only direct delivery, so a child dismissal does not +accidentally fire the parent's direct callback. + +## Complex controlled widgets + +Simple selection can move to `on_select`. Retain the typed action reducer when +the application needs modifier/range selection, focus requests, validation, +editing, sorting, resizing, submenu intent, or another multi-effect response. +There is no deprecated duplicate API: `Context::listener`, `Element::on`, and +all behavior/action types are still the advanced layer. + +## Testing + +Add `argui-testing = "0.3.0"` as a development dependency. `TestApp::click` +uses real layout and pointer hit testing; `accessibility_action` is a separate +semantic path. `advance` drives animations and native test-task time without +sleeping. `TestWindows` covers independent presentations of shared state. + +## Imports and feature profiles + +`argui::prelude` exports the common runtime, element, handler, theme, length, +and application configuration types. Widgets stay under `widgets` to avoid +name collisions. Existing granular flags remain unchanged. + +- `basic`: tasks plus common form, selection, and overlay widgets. +- `desktop`: `basic` plus native popups, file picking, tray, backdrops, and updater. +- `web`: `basic` plus WebView/browser embedding support. + +Applications optimizing dependency count or binary size can continue selecting +individual `widget-*` features. diff --git a/docs/native-mobile.md b/docs/native-mobile.md index b417c167..822cc030 100644 --- a/docs/native-mobile.md +++ b/docs/native-mobile.md @@ -42,9 +42,9 @@ Mobile entry crates are explicit dependencies. They are not enabled by ```toml [dependencies] -argui = { version = "0.2.1", features = ["widgets-all"] } -argui-android = "0.2.1" # Android application only -# argui-ios = "0.2.1" # iOS application only +argui = { version = "0.3.0", features = ["widgets-all"] } +argui-android = "0.3.0" # Android application only +# argui-ios = "0.3.0" # iOS application only ``` ## Android @@ -79,7 +79,9 @@ argui_android::android_main!(launch); ``` The repository shell is in `mobile/android`. It uses `NativeActivity`, so the -UI needs no Java or Kotlin layer. +UI itself needs no Java or Kotlin layer. The optional background-activity +example includes a small Java foreground service; applications that do not use +that API can omit the helper sources, service declaration, and permissions. ```sh ./scripts/android-gallery.sh apk # debug-signed installable APK @@ -95,6 +97,25 @@ paths, emulator ABIs, and signing. The runtime creates the window when Android resumes. Suspension drops the WGPU surface before the native window and recreates both on resume. +Text fields use Winit's native IME path. When an Argui text field gains focus, +the runtime enables IME input and explicitly requests Android's soft keyboard; +losing window focus hides it. iOS uses Winit's UIKit first-responder path and +resigns/restores it as the app loses and regains focus. + +The shell also declares a `dataSync` foreground service for explicitly started +background work. `argui::platform::mobile::MobileActivity` starts it with an +ongoing progress notification and a monochrome notification icon. On Android +13 and later, the app asks for notification permission before starting; if the +user declines, enable notifications in the app's system settings and try again. +Start this API from a visible user action. Android 15 and later limit background +`dataSync` foreground-service use to a cumulative six hours per 24-hour period; +the notification does not make arbitrary or indefinite background work +permitted. The Java helper/service is included by the repository's Gradle +source set and declared in its manifest. Other Android shells do not need that +helper to run Argui, but must package +`crates/argui-android/android/src/main/java` and declare the service, +permissions, and notification icon before calling `MobileActivity::begin`. + ## iOS `argui-ios` exports a C-callable Rust entry point. The application crate builds @@ -147,12 +168,74 @@ fn view(environment: WindowEnvironment) -> Element { } ``` -Android derives the value from the activity content rectangle; iOS compares the -window with Winit's safe-area bounds. Insets refresh with geometry and scale. +Android reads current `WindowInsets` from the native activity, including system +bars and display cutouts, then converts physical pixels to logical pixels using +the current scale. Older Android versions use the legacy window-inset API. iOS +compares the window with Winit's safe-area bounds. Insets refresh with geometry +and scale. Android's shell uses transparent system bars and iOS renders against +Winit's outer surface, so an application can paint edge to edge on both systems. +Paint the safe-area wrapper with the current theme background: padding protects +interactive content while the background continues behind status, navigation, +home-indicator, and cutout regions. Overlays that are visually closed must not +reserve an inset-sized flex item; an open overlay should paint its own safe-area +padding rather than exposing the renderer clear color. `WindowConfig::with_safe_area_insets` and `AppCommand::SetSafeAreaInsets` support custom hosts and previews. See [safe areas](platform/window-insets.md) for the coordinate contract. +## Direct-touch scrolling + +Touch scrolling follows the finger and keeps momentum by default. This affects +direct touch only; mouse wheels and trackpads retain their platform direction. +Applications that intentionally want reversed touch movement can opt out per +scroll region: + +```rust,ignore +let config = ScrollConfig::default().natural_touch_scroll(false); +``` + +## Background activity progress + +The shared API offers one owner for a native progress surface and a cloneable +reporter suitable for background work: + +```rust,ignore +let mut activity = argui::platform::mobile::MobileActivity::begin( + "Download", + "Starting", +)?; +let progress = activity.progress(); +// A worker may call progress.update(percent, "Downloading…") as work proceeds. +activity.finish()?; +``` + +The portable contract is semantic rather than visual. Rust shares the activity +title, current message, bounded progress, completion, and lifetime. Each native +shell renders that state according to its operating system: + +| Concern | Shared Rust | Android | iOS | +| --- | --- | --- | --- | +| Task state and updates | `MobileActivity` and `MobileActivityProgress` | consumed by JNI adapter | consumed by C/Swift bridge | +| System surface | common title/message/progress | ongoing notification | Lock Screen and Dynamic Island | +| Native layout | no platform markup | Android notification template | SwiftUI `ActivityConfiguration` | +| Background lifetime | explicit owner and finish | foreground service rules | ActivityKit plus finite UIKit fallback | + +This boundary keeps application logic portable without pretending the native +surfaces are interchangeable. Add future shared fields to the Rust state and +bridge contract, then map them independently in the Android notification and +iOS SwiftUI extension. Platform-only decoration—notification channels, small +icons, SF Symbols, Dynamic Island regions, colors, and accessibility +descriptions—belongs to the corresponding native adapter. + +Android uses the foreground service and ongoing notification. iOS uses an +ActivityKit Live Activity when the device and user settings allow it. If a Live +Activity cannot be started, iOS falls back to UIKit's bounded background-task +time allowance; that fallback has no ongoing notification and does not keep the +process alive indefinitely. iOS background execution can be suspended or ended +by the system, so persist resumable work and treat native progress as a status +surface rather than a durable job scheduler. The gallery's **Background +activity** example is available only on Android and iOS. + ## Feature limits Models, layout, text, images, vectors, effects, i18n, tasks, widgets, WGPU, diff --git a/docs/performance/feature-profiles.md b/docs/performance/feature-profiles.md new file mode 100644 index 00000000..2f57328f --- /dev/null +++ b/docs/performance/feature-profiles.md @@ -0,0 +1,48 @@ +# Facade feature profiles + +Argui keeps its granular `widget-*` features. The `basic`, `desktop`, and `web` +features are convenience aliases for common applications; they do not enable a +new implementation path and the facade still has no default features. + +| Profile | Expands to | +| --- | --- | +| `basic` | tasks plus button, checkbox, dialog, input, popover, radio-group, select, switch, tabs, and textarea widgets | +| `desktop` | `basic` plus backdrop, file picker, native popups, tray, updater, and their widgets | +| `web` | `basic` plus the webview integration | + +Applications that need a smaller graph should continue to select individual +features. Platform integrations are deliberately absent from `basic`. + +## Measurement + +Measurements were taken on 2026-09-16 on Linux x86_64 with Rust 1.98.0. The +probe is a minimal application that imports the facade and constructs a basic +view. Cargo used the repository's shared target directory. The comparison is +between spelling the complete granular list and selecting its convenience +alias. + +| Selection | Clean release build | Stripped native binary | +| --- | ---: | ---: | +| granular `basic` list | 201.26 s | 1,645,456 bytes | +| `basic` alias | 217.84 s | 1,645,456 bytes | + +The time difference is clean-build noise: `cargo tree -e features` resolves the +same feature graph, and the byte-identical size confirms that the alias adds no +code. The same invariant applies to `desktop` and `web`, whose aliases expand +only to the documented granular features. Incremental validation in the shared +target directory completed in 2.73 s for `basic`, 13.62 s for `desktop`, and +17.32 s for `web` on `wasm32-unknown-unknown`. Release artifacts therefore do +not need a separate build or target directory merely because an alias is used. + +Reproduce the supported-target checks from the repository root: + +```sh +cargo check -p argui --no-default-features --features basic +cargo check -p argui --no-default-features --features desktop +cargo check -p argui --no-default-features --features web \ + --target wasm32-unknown-unknown +``` + +For a before/after comparison, replace the alias with the exact expansion from +`crates/argui/Cargo.toml`. Cargo must resolve the same dependency and feature +graph; a difference is a regression in the profile definition. diff --git a/docs/platform/application.md b/docs/platform/application.md index 1393b15d..2e4dc227 100644 --- a/docs/platform/application.md +++ b/docs/platform/application.md @@ -66,7 +66,7 @@ executable, set `with_linux_application_id` to that filename. ## Tray ```toml -argui = { version = "0.2.1", features = ["tray"] } +argui = { version = "0.3.0", features = ["tray"] } ``` Windows and macOS use their notification area; Linux uses the diff --git a/docs/platform/desktop-backdrops.md b/docs/platform/desktop-backdrops.md index ce7bb92d..4edc198b 100644 --- a/docs/platform/desktop-backdrops.md +++ b/docs/platform/desktop-backdrops.md @@ -11,7 +11,7 @@ Multiple regions can use different tints. independent of native popovers, widgets and WGSL effects. ```toml -argui = { version = "0.2.1", features = ["desktop-backdrop"] } +argui = { version = "0.3.0", features = ["desktop-backdrop"] } ``` Configure the window at creation, then choose regions while rendering: diff --git a/docs/platform/file-picker.md b/docs/platform/file-picker.md index 37d5bbb1..b91ac49b 100644 --- a/docs/platform/file-picker.md +++ b/docs/platform/file-picker.md @@ -3,7 +3,7 @@ The `file-picker` feature exposes system dialogs independently of widgets: ```toml -argui = { version = "0.2.1", features = ["file-picker"] } +argui = { version = "0.3.0", features = ["file-picker"] } ``` ```rust @@ -56,7 +56,7 @@ filenames containing paths and invalid filters are rejected before native calls. ## Widget ```toml -argui = { version = "0.2.1", features = ["widget-file-picker"] } +argui = { version = "0.3.0", features = ["widget-file-picker"] } ``` `widgets-all` and `argui-widgets/all` also include it. All are disabled by diff --git a/docs/platform/native-popovers.md b/docs/platform/native-popovers.md index 9994fc82..d036495f 100644 --- a/docs/platform/native-popovers.md +++ b/docs/platform/native-popovers.md @@ -6,7 +6,7 @@ bounds. Content, state, placement, scrolling, and input remain in Argui; the platform adapter owns the extra surface. ```toml -argui = { version = "0.2.1", features = [ +argui = { version = "0.3.0", features = [ "widget-popover", "widget-select", "widget-tooltip", diff --git a/docs/rendering/effects.md b/docs/rendering/effects.md index 2f1f5975..05e3cffb 100644 --- a/docs/rendering/effects.md +++ b/docs/rendering/effects.md @@ -6,7 +6,7 @@ definitions they use. ```toml [dependencies] -argui-effects = { version = "0.2.1", features = ["artistic"] } +argui-effects = { version = "0.3.0", features = ["artistic"] } ``` ```rust,ignore @@ -90,7 +90,7 @@ Custom}` lets the application combine that request with a global quality choice. Enable the dedicated preset: ```toml -argui-effects = { version = "0.2.1", features = ["liquid-glass"] } +argui-effects = { version = "0.3.0", features = ["liquid-glass"] } ``` ```rust,ignore diff --git a/docs/repo/structure.md b/docs/repo/structure.md index 75cb036b..67d53d23 100644 --- a/docs/repo/structure.md +++ b/docs/repo/structure.md @@ -63,6 +63,7 @@ when the purpose already names the integration. | `argui-runtime` | Models, scheduling, windows, layout, and rendering | engine and platform crates | | `argui-android` | Android `NativeActivity` entry point | platform, render, runtime, text | | `argui-ios` | iOS static-library entry point | runtime | +| `argui-testing` | Renderer-independent application harness | accessibility, layout, runtime, text, ui | | `argui-widgets` | Individually feature-gated controlled widgets | runtime, theme, ui, supporting engines | | `argui-devtools` | Inspector, style editing, profiling, and tools UI | runtime, inspect, render, widgets | | `argui` | Public facade and feature routing | public engine crates | @@ -72,7 +73,7 @@ when the purpose already names the integration. | `argui-state-app` | Packaged native state showcase | argui, devtools, showcase | | `argui-perf-showcase` | Focused native and WebAssembly workloads | argui | -The first 23 crates in the table through `argui` are published. Applications +The first 24 crates in the table through `argui` are published. Applications and showcases set `publish = false`. ## Target selection diff --git a/docs/simplified-api.md b/docs/simplified-api.md new file mode 100644 index 00000000..109055a1 --- /dev/null +++ b/docs/simplified-api.md @@ -0,0 +1,186 @@ +# The 0.3 application API + +Argui 0.3 keeps the retained event engine, but ordinary application code no +longer needs to look like an event router. The common path is a local widget +handler with a typed payload. State remains controlled by the model. + +## 1. Install and open a window + +The `basic` profile enables tasks and the common form, selection, and overlay +widgets. Granular `widget-*` features remain available for smaller builds. + +```toml +[dependencies] +argui = { version = "0.3.0", features = ["basic"] } +``` + +Application code can use the small prelude and keep platform configuration +explicit: + +```rust,ignore +use argui::prelude::*; + +#[derive(Default)] +struct Hello; + +impl Render for Hello { + fn render(&mut self, _cx: &mut Context) -> Element { + Element::text("Hello from Argui") + } +} +``` + +See [application setup](platform/application.md) for identity, renderer, native, +WebAssembly, Android, and iOS entry points. + +## 2. A local callback + +`Context::callback` receives mutable model state and automatically invalidates +the current presentation after it returns: + +```rust,ignore +Button::new("increment", "Increment", theme.button()) + .on_click(cx.callback(|counter| { + counter.count += 1; + })) + .build() +``` + +The handler is additive. Pointer, touch, Enter, Space, and accessibility click +activation all use the widget's existing event pipeline. A direct handler only +fires for that widget's own target, not because a descendant event bubbled +through it. + +Use `Context::event_handler` when the callback needs the event or context: + +```rust,ignore +Button::new("save", "Save", theme.button()) + .on_click(cx.event_handler(|editor, event, cx| { + editor.save(); + event.stop_propagation(); + cx.notify(); + })) + .build() +``` + +This form does **not** invalidate implicitly. + +## 3. A controlled form + +Widgets emit domain values; the application stores them and passes them back on +the next render: + +```rust,ignore +let email = cx.input_callback(|form, value| form.email = value); +let accepted = cx.value_callback(|form, value| form.accepted = value); +let submit = cx.submit_callback(|form, _value| form.submissions += 1); + +Element::column([ + Input::new("email", &self.email, "Email", theme.input()) + .label("Email") + .on_input(email) + .on_submit(submit) + .build(), + Checkbox::new("terms", "Accept terms", self.accepted) + .on_change(accepted) + .build(&theme), +]) +``` + +The same rule applies to `on_select`, `on_change`, `on_commit`, +`on_open_change`, `on_action`, and `on_activate`. Disabled, busy, constrained, +or unavailable choices do not emit direct values. + +## 4. Test the form without a window + +`argui-testing` uses the real retained tree, layout engine, hit regions, focus, +editing, and event dispatch without Winit, a display server, or a GPU: + +```rust,ignore +use argui::accessibility::Role; +use argui_testing::TestApp; + +let mut app = TestApp::new(Form::default()); +app.get_by_role(Role::TextInput, "Email") + .type_text("person@example.com")?; +app.get_by_role(Role::CheckBox, "Accept terms").click()?; +app.get_by_role(Role::TextInput, "Email").submit()?; + +app.assert_input_value("email", "person@example.com"); +app.assert_focused("email"); +app.assert_quiescent(); +# Ok::<(), argui_testing::TestError>(()) +``` + +Queries by key, role/name, text, label, semantic state, and focus fail on zero or +multiple matches with the focused node, close candidates, and a compact semantic +tree. The harness also supports pointer movement, touch, keyboard shortcuts, +Tab traversal, replacement, paste, wheel input, drag, accessibility actions, +resize, environment changes, lifecycle events, clipboard and effect inspection. + +`TestWindows` presents one entity in multiple independent windows. Each window +has its own focus, layout, handler slots, and presentation resources while model +state remains shared. + +## 5. Tasks and loading state + +Keep the returned handle while work is wanted. `spawn_latest` cancels a previous +operation in the same slot and prevents stale delivery: + +```rust,ignore +cx.spawn_latest(&mut self.search, load(query), |model, result, cx| { + model.loading = false; + model.results = result.unwrap_or_default(); + cx.notify(); +})?; +``` + +Native headless tests use a paused executor. `app.advance(duration)` advances +animations, timers, and `tasks::sleep` without wall-clock sleeping; +`run_until_idle` drains ready work. Closing a presentation or model cancels its +owned task before a queued result can reach a stale callback. + +## 6. Reusable child models + +Use `Entity` when a child owns state, lifecycle, subscriptions, or tasks. +Render it with `cx.entity(&child)`. The parent can still use plain view functions +for stateless sections. Multiple mounts retain independent presentations over +the same model identity. + +## 7. Typed actions and shortcuts + +Behavior action enums remain the runtime-neutral layer for reducers, custom +controls, and centralized command systems. `Context::action_handler` binds a +typed application action. Application shortcuts still belong on a deliberate +ancestor listener so one handler can observe the intended scope. + +## 8. Delegation, capture, and bubbling + +`Context::listener` and `Element::on` are not deprecated. Use them when one +ancestor intentionally routes many descendants, or when capture, passive, +one-shot, propagation, or default-action control is the feature being built: + +```rust,ignore +Element::column(children).on(cx.listener(EventType::Click, |app, event, cx| { + match event.target_key() { + Some("save") => app.save(cx), + Some("delete") => app.delete(cx), + _ => {} + } +})) +``` + +An ordinary button does not require this pattern. See +[interaction](ui/interaction.md) for phase and cancellation rules. + +## 9. Custom controls + +Compose `Interaction`, semantics, gestures, and listeners directly when a +control is not represented by a widget. Keep the renderer/runtime independent +from application DSLs, provide keyboard and accessibility equivalents, and test +pointer activation with `TestApp::click` so real hit testing remains covered. +The [custom-element guide](ui/custom-elements.md) covers custom layout and paint. + +The complete per-widget handler and payload inventory is in +[widget interaction APIs](widgets/interaction-api.md). Migration examples are in +[the 0.3 guide](migrations/0.3.md). diff --git a/docs/todo_simplify_api.md b/docs/todo_simplify_api.md new file mode 100644 index 00000000..727210b6 --- /dev/null +++ b/docs/todo_simplify_api.md @@ -0,0 +1,719 @@ +# Simplify the public API and add application testing + +Status: implemented locally; protected-branch verification pending + +Target release: `0.3.0` + +This document is an implementation plan, not a collection of optional ideas. Its +goal is to make ordinary Argui applications easy to write and test without +removing the low-level event system that advanced applications need. + +An agent implementing this plan must continue through every phase and satisfy +the complete definition of done. Finishing the first vertical slice, making the +examples compile, or leaving follow-up TODOs is not completion. If external +infrastructure is unavailable, finish every unblocked item, record the exact +blocker and the command or external action needed, and do not describe the plan +as complete. + +## Required reading + +Before changing code, read these documents completely: + +- [`contributing/code-quality.md`](contributing/code-quality.md) +- [`architecture.md`](architecture.md) +- [`ui/interaction.md`](ui/interaction.md) +- [`runtime/models.md`](runtime/models.md) +- [`contributing/releases.md`](contributing/releases.md) + +For graphical Linux validation, also read +[`contributing/linux-testing.md`](contributing/linux-testing.md) and keep every +window on the hidden display. + +## Problem statement + +The low-level event system is capable, but it is currently also the main path +shown to new users. A basic button often requires all of the following: + +- attaching a listener to a parent container; +- selecting `EventType::Click` manually; +- comparing `event.target_key()`; +- matching `UiEventKind::Click`; +- remembering to call `cx.notify()`. + +This makes a simple counter look like an event-delegation example. The same +pattern creates large application-wide event handlers in the Widget Gallery, +even though most controls have a single local reaction. + +Argui also has the internal pieces required for headless tests, but no public +application harness combines them. Users should not have to recreate +`SingleWindowModel`, `UiTree`, layout, event delivery and reconciliation merely +to click a button in a unit test. + +## Product principles + +The implementation must follow these principles: + +1. The common path is local, typed and short. +2. The advanced path remains explicit and powerful. +3. Bubbling, capture, passive listeners, one-shot listeners, + `prevent_default()` and propagation control are not removed. +4. Direct widget callbacks must use the same underlying event pipeline as + low-level listeners. There must not be a second dispatch system. +5. Closures remain in the runtime handler registry. Do not store closures in + `Element`; elements must remain cloneable, comparable and suitable for + retained reconciliation and hot reload. +6. Application state remains controlled by the application. Argui may retain + temporary interaction state such as focus, pointer capture, drag state and + typeahead state, but must not silently become the owner of business data. +7. A headless test and a native rendering test solve different problems. The + public harness does not replace renderer and OS integration tests. +8. No compatibility shim, deprecated duplicate API, placeholder, `TODO`, + `FIXME`, `todo!()` or `unimplemented!()` may be left behind. +9. Every new public function and method needs accurate Rustdoc, including + parameters, return values, errors and panics where applicable. +10. All Rust source files must remain at or below 600 physical lines, and tests + must live under `tests/` in the appropriate crate. + +## Target user experience + +### Simple local callback + +The basic counter should require no event type, key comparison, event-kind +match or explicit invalidation: + +```rust,ignore +impl Render for Counter { + fn render(&mut self, cx: &mut Context) -> Element { + Button::new("increment", "Increment", theme.button()) + .on_click(cx.callback(|counter| { + counter.count += 1; + })) + .build() + } +} +``` + +`Context::callback` is the convenience path for a local mutating callback. It +must invalidate the current presentation after the callback returns. + +### Callback with the event and context + +The direct widget path must also allow commands, focus changes and event +control: + +```rust,ignore +Button::new("save", "Save", theme.button()) + .on_click(cx.event_handler(|editor, event, cx| { + editor.save(); + event.stop_propagation(); + cx.notify(); + })) + .build() +``` + +`Context::event_handler` does not hide the raw event and does not implicitly +invalidate. Its behavior must be documented clearly so users do not receive +surprising redraws. + +### Advanced event delegation + +The existing low-level API remains the documented advanced path: + +```rust,ignore +Element::column(children).on( + cx.listener(EventType::Click, |app, event, cx| { + match event.target_key() { + Some("save") => app.save(cx), + Some("delete") => app.delete(cx), + _ => {} + } + }) +) +``` + +This is intentional event delegation, not the recommended implementation of a +single button. + +### Headless application test + +The minimum public harness should read like this: + +```rust,ignore +use argui_testing::TestApp; + +#[test] +fn increments_the_counter() { + let mut app = TestApp::new(Counter::default()); + + app.click("increment").unwrap(); + + app.assert_text("Count: 1"); + app.assert_focused("increment"); +} +``` + +Accessible queries must also be supported: + +```rust,ignore +app.get_by_role(Role::Button, "Save").click().unwrap(); +app.get_by_role(Role::TextInput, "Email") + .type_text("person@example.com") + .unwrap(); +``` + +## Architectural decision + +### Opaque handler identity + +Add an opaque handler identity to `argui-ui`. The exact private representation +may reuse `EventHandlerId`, but application code must not construct or inspect +the identity. + +The intended public shape is: + +```rust,ignore +pub struct EventHandler { /* private */ } +``` + +It must be cheap to clone or copy and comparable wherever existing element +reconciliation requires comparison. It represents a callback registered by the +runtime but does not choose an event type. + +An `EventHandler` must be convertible internally into an `EventListener` for a +specific `EventType`. Keep the construction surface as narrow as possible. Do +not expose handler owner IDs or slots as normal public application concepts. + +### Runtime registration + +Refactor handler registration in `argui-runtime` so that: + +- `Context::callback` registers a simple state callback and automatically calls + `cx.notify()` after successful delivery; +- `Context::event_handler` registers the existing full callback shape; +- `Context::listener(event_type, callback)` is implemented through the same + registration primitive and retains its existing semantics; +- handler slot identity remains stable between equivalent renders; +- removed slots are never accidentally reused; +- multiple mounts of the same entity retain independent presentation handlers; +- routed child entities continue to receive their own handlers. + +Do not make `argui-widgets` depend on concrete model types. Widgets receive an +opaque handler and attach an ordinary `EventListener` during `build()`. + +### Direct listener semantics + +A direct widget handler must fire only for that widget's own activation. It +must not fire because an unrelated interactive descendant bubbled a click +through the widget. Use an explicit target/current-target policy or an +equivalent dispatch filter if required; do not rely on undocumented tree shape. + +The following behavior must remain consistent: + +- mouse, touch, keyboard and accessibility activation converge on the same + direct callback; +- disabled and busy controls do not invoke the callback; +- callback ordering is deterministic when more than one handler is attached; +- direct callbacks still participate in normal propagation; +- `stop_propagation`, `stop_immediate_propagation` and `prevent_default` retain + their documented behavior; +- handler replacement during a rerender cannot call a stale closure. + +## Phase 0: establish a green baseline + +Do not begin the API migration while the protected branch or release checks are +red. + +1. Inspect the latest protected-branch workflow rather than assuming the last + local run is sufficient. +2. Fix the generated website catalogue mismatch and ensure every catalogue slug + is unique. +3. Fix crates.io archive verification. All packages in the staged workspace + must resolve the staged Argui versions instead of an older published crate. +4. Fix or split the quality workflow so it completes within its timeout without + weakening the checks. +5. Run the relevant local targeted checks and confirm the protected workflow is + green. +6. Record the baseline test count and coverage report for comparison after the + migration. + +At the time this plan was written, the failures included the duplicated +`text-selection` catalogue entry, archive verification resolving an older +`argui-ui 0.2.1`, and the quality job reaching its timeout. Recheck the current +state; do not blindly reproduce stale fixes. + +## Phase 1: handler foundation + +### `argui-ui` + +1. Add the opaque `EventHandler` type beside `EventListener`. +2. Add the smallest internal/publicly-hidden conversion needed to bind a + handler to an `EventType`. +3. If a direct-target filter is needed, model it explicitly in listener options + and cover capture, target and bubble phases. +4. Re-export the new type from `argui-ui` and the top-level facade where + appropriate. + +### `argui-runtime` + +1. Extract event-independent handler registration from `Context::listener`. +2. Implement `Context::callback` with automatic presentation invalidation. +3. Implement `Context::event_handler` with access to `UiEvent` and `Context` and + no implicit invalidation. +4. Reimplement `Context::listener` using the shared registration path. +5. Preserve hot-reload and presentation-owned handler identity. + +### Required tests + +Add tests under `crates/argui-ui/tests/` and +`crates/argui-runtime/tests/model/` for: + +- stable handler identity; +- callback replacement after rerender; +- removed handlers; +- nested and routed entities; +- separate mounts of one entity; +- automatic invalidation for `callback`; +- no automatic invalidation for `event_handler`; +- capture, target and bubble delivery; +- propagation cancellation; +- once and passive options; +- no delivery to hidden or closed presentations. + +Run, with the final feature set retained between commands: + +```sh +cargo nextest run -p argui-ui --all-features +cargo clippy -p argui-ui --all-targets --all-features -- -D warnings +cargo nextest run -p argui-runtime --all-features +cargo clippy -p argui-runtime --all-targets --all-features -- -D warnings +``` + +## Phase 2: button vertical slice + +Implement the complete feature through one control before generalizing it. + +### Public API + +Add `Button::on_click(EventHandler) -> Button`. Support more than one handler +without silently replacing an earlier handler. During `build()`, attach the +listener to the actual interactive root produced by `ButtonBehavior`. + +Do not remove `ButtonBehavior::action`; it is the runtime-neutral, controlled +behavior API and remains useful for event delegation and custom controls. + +### Migration + +1. Rewrite the Counter documentation example to use the direct button handler. +2. Keep the Events example as the canonical bubbling/delegation example. +3. Add a direct-handler example to the Button catalogue page. +4. Do not migrate unrelated widgets during this vertical slice. + +### Required tests + +Cover: + +- pointer click; +- touch/tap activation; +- Enter and Space activation; +- accessibility click; +- disabled button; +- loading/busy button; +- two buttons with different callbacks; +- multiple callbacks on one button; +- direct callback followed by parent bubbling; +- propagation stopped by the direct callback; +- custom non-interactive button content; +- stale handler replacement after state changes. + +The vertical slice is accepted only when the new Counter runs, its headless test +passes, the advanced event tests remain unchanged in behavior, and the relevant +WebAssembly check compiles. + +## Phase 3: consistent widget event APIs + +Inventory every interactive widget exported by `argui-widgets`. For each one, +either provide a direct handler API or document why the widget is intentionally +presentational. Do not leave controls with undocumented event handling. + +Use this naming convention unless the existing typed action has a materially +better domain term: + +| Control category | Direct API | +| --- | --- | +| Button-like activation | `on_click` or `on_activate` | +| Text editing | `on_input`, `on_submit` | +| Checkbox, switch, toggle | `on_change` | +| Radio, tabs, select | `on_select` | +| Range and slider | `on_change`, `on_commit` | +| Dialog, sheet, drawer, popover | `on_open_change`, `on_dismiss` | +| List, tree and table | `on_select`, `on_activate` | +| Menu and command controls | `on_action`, `on_open_change` | + +Choose one name per semantic operation and apply it consistently. Do not add +aliases such as both `on_press` and `on_click` merely to accommodate taste. + +### Migration order + +Implement and verify controls in this order: + +1. `Input`, `TextArea`, `InputOtp`. +2. `Checkbox`, `Switch`, `Toggle`, `ToggleGroup`. +3. `RadioGroup`, `Tabs`, `Select`, `NativeSelect`, `Combobox`. +4. `Range`, `Slider`, `ColorPicker`. +5. `Collapsible`, `Accordion`, `Dialog`, `AlertDialog`, `Sheet`, `Drawer`, + `Popover`, `HoverCard`. +6. `Menu`, `ContextMenu`, `Menubar`, `CommandPalette`. +7. `Calendar`, `DatePicker`, `Pagination`, `Carousel`, `Breadcrumb`. +8. `List`, `VList`, `TreeView`, `Table`, `DataTable`. +9. Interactive composite widgets such as `Questionnaire`, `MessageScroller`, + `Toast`, updater UI and sidebar controls. + +### Payload policy + +Direct handlers should receive the useful domain value when the event already +contains or deterministically produces one. Users should not repeat event-kind +matching for common cases. + +Examples include: + +- the new text for input; +- the submitted string for submit; +- the next checked state for a checkbox; +- the selected source index or stable option value for selection; +- the next range value and whether it is an update or commit; +- the requested open state for an overlay. + +Keep existing typed action enums for advanced reducers and delegation. Build +the simple path from the same behavior rules so disabled options, keyboard +navigation and accessibility cannot diverge. + +If the current `EventHandler` representation cannot carry a typed payload, do +not introduce `Any`, unchecked downcasts or a second dispatcher. Add a small, +typed runtime binding abstraction or widget/facade extension whose callback is +adapted into the existing handler registry. Preserve crate boundaries and prove +the design with input, checkbox and select before applying it everywhere. + +### Interaction state policy + +Review `RangeState`, `Typeahead` and similar helper state individually: + +- business state such as selected value and open state remains controlled by + the application; +- temporary gesture/typeahead bookkeeping should be retained internally when + that can be done without hiding application-visible state; +- advanced applications must still be able to use behavior objects directly; +- do not move all widget state into the UI tree as a shortcut. + +## Phase 4: create `argui-testing` + +Add a focused workspace crate named `argui-testing`. It is intended primarily +as a development dependency and must not require a native window, display +server or GPU. + +### Initial responsibilities + +`TestApp` 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..f9f801d7 --- /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 10 interactive documentation examples passed independently. +- Android and iOS cross-checks, the complete native and Wasm example workspaces, + all 24 staged crates.io archives, and 107 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/interaction.md b/docs/ui/interaction.md index e22e702f..5730bb4c 100644 --- a/docs/ui/interaction.md +++ b/docs/ui/interaction.md @@ -22,6 +22,15 @@ 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`, `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. + 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. 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..a15bf7ad --- /dev/null +++ b/docs/widgets/interaction-api.md @@ -0,0 +1,74 @@ +# 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_input(String)`, `on_submit(String)` | Editing, paste, IME, Enter submission, and accessibility set-value use the text-input policy. | +| 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. | + +## 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 0000000000000000000000000000000000000000..c1ee826b88a254cf34c36a853b0f5c7eba1137ab GIT binary patch literal 3059 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

NXTPm_-`Ep8(R zr4mgci36v>FcdJ|xcz#wyYOTFeoTJvOtOYeLzDH}@4t@-jQAvGHWcPVKROI5kL{Z^ zY^|sKgPqE7 zf7IDXYSM@MFXgsn%qch-I#J#bmoG=PRw$7CJp2nG%^^#sao>RpL(+WtA%=(Wx~#qi zYdii$Yl@ei{o_O(M{KqFN6EJKl!ZZjsH>}2F;5Ai*x9K|V$GK<;3ljJ*TI2%rQ3Lu z(8>9CvlJp@3@}Af-YQN_PZ1BOspX8+{8u5D^AOc?TI6Z@{ga)y>&7sVL%pdZ=AcFT z0@T0gQ-OK#Nz=kP!pVp4b(6#hoRjHRA0|Lk!L0>dE$>^Cyp-C2Iz~n*F7LCreIl5o zTYt>Y;|w|cHK3=_OqP&Nc{BaE;`V*k)=FxzPBOVJ@w1I#6Q8ir1us@uA%zJjttcDY zN||Kso3lq8YQ?&<+DA(_?u01^9=uM%7$i%SVD4&<#nMJ)Ei+m@od64~)+EoaLD!|d z1$N`)OM(^ZGK_=+;7Y5FyT^^8uHINmj&8nIi9l3c1L^nh%aLF1`>G21gw;__U;%>* z*pjxZI&EGoCKEm=d?Zl{t)aun4W_>4?S48iWH8D~@KJS=#u3U&I5lim%obp3VyrLIy$r06?Ol{`@rnfK~NB zH#zRVh{!bO>c57~R{ix$0N_0r01y@l0Nnfwg>3@>K0*M%E(`#W$p8T8JhEF}$^CnP zZKL(#IRNk@fkWh959F=!QWdmIKt|2U!;0J8`7iX;Th+wd&f42v#@5sRUjq;k5|Q8+ zlI9l{HxLn$5fPIS5$6>Wk`WTRe+rfO|0KM1w{vy~_}?dd^uLMwmjL;HNAPxjYwzW4 z{nq3Emm?}6BPR0y48pv$R`72+0FCF$2L5xHcE8Vty$u)lWp7*#KdqPJtCQnW3x|c7 zQuJn15wdUcZAMCOWqWlBIgUOm~ij zo;m{lD$yXKtqAl0JaC(qF|KO^>To#L{LKK z<-5ljDw@y=jt747Q=OkVC8#R3~0r{^KUr5i6EHeB=RMk}p9$%{~sM{_db8 zh$ciU3F(F(5`VmQC(JNix%TY5jIyDKkByDJ!da2`PbBC(%KZ}%X8?AmM3ZN zi&iW<&kbyo{1xc+@$y3b@a<~5^On5*p|o$*m|}lmZSJK*!{va^QptXsp!a3RD(PX_ zmj7~9!ezp*z-7Dg%MqRP4pq8CGxzoT!Nqo~Ym38q-?I>!wR@ha+lKBo_xV4!4XTI6 z9sRedn*p7#zg_-RJnHx>zSJ1>=XUdaNz!67I7PnA-T%{NePV!BZQP|{=4Gl`fR%su zt=*4H-{{Ltp2O?GKpnG{+xzor{U!Z1`T4-HtR>N&KpFor&6VcTyPPqNkgc(YvC7!k z(6>E|Z+afdu0W(?-cd>DN)BXn!K6uBSKmH~IngGk5DAJKZH28s5^9BO8CfSz9UT$O zOy~!#7Nuq?YR^BSDEfMiOK@F02wih2DC_mQv33l&Nn!gZB}+{X&w?N`K`#y#l<9}>)>5Qo43=JmHoalBCa zt8`Qz=p){e$x6U3CCgULxHCUK^UldpAZ6~*$#U?Nu@YvK{oy9oeKl4Q@Yp>ca>7{C zHHy4NULsRrX_5DTHed{4W;d*?|0PDOi$m(72XXR$fDGDzhQ=z#P>4D4&Mkt8WFQb3 zf}SR^ZD~ik6T1@K;Xi+_8zV6s=s9Yw3zo*q8x^lLf2)m1dItCH>)`WX}ybgP(~AS%kSD6UqsfYz{3v)*6JSewd6eeg;E3-%-FRl-?o7D<43Q##zy zpnTyR!OG}soe(Aqy__PxlSV#8PvbVqT+N~z@gJ?G4m&um&cTZ03u9=(7z|fGw^9hZ z-B0iyc7UWuo^lANN`I#J8!`n+j6Tpiqr;9jVvD+q8$4q8^SBgGO=Q|*MYY3vUy1HLMH6{7Np-GrLXkt8^eBv<=LGSISRFCqoCjCTM#8bV%T zrH9?J+(n}Wu≻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 E2bKo;g#Z8m literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..509b300212689c8a6d4713c18cf03b546f14c18d GIT binary patch literal 15085 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

NXTPm_-`Ep8(R zr4mgci36v>FcdJ|xcz#wyYOTFeoTJvOtOYeLzDH}@4t@-jQAvGHWcPVKROI5kL{Z^ zY^|sKgPqE7 zf7IDXYSM@MFXgsn%qch-I#J#bmoG=PRw$7CJp2nG%^^#sao>RpL(+WtA%=(Wx~#qi zYdii$Yl@ei{o_O(M{KqFN6EJKl!ZZjsH>}2F;5Ai*x9K|V$GK<;3ljJ*TI2%rQ3Lu z(8>9CvlJp@3@}Af-YQN_PZ1BOspX8+{8u5D^AOc?TI6Z@{ga)y>&7sVL%pdZ=AcFT z0@T0gQ-OK#Nz=kP!pVp4b(6#hoRjHRA0|Lk!L0>dE$>^Cyp-C2Iz~n*F7LCreIl5o zTYt>Y;|w|cHK3=_OqP&Nc{BaE;`V*k)=FxzPBOVJ@w1I#6Q8ir1us@uA%zJjttcDY zN||Kso3lq8YQ?&<+DA(_?u01^9=uM%7$i%SVD4&<#nMJ)Ei+m@od64~)+EoaLD!|d z1$N`)OM(^ZGK_=+;7Y5FyT^^8uHINmj&8nIi9l3c1L^nh%aLF1`>G21gw;__U;%>* z*pjxZI&EGoCKEm=d?Zl{t)aun4W_>4?S48iWH8D~@KJS=#u3U&I5li + + #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 0000000000000000000000000000000000000000..ebdc10d3b8de7d3c4f777e685b16eddacd89e4a1 GIT binary patch literal 204694 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 T*9QOj00000NkvXXu0mjfszFJU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e409b57b505b051d7ef7f9467e35b101049a5785 GIT binary patch literal 633 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..257fd1fce08b2cd46d83a7f1dd36447057c9830a GIT binary patch literal 901 zcmeAS@N?(olHy`uVBq!ia0vp^vLMU>Bp8Z+SX>5D3dtTpz6=aiY77hwEes65fIxotLg85KdZf8d@uQ+(~(p8UscXemOt>H|G(4U^49+^>Nj^Xn4|ig zX3D*1Vbx;y{_C#0>waHebZFh(Y|l9zpa16VzaIB(e=gICt2Z@8RKCtBu3QnV7alb? zewx68GlB6MiW7Zxq@uTEbXf!d-7E3B@A}cGB$e3$5?U$_6SNsF-015+nZ8?Dk%>vw zo1u-vFpa^%{Cjru?9b7w=ka8*_!uxrvAkzkQJ3-m#O|a2vTucPv`iCa=+$yK!;EcqAYY!<1H5U|U#%@;CmAm=- z%foZsle_0zpWXg4L$%A}%l#E-lm*C?K{ub=hbub&&KuGEj@irjYBC%qWt`x zJ@K|->zAy}yA>DpZh6T`C#Mdf8CR$B<<pN?en*S zMUn0MeAY9CFrR6fxVP9Y<>!xEKg}(-|C+M)O6XKklL|4uW*hk(^W^n()>ydTFfqNV qp))a7;Qd{N)0_TziA_KB!GG4lY`0A|j_ZL5mBG{1&t;ucLK6TF2X@B* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..08294053000108bef0830627a74bc43b0598fa95 GIT binary patch literal 2198 zcmZ`)do74L78B9TE%%<>!n^0Z?|IL8|9H>&e!rjdy?oC1^T+p`Z;C74NnZAVEC2xV zCopJ&7_I(xus8tC-KyWk01dzpZ~zdi3joX{0N4VF8~5rLt>G5??N9Q-v=Owj-D25M+{5QTa+octe- zCH$zFyD`Gb*wo6{?B7cS;Q|k_&e#*^o83ht4P>h^=jW#Tj5Z8aGfW*;k?D0zyTeR+gawx)PjD^pKxW9%Ur z#4tCjXdlXb$)6zZ-?>Bh{+U*O3j&K={q`vM+0b1NKCMqULfkSk04a3ICh8FQQ$<#^$9mOcNxl=J;^Xlh8{*LwJ}XB4`n0*t`J9b4Wml9{k7+m z;i$Kf=cMA#br*+~En1en&sq0$U44}%O9=(BtmvAP2Meb8Y}N$T?9BVNxzP&vx76!U zczW0w4s{6{8Il6Fhe8T>1IxNbiY~YBGbW!!g!_6~EVi#qciUaJCX7Q*DN~qlZj%-5 zPe-c&LjI)xXA_iFBK1)nu|kv?DXJZGM5-&enwB5OgU5lla2O1$%DlYF9(cfuB}E0l z$niVfP#Y6E{%IoQd6!F7m@^4nTEqlF#-u0?b;w<e; zs^x{1rf0pzMS=d62Zkjy?~swJdAnlDr>@M{ZVk!>bWq&7)|7l^{i+2oKQ#2_Xmn%2 zxV8*9DIOf1#LDU074xd^M?AxJr(fmy0?JhILz2Rp)wsVq?Owx<;0^T*p9IsPIM*MT zS1ZYqUykM+uH;JJDcEYOXzJ)0@z<{B)FPLvv7lma!*z4oF=uH%2b zNrc&Me3>#T!0Rc<54Mi=r;hY#P*(2q{k3!EiIy8ESe-A?M5OgweN*gW#@_n$K`Hy7r(WU^?$TH zzNRLk_j8Cd#iepa{@N_epaGUd4xu5#wW%~d(T@qK|l2lyHntguNhpb+l$s3vi#ZFoaxfGl-OiN4*Vm4L3yW$_qE}C z)V>gEp?Ur?y#fb+oJmGx9+$P~yENIajw?o*`J9OkPql^Ivojp>q;UutL@a6LSkT`gnu}&QTQsPRRE|i=#+FV#4zNTmNjkSM&e5%$yzDMsY zr~GQ=Jr0r`6}BFmVfPDm@2=~=>X#LNN!M?2i5nkwyHKOz;Ly8gepKaNOl?(ZsT-#5 zGVYi7#uS-{4-p|8Er_G5-vY{vCPMzjE95Da`tzINFxcJA3D#nyDw|P&@Xqzt$u)OXp z3|dM-x>bfOce7kOm+p2mE0JULdVRRI>LQz{7sEeb+0kr!L{maG-Kmc8D!I?bkfmdj z8}?oJeVsBvJ{5BAx>-;bJuM0Y(X3CNzFi_yRLPv4-!!IkeA5ojqIRkk2wq6sGluT# z7k_m6rW2SlPrqF!W$!j*mF2`f2`kOK6}b(cIq!8}V69VEZ7~DY+XqPiO9JvqW%Z%v zedVGs;<}Lc^L>2^oz0L>qRo-dDL5p>NHGx<4p(&U_aR;f4N zi&aueaYK3-9r85&WCXmMan}GxDyEkuCnpQ$QUCPq!m`S_Z8Wz5k3qqRe%tuMOg}{M TcGUOp&kdY##G{|tlM?;~q5u3A literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..08294053000108bef0830627a74bc43b0598fa95 GIT binary patch literal 2198 zcmZ`)do74L78B9TE%%<>!n^0Z?|IL8|9H>&e!rjdy?oC1^T+p`Z;C74NnZAVEC2xV zCopJ&7_I(xus8tC-KyWk01dzpZ~zdi3joX{0N4VF8~5rLt>G5??N9Q-v=Owj-D25M+{5QTa+octe- zCH$zFyD`Gb*wo6{?B7cS;Q|k_&e#*^o83ht4P>h^=jW#Tj5Z8aGfW*;k?D0zyTeR+gawx)PjD^pKxW9%Ur z#4tCjXdlXb$)6zZ-?>Bh{+U*O3j&K={q`vM+0b1NKCMqULfkSk04a3ICh8FQQ$<#^$9mOcNxl=J;^Xlh8{*LwJ}XB4`n0*t`J9b4Wml9{k7+m z;i$Kf=cMA#br*+~En1en&sq0$U44}%O9=(BtmvAP2Meb8Y}N$T?9BVNxzP&vx76!U zczW0w4s{6{8Il6Fhe8T>1IxNbiY~YBGbW!!g!_6~EVi#qciUaJCX7Q*DN~qlZj%-5 zPe-c&LjI)xXA_iFBK1)nu|kv?DXJZGM5-&enwB5OgU5lla2O1$%DlYF9(cfuB}E0l z$niVfP#Y6E{%IoQd6!F7m@^4nTEqlF#-u0?b;w<e; zs^x{1rf0pzMS=d62Zkjy?~swJdAnlDr>@M{ZVk!>bWq&7)|7l^{i+2oKQ#2_Xmn%2 zxV8*9DIOf1#LDU074xd^M?AxJr(fmy0?JhILz2Rp)wsVq?Owx<;0^T*p9IsPIM*MT zS1ZYqUykM+uH;JJDcEYOXzJ)0@z<{B)FPLvv7lma!*z4oF=uH%2b zNrc&Me3>#T!0Rc<54Mi=r;hY#P*(2q{k3!EiIy8ESe-A?M5OgweN*gW#@_n$K`Hy7r(WU^?$TH zzNRLk_j8Cd#iepa{@N_epaGUd4xu5#wW%~d(T@qK|l2lyHntguNhpb+l$s3vi#ZFoaxfGl-OiN4*Vm4L3yW$_qE}C z)V>gEp?Ur?y#fb+oJmGx9+$P~yENIajw?o*`J9OkPql^Ivojp>q;UutL@a6LSkT`gnu}&QTQsPRRE|i=#+FV#4zNTmNjkSM&e5%$yzDMsY zr~GQ=Jr0r`6}BFmVfPDm@2=~=>X#LNN!M?2i5nkwyHKOz;Ly8gepKaNOl?(ZsT-#5 zGVYi7#uS-{4-p|8Er_G5-vY{vCPMzjE95Da`tzINFxcJA3D#nyDw|P&@Xqzt$u)OXp z3|dM-x>bfOce7kOm+p2mE0JULdVRRI>LQz{7sEeb+0kr!L{maG-Kmc8D!I?bkfmdj z8}?oJeVsBvJ{5BAx>-;bJuM0Y(X3CNzFi_yRLPv4-!!IkeA5ojqIRkk2wq6sGluT# z7k_m6rW2SlPrqF!W$!j*mF2`f2`kOK6}b(cIq!8}V69VEZ7~DY+XqPiO9JvqW%Z%v zedVGs;<}Lc^L>2^oz0L>qRo-dDL5p>NHGx<4p(&U_aR;f4N zi&aueaYK3-9r85&WCXmMan}GxDyEkuCnpQ$QUCPq!m`S_Z8Wz5k3qqRe%tuMOg}{M TcGUOp&kdY##G{|tlM?;~q5u3A literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ce8e2e00ec26c0f41b3c6e13c846b5f8f61ad513 GIT binary patch literal 4077 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 ZeaYQ=NhAd-@NHK`Xk`zM5GLd~`FC$xG?5VF* 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 DxFfnO literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f8d3ef552dfd00e6ad7f995aab1810fa3fd5bdd0 GIT binary patch literal 3595 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 DxFfnO literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..005f0227db24431c123a492a944a318fc0d6bd70 GIT binary patch literal 6833 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 S8~>g<098dTg->!e;r{`S!zShc literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..005f0227db24431c123a492a944a318fc0d6bd70 GIT binary patch literal 6833 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 S8~>g<098dTg->!e;r{`S!zShc literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ce57d2615be29891a8b1ecb98edf4d96d734da44 GIT binary patch literal 13485 zcma)jbxa&i6y`#4X>oUVS=^<#yF1I`?(P(a#ic-TDDK4z6kni=FWN$JFIN11m%ChY ze_V2TGxKHM%$JwRBs0nP@)EQ(6tOYLFaQ7mwz86(&VShcKReog1AwH;`20Vhu~pJh z2LOVY0Dy=X0O0XISHvLz;L8I599sbZqIm!SvB&2gZSnsW5N%WyD^pQ960a^RliQ0PE{RaRb4^WVkN0<{Rtj8lF3IzVw*m-zF zd3d(;O{M-Xf~z~o5ghpcCN!b#M*l}(`9BeS99`|aeXLzQ{x?U2lUG0w$Ri5m7X=FZ zuOREL>-qoyA4FMBMlWFPtlz%PZpD-Fo58;1!PbHLLli3sB8|LeLKaH~u%Y+}%@?Ys zsOev~#6bA+ZhtVp1_L>($#AYW_yV5qEP3jU`2}B{oe50mfm4H62A%HGJm+U$ znZgXY`~R_LnGbK_H+yK(XHqE&%;?e?S*(AOwyWNnNYu3fJ&lDvp{#BS7%;5aiF$#! zn$qX!!5@QPVY@J2cJ>ozc5_dqi1c(IJt4YsEwqMru_u0Ua6j@5K>;7}X{jNlBU{-W zLkofkiK?j2hp5o#LTZz1Mr`7r4ZSnA7aU|Gn%vG@>NP%RWaB9-5vJFuv9wmvG0^aR z%+Dv~#Tvmu{3qr9bYaI&`)@ycv%wKTj&D*_F%DoSjR(YsKp+SSkbDS42edeV}LueTYAo=+i@(Pr3rLTh^epc(Cv&xeb zrYM&SBOdVlCVvb)AD^*E+=nGHbhNL#j`me$?~_Vj=Qby|XB=#;uJ;TZ38EN4#ej5l zQRmkG_Ku=yV=w~g=|0IN?piC-5G4M0uq!FsTX&%XKxS`Ke~+VA!DN<#G!7rKB3(Vf z%tty|3^wCzQe$Tp5ryFb9R(J?V;hTG)b*G36hVfj{y=+d9#m`wISe4e6dU{31e+-m zB@hNgt_tV`YEVI&*gqhZ1u2+w#7W&~{Ox-E8n9QiVkk4O&VzwurOcG469-MNZM2d^ zD<)MjZoi%>5Mii*9cz7+&&uPDlR9k?lpb@15QqU1|SJn~yFmy3{DrQ;Z|wQx=78D^fHEBmj4nPI$Gp@^ z2CK}Djkc1YRBJIF#MG+5Mu$(DkIT!kA0CnRjH!@$)D&`Nz#I+jW05A4gDn6UX{4hF z1XOc7rYL-sL~$S)&XRdVwPbLLP{>#KKp~dAIrQYhwet;J$k{b;J7ezeFH{grJL9~ryL-0!EAj~1k{bjGPsJpl2 zrxLqQG-TfZisn!7Q;E4x_%{k@_z9>N;29Z{uf1QU8&&T;bzJ`vdw(XIl`1kpGVl=Y5af=x6Vqr@kt zRaPjnoF1XFjR1`x+E|Px0{kq2SMg?2xbk*Po;gokO}+^Y!k>};2@pb%Z?q9$)~zxX zYun!{^Ow*YdBTIlJRRzwlpg}*3V4w}MgF=7Tpb*9u-V-XD-PxT20Pm4k802IFGeTR zX8DduOMw5oB9aG&%T{T-$UmnxPf_u6Al&XKk3d=|UI9012y5`f`YjR0Ftsv}*!XSX z37kTjxoBm+T^WH0V&_T#&GDQJtDSZB9Hjfq)qB{h7?=ptS%GbMSsmNoC-8dU@js_( z|H(~ZYHxjfq~vzn$`|`hDTbQjcB2oz&?(3zLymc}!o9&!Q?3T^O=Fed;8yx09HQq{ z8UYL^CXi@^n4sUm9WQPMZyB&G_1@OIPXB!atae#$JZZAYj3HSxBtvn8j%RIoz6`k* ze*bFOB7A*|AcAb(GJDHi9;MT9EHFBs{Df?^PAZqsbJsr&l-Gw48aj7vh5}* z@6j9Q%VJ0;=rm!$=}c`%M~sM>bn=fCiz30pXp?G(s!1t;U*~wq4hg^fTOM;$!W>_k z?JWj&DV2s(V!coopwN2hDcO9xMlD8p8IQyaz$B-#%&uc@d{(f+FeT5F6_R>0qB7!S zoGM^&-T18)=KfDcTZF60)+S3VB`g1(2I==p9(_Oj{qJQ^Bnf%URuvSP_8rF<)}?H{ z_h3gnnY?6Zj47T^N&{2EmE`Mt`xFGoo;s9czJ>7g=`JFo{-|=rKcsT zsIqh-uMD~Q>3J|oCW}@pf$q<0c3%V{9uut#iY*fmLT579;DYg66pa7ZOpq1PW`EsE z`A-0Q5vV8w{FyCDjJ*LrSQp#qN^Tl6#^2E(y#^hJUx=muL5EErXuegofXq62Zyz589-v1 zmxe4hZPLbFFXN7#E~7x=s32VmlLr{>UpTen)1+DUclaCYy+ofCa)9HSk;<>*iy5*O zI!MLraAFRt4mM6^O z0X~&;pNba|%0C$UlW)q=MM!_;5jCkg*f7Apz}gHE40xp)x@bDv zXiJS?3lt_wpQJ=27`zk```tYugj*xXSri|z3ct$jESXJmx*vKLAhO3rCku(HP|7Vg z%9^Tgp8upL_?2#+2Ja@;x1EfQwJ@j(K~Mce1&d9B4>XH{niON)o22D@tNff4cD5^` zIp_l{2TY`<4FugD#d_y$h5Ok%@!Kc8xIw_5dEn|eU(c4AH(Mf@GBGp($F7ltb}|~; zmhIfQEJOkeK?ue)(c-ia9|!=UqvRj=60IAZ^waRkI5P@D4CPt)US;*cY~bMA@7v9( z`N?`3zJ{+^0}Qb2z{tKR=&WwNnYqk+FB)fSENK(>{K!VtA!R2k%QAy3S>z&{DRiEh zF~LMUIkY#~$TJ~udh3l?W@0UGslSUk!UlGVcT-$cOoT=VYj(RjBxj9w4a(>(_#`ZEp zxebj)CyHghhW2q7dP%2T0U4ulJTh9=z$@K8T@Eu?5n{63CNT8>bO6R4?nwI3IjCO@ z6FC(WZ7;zX=^Bc{Jj|AEpRS?Qy@F&V0eMHIEweIJ0Du`r- zu#%Xvbr1`_`{9leG7#*i^Y$kN!{cSjV>QBl_!xDh#5~x~sq;`Hd~vh2oxhw}9o(oh z&t8qftv1iH0HRD%lQs4N&=OR3my#*9XyW+P#Q7FO*hWb+oJH2jKlG?8FJ6AJw7(HE z9a*+iAI|CyY&dVDoui=^fBl=Qan~ky%sl7j{#|$T7zB8Sr562S##9zfF`r&}(>6(4y569HsQ8wE~LrzD_hbim(q<0`}l6(&hBZ>(u9z-wu5}*gY$ON z)X-DcYrS}WZ=Bl&v$Ih4mS!JHX)`oTX__ZfEFY!oHy!weZIO1>k=sUvEefXEwNb@u8tKI^=cX_nc**A84TY z_hz}_3W`(BzL&0f%qQY}V<3lI9Z_XWlMmx3k9$CrM*>fiwVQG!gE!wtq<-B7YaWCw zR$4lkm(7TAc)w|{;2XHkhus;0&312j85TG~gLb_8RPv?}3d~Xqzj>WKvz9R)_t66f zBt-E{W6+QMXuOYw#ZF*cJStTN@^nlwIHC_DRYTL?F^`eiL?S_0BhOU0&K8=cc~dV! z;g7QhEApDyFDc}?ieOHY;rH97PqRza)niU=)YOBmEfMI_pJL{fHKTuR#voR-*JCvv ze8J&mB|K8X!&IBW)8LKPQx~#pzR+Ri14k${BYDHee+kJs4_> z=yV`Dv3e@YUl#a&l4g5Vq=P!iOaV8}bn$P!D?RhQ-I{_=K5|&(O)eA`1nuYXuQ%1b z+na$wvRp2Ay9eCvdWC);eRP@VMP_1Q@sc9G1geTZl8vsGI`U+qofwr>zLrh5wq+gW zkJ2o~7=sySfF>;~=L9rb{{-aY-!90vZqG-LOc!{j^E54cc*W#~0#@5fC_>Nb+NwK1 ztGoVB>z^MMiOiKrjKt87jMO-T;z}DAWiHXDz*0^63a%hm*K7t%H7CDGId>i*5L04l zV$mlBQ3WxObXM4sOo;uhiOiJ^huLO?SiiWK7e(CkKD~ z_yNnGpQ~ttP+-CT>U$RNMGav#=Vks$7=bHw3dwt6viPhJU_?4B58(vJr>tySMOtJs z-&931_;9*2#nhMXR^Z}$1RZU@TU%rublJ{T^szP{!=e;Aj2rV-mh z(rGam)f0*{VSUgLyq!Hfdf4xS>1gfd7QIfspuOq^AQ_1Ntxe;lTOCTT~ zF!{t%>c@=Eovr828VRmKmJk5@IQ)eP9;fm!_w4#A2B5PcsWNMaiS^u{&F$0|l zgu_FRix54Jt;Ix{xGd|$kxwT5lw^O_TWDJSfx9O5!@r-rr^ z&^Hi%e|ym=B_=>#Kk#(T-URM%4HUVyk(vLgiNfCD5UIRa%g)ZLYC_7;GPdXxSDnwo znp9bvO-fXzizZSD1^ardcNqL7PJX?Z<8D+dky4giUGGLj0K;9IqH>b*`~L>{^KjeV z$DRKxnH{`cddLja=2U#UhpCDs1tv@lIQt|5(Y;tIxx^ih>&(orfJiAH!FOttd#rr& z!phxfRc3zzRwovL`vP_0M_i9@x7kM@%@#64XU}Kd>KQ5i`%aE>KZTy`;FQ~sT)}$R zw}T(7EQuVcpKZ7P2J-6Pp%BwiGE~+7O(@$%?8i?$u9V~1kZGL#MKj;0tMXpCSqWhb z`h`~45oswzG@OV7*Z`m<@5j0p7V;*$`u0STG8Iu`tWcdj-AInM%^bx$;!7PkS zBkA<3S++IPxQ-b#tde#&+ufL51A%+L_d5Rm%b%6X+cu-N|3zLHaj-+@*4H2Er82kd zMihFPxj7iJ_p4iFqWF3DXvUXdZfJ-ueqWz`A(=r8ORt3|QHe;%2JK=)mb1Mjae|)- zuZjfUh5_A<;Y-XHsAgctn?1V$$UU?!1hn!ARWZoV-K#Aod1vZeh0Ip4a3gT(y?v1k zQia#+E~@3fyW=)|6Pk;G={ojyibPPmQQnByd$M_El8K{=LhmxQ6K4Do3Z{2!Sxlzh zc085FltX=fj43J!c>YX<4J6j0KkG8@y= z&Ga6XeI@kC+L%<3iiy!ViC*nBbkmTRUi~Jj zZf`MbxE(@?dflk4Q0euM`N8rvi9LAyZ^7MZ$@@mPskoagUr4Bk@(PlG3y)q_h7sdv zAR|J>FHFS})=Vpd1hs73;hz)g>e98(m34=2Da-y_fu0v-Ps1fw^n*cHzkEC|ChwLE za@E~W0ad?kzTMrWz^!wZH%Fc?73T(@t6$pq+AucX4jwx6>wZ^)(p27SUD$N+10)k7 zj0xJ(SB9tR7wZHebUb~i7ZT!&E_7V|w@;i#DtP|L;@@D<=5usMZaH2Th5)z!tv*y(b7%;Tb_GQ>%oKe%{Z~=ol-hMV1u~I-}TLU;PpZP)-%_V|8JpKIN z*0QkxB5-5Aw$GNAdtF@`8pcQ&84p8J5^v@1O_EZj`ta+wmaaBi%#8d@pxh@X$}*ws zuaf2MGNR}{3p-Ovs1kf|I&W}BX%4Xf(+?tv9E~$6AAkRg8iiE=R9k4^A$wP`uyBrQ z?!6?5TN5_$=Smkd|NA!h&8-lVVy>V@$6mlqIb3L9e{K$n7<5yakUVXQvghHB^V}a$L+pZ>a3i}}vt?AS1i;J*>+v}IuHm`q&{YmU~2-810 z=Tat3R0nHf-&N%^mE%#O(nzxbe`SntFoNdaxrBs<{@p#Kehn?i1`bCl@R=O&_bA@) zNv-sI?cKbN{+`opa>oQGbZoL(2L{6X|IR*6);Gk9cuNwZk{Fzq2nu_Jy<`_k66E$2aq=zf^qu$Js!(txRw@C;#-&7Ghr5>V)An%v zuG)J2I;a=bH&Aax&|-mxoMu4DQWe#om~=3wZP>CNqS9WCf}0rHYYxp9PJY~&ZU;G8Yu=!{<}J@+s->JDFdw@ z1^Z8Hh!mTPY-#6nhB+{Hu@Ndx4H9-w700~%5+NSEeuXbsbGuHOLPH8ZI>#dGse@#S27Y{-p#`>sms3Nrh->FCCz!VlqR zj$_|`d2Z{k+Z;Kl_Lo}g+cJ(?c5UbtiMxJSUiUwB&{m^Q`OY2o_&V0LmH2{b&!8C= zlxB)tCmDg5<;HqTlz1%ZWw2Hu2*{_C8C_lCy0Gc|%8K;w!`fj14V0yWWZ4OU997fi zSI1y?=*9Dz$J^m0^IVg~1shtUOH(U*i!fzlzjrSc_H>#4ezjD0?^DCd$q=-@s^s;C zs*BTMiG>BE7o@o4F$v{^JK?3uJEEwIy!uYbbt>={!W8)Y+aTf}R)t0AkFrdk8p}eC z+#gG>rzWG@n586oefuqjV^fOsGeicPO)ty}DXUHKMdJOQ&lOh&9v*idHaO-r!tSi= zHM7rpFx_}@N-PdQ(MdC*au=|U< zzohU$&WF^yuy+4F40iPbmnKs6kq4V=Z+9PNK0dSxhOYkbu)p)v+r`wgP`a0$b5vqe zQ**+3Qk!;2qShCs2)cp+=85DSFrKRK?!46VryIyyjEzSWslw0ClY0)STlvN&)w*FX z?kn5WjYXSMVgK4jZ)aqx+HW^&VP2k+W}yd!R5M8_>C6;!TcLhWg)-`fFiOUNyH2wm%-!Y_KpZ|7#mmxw;`dHEJ;pw^8 zYY7kU69|32`Lk7idt!hM$vjI*P0cf3o#}GpNNp<@?#VCie>z=WasQymXEJp%QgZsA zRQVIlJ7a?nib%!Nxy)-?Vxxsoy0`8?$Ma@wOKN(3;Id)X5-G`oGNndWAf2qR;u7}M zwEZnS0QtlF_mZpI{bejn>i&YsjX|F9!JBUOTxNKw9r;kp{Z?Dyc7TWmmm?{(`a)_4 zJQu@LRl#qdO*>JRt%m(W_ZR*Trb3t$k0O5KPo%uuuk(O>es{T|JFhZ~tb9_flY`fp z^ZwE7X;j5ve|w?MuV@ZE{T5Juv)QVq{*KVV9LjMZO4Wf>sMpJM)8RiIc|zgGU%MN5 zG}c^7Ut1xZGBCX9_g&f!rZ%KYR)4AaWot=)I@l8mGU?-D1kyqiTF_ftz8CmgzF}@% zpOt^APLup|tE_`zx^2#=KC%~b)*-aaHLJz3j)PeBa$kHJ_8=f8;a<9XdcK)78MovU zSKxz+sFOpJF#$=WS#!81k$isQ=X0JyjI3!mJq(FyN!SO{9PR?fF=lK#Uk}1V9vwno zCNp=_s2a?!Ria%&gO6v8%>L6?`Uir-hK4D+Q{tlUsa--JI?hkUv1tpXzI90%cTE*5 zGclONIjFx@gV=??LvzLouU_8?iPENO*tqr`j9Q@$p~H?UtQk@WN`;}`VY^LRVRtSo z)Z+iqyL@92SVNticPLMG`rHOQ?Y)8~=au5plNB$*+yBP;Gua<3ktmRzRr4>Bx!_yyThujKGlW5|qOP|2k4M*mL#k`FLmO z_tvL_6~T`&i>@yBb~Ykr`^zUye)fDloC!cTX8#NL`bE8EF?I#Y-@~n!(RMyGl}tHC zS^9j@@mD6i2{l$7(${DrVI^{rT@rSxivk4>Y1jgCX(@zW323OI%AN|f(~JDF?yN4fJzp-NrwA`yvc+& zd?=u0Uav#Ky~eZ%zV0lVoq>TGaYFBYosUkJ3_|Sra3Msf)Djd-?B+}k=;gfllR3kR z8@7G1`A2WkAH^htB0*rJHqU1$eElNpnl}Hob>|XSyLJZLT<_LhY#hykmuiq^&%;9woioXH(+wP<2AcxU zyRnm`ulE*fNknR3D*77p8Y2u@lNulIW^7p#4FC}Du)T&m{9=iDyf@(A^RJQJhO-tN zi|yjP?zo3;^Wd|%k~x_rBW4(_mmhKeysRYSg3KG z42L!ige`FIb2LmjUR>VnrX6%i`p;e^ZL=N5e5u=cHyfUGFYyr!c{gs0chRgTG6jA|;mM2kapuv`Nj{o$E zt`mPZKVm*|R!w!BwLYB?LUkv;u zO%oH=0=Vy`f{wX2;WKa!hkRwTbBVPQ(}Ud#Rgs;lS}pLua9-U~xr>4gExLv|m0|dw zMWuP{Sr;U;c2Ck$X?zwSJ`KIa>-luroZrdF0qT8fR~I4OkIDV7rlmFmmnTh|1!iWg zB$jBzSO*`d&D^%TCCs65?Qy$BQsJH9lR6xgejB`1S7>id3KJ5>eWVQZtG+ zQu75YovBY|%3%4AcyJVTa~W2?JAUu$J719`xM}7sYMWQ&{8VXLcdZ)Go0(#I?D=>I z+gqMqsTQ$B!pe8-7T%H)&YvcuwuW;54sXhejLlE+T6ScRfY(Sl;=OOTQ6Ecos2yX7 zNP#(N$s+hKjvkkU?Cl&KAGa|xf1vg^thPc+$Sxu@D)kav7(2^djx#2r{xn4=2fe4J zTJ0$84K-gxswKe9RYV=PPs(vf|L;Yy%)zLLl4}hx3AV2=1_zZN{Zu=y;WuO`9|lB2 zVNAm=|DtL5uzR<|POQS~#Mi_^n!CJ4>0{LD&o!)2_H0sb*STGP*mY=cu5wlF9c41x zFVqS7sc^(;(WfKuN{NYx44N&Hks7~Tkj1U=Kx4SyeFjYAs~Cydvn3>Bc!NL;zg!(e z!t^V<@%W7v*S7~={yeLMdU)Ji4Lw%HBIz}_X~fUc6JMM4dIl?ABd^me{Y0Kj7KcMOSs;m zzXxX5(4p=-c5ar>`X(l-=rgv80P=jdm*Y{{m;A`^rIxDJGeMuc-L$ijX&h_O4Qr+& zFXVyzu4Y>}#HZ)p>RXR~9Ya-v+g-CwzOSH9Z&%~p2uD(vAExTS_}=d=>!Ncr_#uZA zNbn~MBjfM4NwgdThza7p2lCi^%mfZ12U!+6zB)u`1-G^vVoI&dJMmH6U$pxt+yCbG z7CM&fH8qOGzh~n)MhzRR1Qn12_oLl6dj`m^n`Jxyt;HcHPnRE{Cbf2VbQ|E{LR9~K zd@VVu@GoZG4BQ&GCzrInP3 zhX`(R3dBPH96MMeS^7e0I}NUOdZ*8KKOSW-N{RL#K9kSX+l;rE;UdVe}w(O85XB2bxO z{j$jFC_XEVVBLYoK$|6(lP|Z44HeglUOqy=@-6e0x!e2U^54JT@O_jrb6brSrixx2 z-lVXT$8(kU*848=`U962W@Z#6pUpWC&keAS-db+knq5SLT69wpZ-!lxXpzW%r$ct_ z3^E!FHRZ@V$%i@_q*-xzJ10B(ZuP@W1U0FOD8vigo3LR~Sk=z`rQR`dfqQF9&V?4J zfsVU{dr@CeC{{*Kcb<1_B%AbKsZ$hMN(F2eoD!{+N%5EewpIMf@X};RISaRH82;#7 znr+*$L8j)+3Afpsw#<{TG~EpM3cD2cufla`>}VB4?knf#AQFN1x3+yPZB4HD@H&r} zL3H)&EmE)%-prWI4H!=!QzOCCNL4DJT|D>4B%RWlZxKG^GSAK4-?{XX-b{6X`G=J~tw)KaU$_W_I$j zk=t{X^4sOBH*w;r*)b?9dS!hSd}*2#KAbh|Dqd#4{-k)InPw4Ny>nDOkdN=>Soy8N(`?uVRwf!rC6a0{{*KjiBlBZ?YiG~*JTDMpfmt`Ms|+y* zzIq5-G!6p;X=Yq(|5fF=ahLDi<)71|qcu@mR)fnU6gBH|ar7qLjIgWzJS7u%-`tmF` z^6ImDdG&C#0Dp8^M(LMd^O@H+z^biJnsW2?wk^NcdMdc96+3z>b`4A}Y)R>iNNrHL zjNp^!(K1OU&n3)Tq9`w{p~Vx_uq~YB`zUS7p6>LC5|}PhAAXdQR6cXmR^7H+?c1N` z@-Y|dt`1-DrwHK)4$DHvOp2pWRlDceJHVSMUQ}?BT`@y}Ul{0Cq~sIH~Jvon?JPF?K&uzS$9E8%)`i>&h9&Ccmd{yQBmr=Ls< zOC3uCe4P%98jsrosZXo2IA8d3RF(Q1kr9Fn@=}}$Vmk%t9O0b@jCpb=$bU#9@V0&g zHK3~j$KAzt#tb{}KNW?9Gz*KjVyF2nD^=bu6kG9DMbv+cUskVPIBLJ&--vi?!d}Z& zcpfZGoc@}i9*c^Y+C$KWSiaoSVTIm_U9WDnX2$UIwbkXKrZXo)C4=npm|FebHTdoy zXdrx}!IVP=rsfxFAC=!hHn5B})R#4IvVo2MX8G-ha({{gW2T5gGpm|4Q{6|=XqV4Y z9dX9Z>EEG#FRg?jxUM=G5Jd0Ay5<*%pL6@+*1wQ*O~E;;qPcs`rzJ+VOr0wKP)D(J zIW!2aK$&$&KKk3V-#gRD-$eo;tXaqiIcRd}I6%xw{%J-lbXLw*-Az4gq4l$pL``KZr05cyQ8D#O^; z;=x%WDrfs&Ie}kfU8D6zbUP@jw0B?lJBAALd=Jhx%O4sgHp#(?BPx@`W=DEeP%(F$ z9pRGcWVP=rmQ*6H;4ky}>}cQCCqB@&9Tl=GP}bWq>GC-0<*3x|wSHEZbGzBli>n_U zA%=MCsR}WL9zL!+E3PGsfAaOqVrN{>hI-DU7xad{yno zS16nutFT^*#)Us7e2Kw;D;_xKovf>b!O9-Yj=)2 zrr0n@X2LF?+bgOqzR$QG=IEVS1afK^EYDIKhkMM{Vq^16;JT}g>ICK(>qe}@iv-Bj z-3XvKDp`xd$!3Yqj$?i(j#cxq1jc;c-Qk!@jE*Hcv?(5jRq4R5ou>FSeB%;2Dm2G` zmP~VrPcoNwKlynkqL156l@u5)lH7ZI0(2#vzh6DpQQQsC<8M}p>2|QyRzHSHCi<)S5}8@JXfY(Xb=_HQg+>JtwWT^WT2B^QRS55qfu z2+-5fAqxlGOc~6ZiIk-j;d%*VWg##gDbZNFek!wm-=xKUi$#}&Iu=yP%QR2mv=7Oj zl4Hrn#@WmGHU=9lciVysFXTvtQ+@k(6Gcq_?QWdTNiGEqpi#Iaq-y$^<~w%OZLP8_ zKj<5?=@1b-Z&-ze95osdgf*$6dk2o6WsI#h4@?fOCAZ}vm&@p;@a;m7U%F#o&^^$iy&Np^G~&W}QZ zX#zX4Bh72KYLj}oN!Z_cZy2(rzsy9B(VAVZsfI1PVr!;Fz=y>x>e179sh0r&W>?QJ z`%y}M5#8myMjz8;k?IDROK=ska2D++EbJ7uW>*Uy&~z$-v}-mu8|U~;g9?i-ED?v{ zy8~ZWixKJ6ZQ-q#-Sy5@t>*v_7?iA$fs38uvL1OF5L7stPC1mM{fZ!af~1IjJa6lt->!RXYffSBHGJ&&VS#VAthGVZ zeHPc(%iwt^G9Da> zJ0-9d?aRXtqY1&an~VrzUoYEhI>D-OP41+X$QsVoB@^fCRB-zY*qvvIknuAsW1e1i zu*zY+9E-Xf|L0Quwe$^g1SA*{M99-O{zuKF5gr;3Mt%h}BK;!=Jf(ch(F|U~+us=! z$teZzJ?-fP^@=DaJBN}RCZnEkccc)eWWu6w3HfPlyQy>FR(+iZVwrN;bzsW`C8DoO zCQ#4v&{p$#v}EtaMNDBHOQoF%aJ$!r?Lg1ExnH69Dmjz+fJbUiR&sLNLjQWu1Hg4(6Nm@#IeC9DUBuO&)RM|O~ z%hyG3;ZLu#`K>xrL~wW)#(4Oo(ppD)xH;4`x!*hOk?6j1!KTXCis$`vu0eI zlC5S3|92Gng@n5N0M6m+sbIet=IQo_jCBacvmE2FHxUt zdyaNlZ|FpHIt#7(B{1-I`j(foEQD12rR5u4w&2(HRjqF5a-nYH8^hvM?FIMJk-0VJ zWzPr{QJDW*o=ud5BcPjX4)&ld`WS+_a3?8 z(v0A7j;84@5dSjg5+H0)t_p`3jQ!1 zA%K$8QFAc`IbjkeY{>02=rAg1}NQBpqB{q9}?? zAy+eMq4NjQUAnPNl<7>W2Vlw%qFQ1ZQJ_;*cUqKH*Szbe~QPwKze*mFR6ASX zMp?ftmc}T{7$RF!NHWx<`FYOsoadb9kLTRweeS*Ib?$%noSW+GWGyOmSO@?BQCk}f zc3&O-DuVyH3Q_FF2Emggesbn-X=)&KERho!^52Ka&@##u6rK7WQXWb-V zBO6xV2%Q9#-x6Nwd!JqRgWWGmmGVHKPstmjSp&FsQ)Waw8q5Apcpju)Pzr^Z2 zZ@KIa3fsW#Tn=5>f;MkmkQ`R zvK=TO$IUO=F@^z!7wn_ELia%> z=dlP1e32(AQQZ5XcWC>W;j9LtG8HKdElE9=Ne5HFcm4v8XCfCT0#*CzYl;T$D|Ozn z*CUO=%hjKhT;x|%Qt#z8WR^hCuI40CI^nx}*0h7y?d}AF*V)!$v+a>l{`5!B+>|Wg z8Rgpa%~H?-g*yc2)9RNr<>fb1EozK-|E{q)9+}T*RdN>$nWvoXWvO)+uxOi6&!EJE z$K7!j>S%-lft;T8{YLQAq`T9#+_ImQQ?<1LMpCAe=kiZl|0Qe-g4I$)plD_FW=(Z8 z9|kH8-n^kR$<7@N5KR75HenafTgb3zGc#@r=thK;>NaK8klBOBB1k0ud`0kSOOZ6l z!sV@Lv!UsJ-x<$~)a_!<7UzCp{KlW}r9#t(9c({3J!4-$TgaO$VBrLz1f4s4SM^jzY?Z!V22Dzyxa zUK5ooc+vPiaAcZ6i>T5W)e<7BpfN_IR*8}F^C_<8KJtg)x;DR@$%VTge|+eUncK4Q zD2OwjGhg{Iken7$7=zz=87{G$*$gC|^z^exN%BQV67?WRxXjZFoV0uHw|{qkol71d z4s%kMm^-N3w^t(WP0X1n9aHi_IONoZKhr(eUvp51UzxuLautIS0OEc&u2{_`d|6l| zZM&h`)Xsd~o;lAOn63Kq+>C16Sg(mKrm08KQPyO#VUN5x90NY&=Lc$dk^On4XnJqn z^T=C0-OhGe)Q@k=g;{l8r=r8Z^%w<5Ja^t)O>u89AIE7+{*~Eq9po3C{*-U>7|qVj z%Q$?AYqr3SZb3zLMg%Q?xKd@(wzvDIg<;0L_@p={(Q+}X1~~)O0^3Aoirk6w195_~ zgH}_es^)lUFixFoTrRaS+Age_^`HoEYIb9d1*5*J&^-$k*Mp@XUqaBfhDtLm;Y$fc zl=2+wqP?W9JzB8+^Y=5ooOQYq6W`o@Bb4)cqyj&}ydVXZI3ansBr2Q)UM&_>GVbzw z=~df7sk}3#^R$CEn80yYi+g+d%jeev+Ydanz0JOzdtwq#eR`yt&38^v6yYjK0{3c& z--qnvG~{ZX$EmE{J$mc&E9ykTi6+#^Zk^gQPY-v}^v0gd8QvoQT%|puReJ@Um(H?N zBK0*{%O{!st!4Dpv-Gt?cJ&+1WNuZ$_vwH11g)xa1q!%DS)MU|Ti1BIiG^uSa}89N z9Z0l!IlS}~5R1X2#Jz5Ce?)qWQ_aLO;?{}3hBszoBV+t-vror;y~-0q9XUF`*gfq1 zg!(vVaN1!TEJYFYZO=J=At{H`q#`TFQp_pOzxA0IH}^A_^J@H6ccXrsuYbfa+bDpM zIv^Ne)}DDK&TB}e7f4sYaZ2>N{MvFY|K?My)GW=pwG>=rwzJkYxRdr!YwFizHF2AZ z>xk-(xD~qfo9iCpqijuwRU z?9cdYORYzk*Q!h*vm|4@fHWtsNK@`(!&}{lvXBR4Ub{lCCI@tn#eZrkcq?2; zoMH_xsm0>)!PKb62*2AqZ}RPjXH6i6(uokYd)Cm=DKA@!>S_I`UWY<>u&USYqqPZM zd_#h-Utm!1w^duuFB9S1Oj(biiP6Hj_4Vp=fSOXTa2_inxIQoSu~51d$>I?xseEnB z{>;U_sy0eCeZ89odoy3w^kF=F2LbzglQSTU_wV8qTc`hN912U#BPnlQLW3=F+09t7 z_`Q#3ct2^W@rCq?5rK!Y49yB5#hmNWBuZoW zhR8x5s~3cT<7*^gDl=rcauEU)U6`-)nz9mYpBtJ@Nj1Y*oF+$*@q)mBE^+>Gsa) zmDog~f5?y$NgqSe`9x+KLqb5^mje3%`OI}*<;TfY``ZFJXWc9=DlZ(=9N4}5**+jm58)jW$p>PKct zo)yFUyWrE$!%XxxXA>^6^i)Kx24kf~-W5Dq#i2yB6GADJQUVFo3&LR_K=h`0mW&8A zv$xeWR+Gl<+is-Mo7me!1*+`Ode0-QZdVfrHb@)=A9N<_fqECjI&#r>)1AWN)Y6uU zx}Ve9R%-1{u)<7LBc;wlnKp2W27j>t0JeyfzVY%^qP(yPT&~z4?o3T6ZOcdQOIEA% zSu9kC3VmyBd5qt+IE7GtKwTU-1n#|;yuQvi)_4BR_~t-M5lj2BTe!AUQbovByj*?- z`CYc8>>oPh`0`h{e>>qm;>Y!6NVFk6i)ta!wtv{P!$X-fts#uD_9%pazAa8b83Tzf z!2psKMm^Wn$}sH`US0i!2(ES5t;qU1y8?UWi9Hd8@dCE_5gv_-^S$pA=i^4$eF(-}b}J4{yM z(9qVh=*F%SBMd0pHh@{+i1Kiv{OP-rJLceCsA)}Abk@SUxR-GpbFx2Wt~-}qC@3jS z#$P*ydFgW&15QzdDaE9ii8%H^Vxc=&yUkZ#c7dLU)|MXhOul;Q11wIDOM^A<1LATD zkpckzyAL33h0>vl9HnMq!$%w~9R1&3u|9mDcxl-e!T0hm!NxH8IzhV5J0(IoO0i24 zHcHLPj3|f*6-;IgHWV(408u-$rei{2k)-qm5nbu~-_YCN7RrrGx)!mdTm=o-p@+y@)2{uG0I;=m!qlNJCH)7%vMu2N literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..469e3f969269c6d8f0a050978ff23fb394601549 GIT binary patch literal 10060 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

pzU-&5 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 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/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 533cc734..f0b2715f 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -9,6 +9,7 @@ import re import subprocess import sys +import tarfile import tempfile import time import tomllib @@ -101,13 +102,56 @@ def workspace(): def package_archives(): current, names = workspace() - 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 240362d0..c61e6524 100644 --- a/tests/scripts/test_release.py +++ b/tests/scripts/test_release.py @@ -207,12 +207,18 @@ def test_publication_order_rejects_cycles_and_facade_dependents(self): def test_package_verifies_every_archive_in_publication_order(self): names = ['argui-core', 'argui-render', 'argui'] with patch.object(release, 'workspace', return_value=('0.1.0', names)), \ - patch.object(release, 'run') as run: + patch.object(release, 'run') as run, \ + patch.object(release, 'verify_staged_archives') as verify: release.package_archives() - self.assertEqual(run.call_args.args, ( - 'cargo', 'package', '--locked', '--all-features', '--allow-dirty', - '--package', 'argui-core', '--package', 'argui-render', '--package', 'argui', - )) + self.assertEqual( + [call.args for call in run.call_args_list], + [ + ('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..e59c5fac 100644 --- a/web/browser-shortcuts.js +++ b/web/browser-shortcuts.js @@ -1,10 +1,18 @@ // 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/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'" />

props.component, () => props.app], launch) ref="frame" :src="url" :title="isApp ? t('appExamples.label') : t('gallery.label')" - :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 9493d03d..0f58c8e5 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", @@ -659,11 +668,11 @@ { "slug": "text-selection", "name": "Text selection", - "description": "Selection surfaces and contextual actions for retained text.", + "description": "Selection policies, rounded highlights, gradients and animated fills.", "source": "crates/argui-widgets/src/text_selection.rs", "feature": "text-selection", "category": "Components", - "gallery": "Typography & selection" + "gallery": "Text selection" }, { "slug": "toast", diff --git a/website/app/data/doc-example-sources.generated.ts b/website/app/data/doc-example-sources.generated.ts index 934c98f8..860948d8 100644 --- a/website/app/data/doc-example-sources.generated.ts +++ b/website/app/data/doc-example-sources.generated.ts @@ -1,13 +1,13 @@ // 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 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 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 .on_click(cx.callback(|app| app.completed = (app.completed + 1).min(3)))\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", 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', @@ -17,7 +17,7 @@ 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', layout: @@ -25,11 +25,11 @@ export const docExampleSources = { 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 runtime::{Context, Render},\n text::TextStyle,\n ui::{Element, Sides, 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 behavior = DialogBehavior::new("example-dialog", "Example dialog", self.open);\n let close_key = behavior.close_key();\n let trigger_key = behavior.trigger_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(trigger_key, "Open dialog", theme.button()).build(),\n content,\n )\n .on_open_change(cx.value_callback(|app, open| app.open = open))\n .build(theme)\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .background(theme.background)\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/docs.ts b/website/app/data/docs.ts index 3eb5edfd..9d553046 100644 --- a/website/app/data/docs.ts +++ b/website/app/data/docs.ts @@ -31,7 +31,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 +77,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 +104,10 @@ 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 tasks = `fn search(&mut self, cx: &mut Context) -> Result<(), TaskError> { let query = self.query.clone(); @@ -186,7 +181,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 +316,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 +394,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.', ], }, { @@ -581,7 +576,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"] }', }, }, { diff --git a/website/scripts/catalogue.mjs b/website/scripts/catalogue.mjs index 93970be6..0f9a6d47 100644 --- a/website/scripts/catalogue.mjs +++ b/website/scripts/catalogue.mjs @@ -20,6 +20,7 @@ const sourceOverrides = { 'async-tasks': 'pages/async_tasks', editing: 'pages/editing', 'custom-timeline': 'pages/timeline', + 'mobile-activity': 'pages/mobile_activity', } const extraWidgets = [ [ @@ -36,12 +37,6 @@ const extraWidgets = [ 'Resizable panels with limits, pointer gestures and keyboard control.', null, ], - [ - 'text-selection', - 'Text selection', - 'Selection surfaces and contextual actions for retained text.', - 'Typography & selection', - ], [ 'tree-view', 'Tree view', @@ -102,6 +97,11 @@ export async function createCatalogue() { gallery, }) } + const duplicateSlugs = items + .map((item) => item.slug) + .filter((slug, index, slugs) => slugs.indexOf(slug) !== index) + if (duplicateSlugs.length > 0) + throw new Error(`Duplicate catalogue slugs: ${[...new Set(duplicateSlugs)].join(', ')}`) for (const item of items) { if (!item.name || !item.description) throw new Error(`Missing gallery metadata: ${item.slug}`) await access(resolve(root, item.source)) diff --git a/website/tests/browser.mjs b/website/tests/browser.mjs index 07021b00..b78369fb 100644 --- a/website/tests/browser.mjs +++ b/website/tests/browser.mjs @@ -127,7 +127,7 @@ try { await page.waitForSelector('.copy-button[aria-label="Copied"]') assert.match( await page.evaluate(() => navigator.clipboard.readText()), - /git clone https:\/\/github.com\/ExtraBinoss\/argui/, + /argui = \{ version = "0\.3\.0", features = \["widget-button"\] \}/, ) await page.goto(`${origin}/docs`, { waitUntil: 'networkidle0' }) @@ -146,6 +146,9 @@ try { await page.$eval('.docs-live-example iframe', (element) => element.src), /\/examples\/docs\/index\.html\?example=first-window$/, ) + await page.$eval('.docs-live-example', (element) => + element.scrollIntoView({ block: 'center', behavior: 'instant' }), + ) await page.waitForSelector('.docs-live-example .status-dot.live', { timeout: 90_000 }) assert.equal( await page.$eval('.docs-live-example iframe', (element) => document.activeElement === element), @@ -157,6 +160,9 @@ try { await screenshot('docs-guide-desktop') await page.goto(`${origin}/docs/essentials/styling`, { waitUntil: 'networkidle0' }) + await page.$eval('.docs-live-example', (element) => + element.scrollIntoView({ block: 'center', behavior: 'instant' }), + ) await page.waitForSelector('.docs-live-example .status-dot.live', { timeout: 90_000 }) const themeFrame = await (await page.$('.docs-live-example iframe')).contentFrame() await themeFrame.waitForSelector('button[aria-label="Dark"]') @@ -286,6 +292,9 @@ try { await page.waitForSelector('.status-dot.live', { timeout: 90_000 }) const desktopExampleFrame = await (await page.$('iframe')).contentFrame() await desktopExampleFrame.waitForSelector('input[aria-label="Prompt"]') + await page.$eval('iframe', (element) => + element.scrollIntoView({ block: 'center', behavior: 'instant' }), + ) const harnessLayout = async (example) => example.evaluate(() => { const prompt = document.querySelector('input[aria-label="Prompt"]').getBoundingClientRect() From e7055f97a3d0a037ba248ed08a240fc81764ee34 Mon Sep 17 00:00:00 2001 From: albi Date: Wed, 16 Sep 2026 13:29:21 +0200 Subject: [PATCH 3/7] docs: expand overlays and platform guidance --- CHANGELOG.md | 6 +- app_examples/docs-examples/Cargo.toml | 1 + .../docs-examples/src/examples/mod.rs | 2 + .../docs-examples/src/examples/overlays.rs | 187 +++++++++-- .../src/examples/platform_roadmap.rs | 131 ++++++++ .../src/examples/platform_support.rs | 102 ++++++ app_examples/docs-examples/src/lib.rs | 5 + app_examples/docs-examples/tests/behavior.rs | 29 +- docs/todo_simplify_api_checklist.md | 4 +- website/app/assets/css/docs.css | 43 +++ .../app/data/doc-example-sources.generated.ts | 6 +- website/app/data/doc-routes.ts | 2 + website/app/data/docs.ts | 317 +++++++++++++++++- website/app/pages/docs/[...slug].vue | 16 + website/app/pages/docs/index.vue | 12 +- website/scripts/docs-examples.mjs | 2 + website/tests/browser.mjs | 56 +++- website/tests/docs.test.mjs | 4 +- 18 files changed, 887 insertions(+), 38 deletions(-) create mode 100644 app_examples/docs-examples/src/examples/platform_roadmap.rs create mode 100644 app_examples/docs-examples/src/examples/platform_support.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f4ba3b8..b6a2c622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,11 @@ 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. ### Fixed diff --git a/app_examples/docs-examples/Cargo.toml b/app_examples/docs-examples/Cargo.toml index 74d9b077..72df8951 100644 --- a/app_examples/docs-examples/Cargo.toml +++ b/app_examples/docs-examples/Cargo.toml @@ -17,6 +17,7 @@ argui = { workspace = true, features = [ "tasks", "widget-button", "widget-dialog", + "widget-popover", ] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/app_examples/docs-examples/src/examples/mod.rs b/app_examples/docs-examples/src/examples/mod.rs index 850da772..63522f05 100644 --- a/app_examples/docs-examples/src/examples/mod.rs +++ b/app_examples/docs-examples/src/examples/mod.rs @@ -12,6 +12,8 @@ pub mod installation; pub mod layout; pub mod mental_model; pub mod overlays; +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 db015982..95fe552b 100644 --- a/app_examples/docs-examples/src/examples/overlays.rs +++ b/app_examples/docs-examples/src/examples/overlays.rs @@ -1,48 +1,195 @@ use argui::{ + core::Color, + paint::{Border, CornerRadii, PaintStyle, QuadStyle}, runtime::{Context, Render}, text::TextStyle, - ui::{Element, Sides, 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 behavior = DialogBehavior::new("example-dialog", "Example dialog", self.open); + let text = |value: &str, size: f32, weight: u16, color: Color| { + Element::text(value).text_style(TextStyle { + color, + font_size: size, + weight, + ..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 content = Element::column([ - Element::text("A real modal portal").text_style(TextStyle { - color: theme.foreground, - font_size: 24.0, - weight: 700, - ..TextStyle::default() - }), - Element::text("Focus stays inside until the dialog closes.").text_style(TextStyle { - color: theme.muted_foreground, - ..TextStyle::default() - }), + 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, + self.dialog_open, Button::new(trigger_key, "Open dialog", theme.button()).build(), - content, + dialog_content, ) - .on_open_change(cx.value_callback(|app, open| app.open = open)) - .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) + .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_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/lib.rs b/app_examples/docs-examples/src/lib.rs index dc372e2f..8ba8ca65 100644 --- a/app_examples/docs-examples/src/lib.rs +++ b/app_examples/docs-examples/src/lib.rs @@ -81,6 +81,11 @@ pub fn launch_example(example: &str) -> Result<(), Box> { "tasks" => launch("Tasks", examples::tasks::Example::default()), "data" => launch("Data", examples::data::Example::default()), "overlays" => launch("Overlays", examples::overlays::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), diff --git a/app_examples/docs-examples/tests/behavior.rs b/app_examples/docs-examples/tests/behavior.rs index f7dae788..614e7441 100644 --- a/app_examples/docs-examples/tests/behavior.rs +++ b/app_examples/docs-examples/tests/behavior.rs @@ -67,8 +67,18 @@ fn i18n_controls_update_the_controlled_locale_and_count() { } #[test] -fn dialog_opens_and_closes_through_its_typed_state_handler() { +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") @@ -77,6 +87,23 @@ fn dialog_opens_and_closes_through_its_typed_state_handler() { app.assert_no_text("A real modal portal"); } +#[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()); diff --git a/docs/todo_simplify_api_checklist.md b/docs/todo_simplify_api_checklist.md index f9f801d7..3157b334 100644 --- a/docs/todo_simplify_api_checklist.md +++ b/docs/todo_simplify_api_checklist.md @@ -141,9 +141,9 @@ the protected branch. 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 10 interactive documentation examples passed independently. + tests and all 12 interactive documentation examples passed independently. - Android and iOS cross-checks, the complete native and Wasm example workspaces, - all 24 staged crates.io archives, and 107 generated website routes passed. + all 24 staged crates.io archives, and 109 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 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/data/doc-example-sources.generated.ts b/website/app/data/doc-example-sources.generated.ts index 860948d8..bedb5b88 100644 --- a/website/app/data/doc-example-sources.generated.ts +++ b/website/app/data/doc-example-sources.generated.ts @@ -25,7 +25,11 @@ export const docExampleSources = { 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, Sides, 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 behavior = DialogBehavior::new("example-dialog", "Example dialog", self.open);\n let close_key = behavior.close_key();\n let trigger_key = behavior.trigger_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(trigger_key, "Open dialog", theme.button()).build(),\n content,\n )\n .on_open_change(cx.value_callback(|app, open| app.open = open))\n .build(theme)\n .width(percent(1.0))\n .height(percent(1.0))\n .padding(Sides::length(28.0))\n .background(theme.background)\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', + 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: diff --git a/website/app/data/doc-routes.ts b/website/app/data/doc-routes.ts index bdc8b9c5..060d5e88 100644 --- a/website/app/data/doc-routes.ts +++ b/website/app/data/doc-routes.ts @@ -16,4 +16,6 @@ export const docRoutes = [ 'architecture/project-structure', 'architecture/clean-code', 'architecture/custom-elements', + 'platforms/support', + 'platforms/roadmap', ] as const diff --git a/website/app/data/docs.ts b/website/app/data/docs.ts index 9d553046..ac4a7bc2 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 @@ -648,7 +650,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', @@ -662,6 +664,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', @@ -724,7 +733,7 @@ export const docs: DocGuide[] = [ }, { slug: 'architecture/mental-model', - category: 'Architecture', + category: 'Technicalities', level: 'Intermediate', minutes: 18, title: 'The Argui mental model', @@ -734,6 +743,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', @@ -763,7 +823,7 @@ export const docs: DocGuide[] = [ }, { slug: 'architecture/project-structure', - category: 'Architecture', + category: 'Technicalities', level: 'Intermediate', minutes: 13, title: 'Structure a real application', @@ -806,7 +866,7 @@ export const docs: DocGuide[] = [ }, { slug: 'architecture/clean-code', - category: 'Architecture', + category: 'Technicalities', level: 'Advanced', minutes: 16, title: 'Write clean Argui code', @@ -852,7 +912,7 @@ export const docs: DocGuide[] = [ }, { slug: 'architecture/custom-elements', - category: 'Architecture', + category: 'Technicalities', level: 'Advanced', minutes: 22, title: 'Create a custom element', @@ -891,9 +951,252 @@ export const docs: DocGuide[] = [ }, ], }, + { + 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 }}
+

!M>tP9qwE5N3Oh8~dK3~hXn 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 EZ+oIX^#A|> literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c1ee826b88a254cf34c36a853b0f5c7eba1137ab GIT binary patch literal 3059 zcmZ{mX*ARi7sr2w7#hql6)H0rYsDCmHQQ(y`!G?8NVW(WGnRkWv6hIDWGvaTR4Uom zkRD}U>d#oR6vC8sgyHd==Q+5U)(o0!O8;1&nLzQ006&c34GJ`5rN@O?GG>f744uVgqRpZ@9}V@F(Js${6JBs5e`3B2SFgad&)JVWRwr1Mz z)ja9f^{O>L58kMBY_E;@HB0{UTX9w8Vz-H$EoDw42L-8pYYz`7DSuTD9UL{*0|;N` z&pF2Qe?Oi#MG>DCAGWbrq@fc%#+d99^c2>L$n8Tz(X? z*z&BooG7&fOQtB9w_rU3y4aIp`w%4mDa}w4H3Gb#?=k0>Jg8>=aByz9XEHo`M+DW; zk(SIK>1E5?W*R|CmV1;Hwlt|$H|OR%bs(DpJ(2U2^o7ql9?KC>IG*3yG&i_WnNi#& z%_8r%fbj@_nVe9#OP7K4a70;uJgP3X!4qHVX zFR#bv%cL53shfMaDuNno1@ zsscy6yz#(x?W~&eUt#hSX0>749b=4>AD_c7Jy3^$${n8B(4x@7RsfhwK>@irThZE? zqZ#g7S1DOd(0Rl9G4iIUo)0Hmh?l$UE|CD`FMGnxozDf;hkl>mcUK8VXNaH5a_rj7 zPLh?WXl||<50p120ArWj!J-NsdcCL<$x9efs;dX+XQ0ZeUBm7rUNUtcw@Iz7IzKF$58w zmepegzn>2!0)oxBEZ+VKWfaqnYdl_27peeKrRCg;c4(9-@F@tC_`%&H>0{uq+NaY-H8O*jkVhj_lMw|!*}r-MVC*d(m8R( zDKLHgAlE}8cx}A*bH%&QX`3@06U>-*LL)s{vKBu33xOIKM|%&$2W6_Dd)E8!KhaBC*-4a-iVqss3r0#cgq2(#5M(tId4_qR zGpK*~xeK&98WT@TS?Z=8d`D0+p4Z1M^>Ci>?kOKx!u8TV>rT?M&pQhg=?R^d0HdQ) zkl9RT4#)0nnVMl{=+@rY)IP52yjo!GgqHu^Uk^Xb_g~H@F%o1rEoz?W4x@;z+64yEJRz0`e*6G%CaOa?V@bEq?K{N6Tgx9NlKYiYm z_&h3(8cP1#sFJ2oH8%7|$#Re3^b&DJBo{GpN>|rEw52kb*mvq`(}qPR{e=TXy+=eN z1D}(e7wfa@g$2TUN7kZ>((&aYU0a1bn{pPX7<1%o9<`-KRZ#J+X7wp_Dlg1nOw)VTv zV;0@4re1qPhG5eU6(o2`E$8zVB|u@ za^FJ}d9WmSHIHYJnNxZFys&+0p3Jc-i;b1ciXSa|j^V*%7YQGW?5xqhOFwB0^CJRo@%}=Ic*}2G#}N zUhC*+qP!X}m(@HQ3C3cBUc-GWubauZ6&8-&w=V+HwjWhw?=MmmEfJ!b*|%BAbn*Hc zr;L$Jrj!li*Y)d4Hz(v~VEU{ZtErVuNH41qu;~LR%sR3@caL3gurresW7y8)z7_J> zy=<5L>8r26IfVD+$(Ck@=!hS8v~@mhd<-EStuS2ZdQAN#ChAi&`-k1quY;bgLEHKM z4vU68kEYRz875zHcURNxuWF6V)&gp%ytMmnbur4SL@Zm9>E^<1w44+^O(8e$_Ni-L z)YffW!C8{kc1_fLH{9lNH=?XGRY-qd{@#vo7+Xw&*ED<3`f>)6u^e6b@q#J zlR>N5nK2c8GC6P3$1S;MJIwmC7K=Qzu#j@e)1k)K{+7Wpm^GKt5hi?&yDlf}HEb#?h&zD(U4V6LWqpI6MtZ;IhV#wP@rR9s&9pTndecNbby zn2wHCjHRd1TuJr%=odi)0;*jh~-c&p+ZF0vk$K(nJ2rXDD+x zJDGrNV&cnc-vWt9V@bROo=kyq6)e&(&R6W!UQkJ`gd`1nXGN9S$kyL`W@CZ^VeEr% zKwwGnt*!nGAELQ*7JOLUT`P8ziKPlP?<{1W%~jJnSH4A|j$W3GvXbD09pS@VYn_3` z3w?Nh|FX*}$BVT01kas6S7(KyLn(sTg!pf*AJ@7| zN2_utN-kwG==VcCciv4*&l)*@Qjp67zejRMr^n~@oA#Uf%Pz)DCXeD`FHeT_mp1j( zEwV!SAvNMBg(JNNK@uNY<)A_kSO|JSS_Jl}%TLK#toL;JoyL}_fnuL5$BNb$qq;{p zrfGhh(pRus^0@SrI#rBB>~5_>@%-IuQVYtC_K3C7)|+6+}_LwWhVnDZs>!z{KC{r?nSQGYE~f^8`Et zU&ENZeaR;mt;f(Zm7%&IpQE|8A@xg;PD9bUpsqR^y=jilh$Vi~8u9jmz&5gAxd5)P+}U05s$iydc1c>fQOoEn=LpZWiRn~-Ud4*2@N7t#}x<9O-E zlGFafcw*c<7&t5mhbQ4Y{(WR5d4vf7hPeThy$t_8)y0wG%g4>04E>&?Ikxi8pqW9* zQ)^s%q4nEf+RKhxtU#CQP-%nz$AY)QT%q<1ft_V)VCqWgouj38Y?-4%^LI8?<~Iby z%vxWRI8`W>WWj`{Ykso#>!xz4rmfbnczHT=+jG5DZ(Ip?G?ey=~IBUJPpK2QXBaC*E|v93dIY(=HShlF&oh zy-32i7a7gIDcAQSING63?lYk5PkYu0ceyfs{l?{mtE%4nFFqho;L&tLR1Amj>*s?X zF1xJAY_u`&#i((yMq7Q;kWwT zked1B&A$OqOC@M@pdA2m1?{{fQjk(T&aNdb-QyoG&2r3pV{Nb(RE~&*K2jbqnzS7g z@KRN)X}nvR?@wyP8;MulReB}Sm1d{3biSL>fTb|^)ZN9td#sipPH`HJMt+~35*gHQ zblb0Ga!cr_5~O^!;<%c9r;4{!+~5B^r1zH!&CqTbE$ZRuEZ$i^4m<#!Qmf;gFX8@x zU)ml;TrV@eSD)GG`G7IO8VOJOy!`OBFUAwsz{2x+1dpntpewf0!aZwq*ZHTvi6UD$ znI|PjlGZ-m5g89W2HuPn4EE5NO?dM2J6R?fALm~xK7Ud6yB|B5@{i7KaE)sY5!ZhP zB_kH)OQi>@)&!_Fzun#+FKOSsaChyfEktWx53!8BejrA4R3WLe4q>U|4t%SGp=daR{&-TW|2LXnzS=l&G*> zgQH#TZ|cY7+{=CQ)nl02q65xr=YO5;QM(CY+q&E|trMjRBC^GNdgXOIuUVjFStZJ2 zkzus!?+7�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�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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f7672d509dea613659fd4af67055bb81a3aa33de GIT binary patch literal 31565 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 z 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8c580eb8836ecf4f21e0ae412b87d8d64d4247b5 GIT binary patch literal 4762 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d4f6a97aa3c8f4fe7427cf9cba6cd5c4f361e30b GIT binary patch literal 9190 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..36d0a06faaee22d4013640e6cb2daa840ad4af32 GIT binary patch literal 66797 zcmeEs_fylsw>Bk^&_d`^0tsEDN$)KnAR-7TsFWZGD7}O9geEl?f@DwE=*g_Hz$fXhxFu8nc zexmSig6Nfw=D#15#d3-L`vQ7s>uLbE$f)VL1vmiR9sf)`9vUVd)|MWx<*eLZ|LX|E zM8%|pL}i8UOB#uZ%85zHiAmlQ6_pbez2;Gq`deDos+6-rj^>(etrUQ6XXTIWQO}2o*V+S-$?ss&((Q@A_Tb?3Yv7%QdCHR&$eo zzRpM0{5Ex0IC?J+(}<$KYfzw-!1@0N|E~lgh+&=Mnxx>YI^6xX%7UGc5OdaX6T_n7 zm@{>IEv4d~lW@Bi6?~`v(tC{e_LOmI<+gqNwq^0Q;?u43!>jBjLK9%}Wl#rU2?4o^ z0%_09fi$gTK|aO!bD*267I*d3I2NXgo!d$pR z8$zB_tn)01A!QmZg1xa+1Pzcm_X}GuuBo4xV}h4dcm|hQgqy&WePu(Zwo`&&jD1kLQw%BG=-AmtIv|8(3cg1= zNrw|&>P52>;}u=JgMXHtO@kpE!~-oD%CIxPNTF}fBkd6e#ED#3v(;$a%b<&Pufu%r zjlPT6ix)Ra1seBzl(}{wx5G<|{GlE3@wvB)204T-VyU`Ij{)X@s-un@1JFa*r9kp0 zvO;-8?7BW9;vfqXK!YNIcoBTLH#Lo%Bm|KQFyc-2C!G(FUWBLMM)oBZbPE7yhV-7;dqefD0euZfoXn{KCmQGM)6xqEH{QYB zAUR0Dm)+a?UvHNI1RYY zRij75cV@A0Ckh-^yPAW4t^bAmAs4?nRT>Fq9^r>@3SM(`aSlEw9P~y;paNd3R?gE5 z+9GX;m{7A|Tsblh*Z^#d3sm1F#ItENqj$*hDq3LDS#q4uwfF9W#gXSVo}F<7r^Hj` za9_eXI~R^%MMm6LOvMw|$HJ&C5WjzZN1`K)tTPMTO6LF9BL3rzv$?ht<$BGy&E+C>719_cK)f)6yD6uxzz*k;HrJti{#5&u~)m=#p@o}7sU96 zPM@gLDKv2>vyQ0c4vu0=_#7~IxztYRO~NgXWv_}GH6yH-nj&NQk|c|qL|qf&+VwZ6 z!{p(CK=N)_Rvyuhz#yO4A%GY0)XU z$_sdD^VR&lR9qIgZo(V79KlNqqpRhK8|U^QdLur&y=rb!Q!E$FJ5HEE5_x6^k($q5 zXuP;fUkEEn%t(iHMCm=s+wAx9EF(vS7HdnO8GIlPIdXaqNQ(ZRYpCb=* zy0LFmQ2xfE0G|9I8|3)NJeKCRZ;mFT9qd>$wU3Vcy7%d z2R>0mCzQA<8Y~9h0qsOA@uJ#jr@%5V^FPZ|50YSx3FZ5kdUs%f0NhWxWbOd?CQypd zkFBx(LT-Kl=|^mVZevZHgfnl-_Yz_Rfejo}e4QV*)g1MJ^7@%p2CTHe19g7u+!|?a zYPG|pszm~a?b8^BAd%|(LNT|>T-VDt@v9bhQ6aZKt}P|1HocLCoesraJxoiyDrb=K z?`la^zZ&Jop=4>Ro&FmwJ=)Ekq(^ni;_)Hd6gCVlF}?N;<)G1ZcF+RAorRkTl}U&l zfGB`RG@ALx2uz6%dYTD<+wfE>yuAxYsmiq6k~yItAaw`c^s?n~!w|=l-xnhd>L+}g z;CiTwAUB^HJao+;37E>iCTV(KOvr=mKv}a)8FCd^w%DkQB{oD-ZM&(xXz&o+%K%<7h#zK^j*nOOk zkbV+P+MyQBm5Y_(S5(hrdl{^Rd?lLuz{Fi4l@)g9k8iLo+LvigT;-*xyh`m0vus}$ z)Dd=Yr0QoN@vgMzlVwYcFS)7lzuV?y$(+<3*4QMV5uu{7IDDe5!{6sXR+y(iZ(p-bM5d@jwHHjbPVLYluXiEKwX= zNjUp6eiH#>E}~i3r5K1sN~^G`d_s45LT)J!RFNE~)qeLnEavv51BGu7fhkIeTMqcO z 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 QKfFJRvT8EbK+}-_2mj@8MgRZ+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d4f6a97aa3c8f4fe7427cf9cba6cd5c4f361e30b GIT binary patch literal 9190 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..509b300212689c8a6d4713c18cf03b546f14c18d GIT binary patch literal 15085 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