From 806dee516732740da3fd01a00669faf58b6e3472 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Sat, 1 Aug 2026 10:17:00 +0200 Subject: [PATCH] =?UTF-8?q?render,=20types,=20ci:=20resolution-time=20dp?= =?UTF-8?q?=20with=20per-canvas=20density,=20bounded=20GLES=20image=20cach?= =?UTF-8?q?e,=20clippy=20gate,=20backend=20capability=20matrix=20Length::d?= =?UTF-8?q?p=20no=20longer=20collapses=20to=20absolute=20pixels=20at=20con?= =?UTF-8?q?struction:=20the=20design=20value=20travels=20in=20a=20new=20Le?= =?UTF-8?q?ngthBase::Dp=20variant=20and=20the=20density=20multiplication?= =?UTF-8?q?=20happens=20when=20the=20length=20is=20resolved.=20Previously?= =?UTF-8?q?=20dp(=20n=20)=20baked=20in=20whatever=20density()=20returned?= =?UTF-8?q?=20at=20view-build=20time,=20so=20correctness=20across=20output?= =?UTF-8?q?=20changes=20depended=20on=20the=20view=20being=20rebuilt=20aft?= =?UTF-8?q?er=20set=5Fdensity=20and=20in=20that=20order;=20now=20a=20densi?= =?UTF-8?q?ty=20change=20is=20picked=20up=20by=20the=20very=20next=20paint?= =?UTF-8?q?=20with=20no=20reconstruction.=20Length::resolve=20keeps=20its?= =?UTF-8?q?=20signature=20(process=20density),=20and=20the=20new=20Length:?= =?UTF-8?q?:resolve=5Fwith=5Fdensity=20takes=20an=20explicit=20factor.=20d?= =?UTF-8?q?p=20becomes=20const=20in=20the=20bargain.=20Density=20also=20be?= =?UTF-8?q?comes=20overridable=20per=20canvas,=20the=20first=20step=20towa?= =?UTF-8?q?rds=20surface-local=20responsive=20state.=20SoftwareCanvas=20an?= =?UTF-8?q?d=20GlesCanvas=20carry=20a=20density:=20Option=20analogous?= =?UTF-8?q?=20to=20the=20layout=5Fviewport=20introduced=20for=20sub-canvas?= =?UTF-8?q?=20fluid=20resolution:=20None=20means=20"use=20the=20process=20?= =?UTF-8?q?global",=20Canvas::set=5Fdensity=20pins=20a=20local=20factor,?= =?UTF-8?q?=20and=20sub-canvases=20inherit=20it.=20All=20canvas-routed=20r?= =?UTF-8?q?esolution=20honours=20it=20=E2=80=94=20geom=5Fpx=20/=20font=5Fp?= =?UTF-8?q?x=20for=20stock-widget=20design=20pixels,=20and=20the=20new=20C?= =?UTF-8?q?anvas::resolve=5Fgeom=20/=20resolve=5Ffont=20for=20explicit=20L?= =?UTF-8?q?ength=20values,=20which=20every=20widget=20now=20uses=20in=20pl?= =?UTF-8?q?ace=20of=20the=20raw=20l.resolve(=20canvas.viewport=5Flayout(),?= =?UTF-8?q?=20EM=20)=20pattern=20(row,=20column,=20wrap=5Fgrid,=20spacer,?= =?UTF-8?q?=20container,=20separator,=20button,=20text,=20rich=5Ftext,=20t?= =?UTF-8?q?ext=5Fedit,=20list=5Fitem,=20vslider,=20image,=20and=20the=20co?= =?UTF-8?q?ntainer=20draw=20path).=20Overlay=20sizing=20keeps=20resolving?= =?UTF-8?q?=20against=20the=20main=20surface=20with=20the=20global=20densi?= =?UTF-8?q?ty,=20which=20is=20what=20it=20describes.=20New=20tests=20cover?= =?UTF-8?q?=20explicit-density=20resolution,=20resolution-time=20applicati?= =?UTF-8?q?on,=20the=20local-over-global=20override=20and=20sub-canvas=20i?= =?UTF-8?q?nheritance.=20The=20GLES=20image=20texture=20cache=20is=20now?= =?UTF-8?q?=20bounded.=20It=20was=20content-keyed=20but=20unbounded=20and?= =?UTF-8?q?=20never=20evicted,=20so=20a=20stream=20of=20distinct=20buffers?= =?UTF-8?q?=20=E2=80=94=20a=20photo=20carousel,=20video=20thumbnails=20?= =?UTF-8?q?=E2=80=94=20grew=20GPU=20memory=20for=20the=20lifetime=20of=20t?= =?UTF-8?q?he=20canvas.=20The=20cache=20now=20tracks=20an=20estimated=20by?= =?UTF-8?q?te=20total=20(RGBA8,=20w=20=C3=97=20h=20=C3=97=204)=20against?= =?UTF-8?q?=20a=2032=20MiB=20budget=20and=20evicts=20least-recently-drawn?= =?UTF-8?q?=20textures=20on=20insert;=20the=20most=20recent=20entry=20is?= =?UTF-8?q?=20never=20evicted,=20so=20a=20single=20texture=20larger=20than?= =?UTF-8?q?=20the=20whole=20budget=20still=20draws=20and=20simply=20owns?= =?UTF-8?q?=20the=20cache=20until=20replaced.=20Drop-time=20cleanup=20is?= =?UTF-8?q?=20unchanged:=20drain=20deletes=20whatever=20the=20map=20holds.?= =?UTF-8?q?=20CI=20gains=20a=20Clippy=20step=20(workspace,=20all=20targets?= =?UTF-8?q?,=20test-support,=20-D=20warnings)=20sharing=20the=20build=20ca?= =?UTF-8?q?che=20of=20the=20test=20job,=20with=20make=20clippy=20mirroring?= =?UTF-8?q?=20the=20invocation=20locally=20and=20CONTRIBUTING=20listing=20?= =?UTF-8?q?it.=20Run=20make=20clippy=20locally=20before=20pushing=20the=20?= =?UTF-8?q?first=20time=20=E2=80=94=20the=20gate=20has=20not=20seen=20the?= =?UTF-8?q?=20tree=20yet=20and=20pre-existing=20lints=20will=20fail=20CI?= =?UTF-8?q?=20until=20addressed.=20docs/backends.md=20formalises=20the=20s?= =?UTF-8?q?oftware/GLES=20capability=20matrix=20that=20was=20previously=20?= =?UTF-8?q?scattered=20across=20per-method=20rustdoc:=20parity=20set=20(fi?= =?UTF-8?q?lls,=20strokes,=20text,=20images,=20paths,=20path=20clips),=20g?= =?UTF-8?q?raceful=20degradations=20on=20software=20(flat-fill=20gradients?= =?UTF-8?q?,=20no=20shadows,=20no=20backdrop=20blur,=20hard=20bottom=20edg?= =?UTF-8?q?e),=20GPU-only=20features=20(external=20textures),=20the=20shar?= =?UTF-8?q?ed=20Oklab-fallback=20limitation,=20and=20the=20cross-backend?= =?UTF-8?q?=20blit=20panic.=20Linked=20from=20README,=20onboarding=20and?= =?UTF-8?q?=20architecture's=20known-gaps=20list,=20which=20now=20states?= =?UTF-8?q?=20the=20parity=20gaps=20explicitly.=20The=20dp/density=20prose?= =?UTF-8?q?=20in=20architecture.md,=20lib.rs=20and=20the=20Length=20rustdo?= =?UTF-8?q?c=20is=20updated=20for=20resolution-time=20semantics=20and=20th?= =?UTF-8?q?e=20per-canvas=20override.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 14 +++++- CHANGELOG.md | 7 +++ CONTRIBUTING.md | 1 + Makefile | 7 ++- README.md | 1 + docs/architecture.md | 6 ++- docs/backends.md | 33 ++++++++++++++ docs/onboarding.md | 1 + src/draw/layout.rs | 10 ++--- src/gles_render/image.rs | 48 ++++++++++++++++++-- src/gles_render/mod.rs | 14 +++++- src/gles_render/setup.rs | 6 +++ src/layout/column.rs | 6 +-- src/layout/row.rs | 4 +- src/layout/spacer.rs | 10 ++--- src/layout/wrap_grid.rs | 10 ++--- src/lib.rs | 10 +++-- src/render/mod.rs | 90 +++++++++++++++++++++++++++++++++++-- src/render/setup.rs | 2 + src/types.rs | 77 +++++++++++++++++++++++++------ src/widget/button/mod.rs | 6 +-- src/widget/container/mod.rs | 10 ++--- src/widget/image/mod.rs | 8 ++-- src/widget/list_item/mod.rs | 2 +- src/widget/rich_text/mod.rs | 2 +- src/widget/separator/mod.rs | 4 +- src/widget/text/mod.rs | 2 +- src/widget/text_edit/mod.rs | 6 +-- src/widget/vslider/mod.rs | 6 +-- 29 files changed, 323 insertions(+), 80 deletions(-) create mode 100644 docs/backends.md diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cfff65f..dfa8e37 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -3,14 +3,16 @@ # Runs on every push and pull request against `main`. Two independent # jobs share the cache layer but otherwise execute in parallel: # -# - `test` builds the workspace and runs the full test corpus. +# - `test` builds the workspace, runs the full test corpus and lints +# with Clippy (`-D warnings`, sharing the build cache). # - `audit` runs `cargo audit` against the RustSec advisory database to # catch dependency CVEs without waiting for a human to remember to run # it locally. # # Note: ltk uses Modified Allman style (not rustfmt's default), so there # is no `cargo fmt --check` step here on purpose. If rustfmt ever ships -# a stable Allman config, add a third `fmt` job. +# a stable Allman config, add a third `fmt` job. Clippy is unaffected by +# the formatting style — it lints semantics, not layout. # # Compatible syntax with GitHub Actions, so swapping host providers does # not require touching this file. Gitea also reads `.github/workflows/` @@ -51,6 +53,7 @@ jobs: uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} + components: clippy - name: Cache cargo registry and target uses: Swatinem/rust-cache@v2 @@ -61,6 +64,13 @@ jobs: - name: Test run: cargo test --workspace --all-targets --features test-support + - name: Clippy + # Same flags as the build so the artifact cache is reused and + # test code is linted too. Warnings are errors: an allow with a + # justification at the offending site is the escape hatch, not + # a CI-level mute. + run: cargo clippy --workspace --all-targets --features test-support -- -D warnings + # External-Markdown doctests. `cargo test --doc` only sees doctests # inside `src/`; the cookbook and widget reference under `docs/` # are stand-alone Markdown files that rustdoc has to be invoked diff --git a/CHANGELOG.md b/CHANGELOG.md index 341d6c0..dbf0e69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Added +- **Per-canvas pixel density** — `Canvas::set_density` pins a canvas (and the sub-canvases derived from it) to its own density factor for `Length::dp` resolution, overriding the process `set_density` global; `Canvas::density` reads the effective value. New `Canvas::resolve_geom` / `Canvas::resolve_font` resolve an explicit `Length` in geometry / font space with the canvas' density — widgets now route caller-supplied lengths through them, so a `dp` override follows the canvas it draws on. The hook for surfaces on outputs whose DPI differs from the process-wide one (an overlay on a second monitor, an embedder with several `UiSurface`s). +- **`Length::resolve_with_density`** — `Length::resolve` with an explicit density for `LengthBase::Dp`, instead of the process `density()`. +- **`docs/backends.md`** — the canonical software/GLES capability matrix: what renders identically, what degrades gracefully on software (gradients, shadows, backdrop blur, bottom fade) and what is GPU-only, replacing per-method rustdoc archaeology. Linked from README, onboarding and the architecture known-gaps list. +- **Clippy in CI** — the `test` job lints with `cargo clippy --workspace --all-targets --features test-support -- -D warnings`; `make clippy` mirrors the invocation locally. + - **`ListItem::trailing_icon( rgba, w, h )`** — right-aligned icon slot (disclosure arrow) drawn at `TRAILING_ICON_SIZE` (21 px) and vertically centered, alongside the existing leading `icon`. Coexists with `trailing` text, which shifts to the icon's left. Symbolic icons should be pre-tinted by the caller (`tint_symbolic`), matching the leading-icon contract. - **`ListItem::pad_h( impl Into )`** — per-item override of the horizontal inset between the row edge and its content; without it the theme default (16 px) applies. - **`Row::align_top()` / `Row::fill_height()`** — pin children to the row's top edge instead of the default vertical centering, or stretch every non-spacer child to the row's inner height (the row itself is still sized by its tallest child), for siblings whose natural heights differ by a few font-metric pixels. @@ -22,6 +27,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Changed +- **`Length::dp` now applies the density at resolution time, not at construction.** The value carries its design pixels in a new `LengthBase::Dp` variant and `resolve` multiplies by the density in effect when it runs, so a `set_density` change takes effect on the next paint without rebuilding the view's lengths — previously a `dp` value was frozen to the density read when it was constructed. Behaviour is unchanged for code that sets density once at startup. +- **The GLES image texture cache is now bounded** to 32 MiB of estimated GPU memory with least-recently-drawn eviction (the in-use entry is never evicted). Previously it grew without limit for the canvas' lifetime, so a stream of distinct buffers (photo carousel, video thumbnails) could exhaust GPU memory. - **`OverlaySpec::size`** is now `( Length, Length )` (was `( u32, u32 )`), resolved against the main surface when the overlay is materialized; wrap existing sizes in `Length::px( … )` for the old fixed behaviour. - **`TextEdit::font_size`**, **`TextEdit::fixed_width`** and **`Separator::thickness`** now take `impl Into` (were `f32`), resolved like the button label (font space) / geometry space; the `f32` sentinels are gone (`f32` call sites still compile via `Into`). - **Dependency pins**: `fontdue = "=0.9.3"` and `ignore = "=0.4.23"` are pinned exactly because newer releases require a rustc newer than the declared Rust 1.85 MSRV (Debian stable's toolchain). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a57fc1..172c06b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,7 @@ make all # cargo build --release make test # cargo test --features test-support make doctest-md # typecheck the Rust snippets in docs/*.md make stylecheck # mechanical style checks (tabs, attribute spacing) +make clippy # semantic lints, warnings as errors (mirrors CI) make audit # cargo audit (installs cargo-audit on first run) make doc # cargo doc --no-deps make examples # run every example under examples/ in turn diff --git a/Makefile b/Makefile index f19f9b2..4533a10 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ DOCDIR ?= /usr/share/doc/libltk-doc/html # ignore filesystem entries with the same name — without this `examples` # silently no-ops because the `examples/` directory exists, and `doc` # would do the same once `target/doc` is around. -.PHONY: all test doctest-md stylecheck audit doc install examples clean distclean +.PHONY: all test doctest-md stylecheck clippy audit doc install examples clean distclean all: cargo build --release @@ -25,6 +25,11 @@ doctest-md: stylecheck: ./scripts/style-check.sh +# Semantic lints, mirroring the CI invocation. Warnings are errors so a +# local run predicts the CI verdict. +clippy: + cargo clippy --workspace --all-targets --features test-support -- -D warnings + audit: @command -v cargo-audit >/dev/null 2>&1 || cargo install cargo-audit --locked cargo audit diff --git a/README.md b/README.md index a89dfd1..f10277c 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,7 @@ on the roadmap. | [`docs/architecture.md`](docs/architecture.md) | Runtime model, overlays, animation, theming, performance and where the cost of a frame lives. | | [`docs/widgets.md`](docs/widgets.md) | Per-widget catalogue: what each one is, when to use it, minimal example, see-also. | | [`docs/theming.md`](docs/theming.md) | JSON theme schema, slot conventions, runtime APIs. | +| [`docs/backends.md`](docs/backends.md) | Software/GLES capability matrix — what renders identically, what degrades, what is GPU-only. | | [`docs/cookbook.md`](docs/cookbook.md) | Concrete recipes — slide-in panels, password fields, runtime theme toggle, channel-driven state, embedding without `ltk::run`. | | `cargo doc --open` | Per-item rustdoc for the public API. | | [`CHANGELOG.md`](CHANGELOG.md) | What changed in each release, and what is pending unreleased. | diff --git a/docs/architecture.md b/docs/architecture.md index 68cd121..490ac73 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -193,11 +193,11 @@ Every size in a widget tree is a `Length`, resolved to concrete pixels at layout `ltk` offers two adaptation strategies, and both live in the same `Length` type so an app can mix them per value: - **Fluid** (`Length::fluid(n)`, and the raw `vmin` / `vmax` / `vw` / `vh` / `orient` units): surface-proportional. `fluid(n)` reads a single design pixel `n` as `vmin(n / fluid_reference() * 100).clamp(n * FLUID_MIN, n * FLUID_MAX)` — at a surface whose short side equals the reference (412 px by default) it is exactly `n`, and it scales with the short side elsewhere, auto-clamped to `[0.7n, 1.5n]`. This tracks the width in portrait and the height in landscape, because the short side *is* the width in portrait and the height in landscape. `orient(portrait, landscape)` is the escape hatch for a different percentage per orientation. -- **Physical** (`Length::dp(n)`): constant physical size. `dp(n)` is `n × density()`, where `density()` is a process-wide factor (default `1.0`, typically set from the output DPI via `set_density`). It does not scale with the surface, only with pixel density — the mainstream HiDPI `dp`. +- **Physical** (`Length::dp(n)`): constant physical size. `dp(n)` resolves to `n ×` the pixel density (default `1.0`, typically set from the output DPI via `set_density`). It does not scale with the surface, only with pixel density — the mainstream HiDPI `dp`. The multiplication happens at **resolution time**, not when the `Length` is constructed: a `dp` value carries its design pixels, so a density change takes effect on the next paint without rebuilding the view's lengths. Stock widgets do not hard-code either strategy. Each carries a design pixel per dimension (e.g. `button` height 48, font 16) and resolves it through the process-wide `WidgetScaling` mode: `Length::widget(n)` returns `fluid(n)` under `WidgetScaling::Fluid` (the default) or `dp(n)` under `WidgetScaling::Physical`. `set_widget_scaling(mode)` flips it once for the whole app. An explicit `Length` on an individual widget (`button.height(...)`, `text_edit.height(...)`, `font_size(...)`) bypasses the mode entirely — the mode only decides the meaning of the *default* design pixels, never an override the app wrote on purpose. -Both `density()` and `widget_scaling()` are process globals read during layout; set them at startup (or, for density, whenever the surface moves to an output with a different DPI). Because they are global, ltk's own test suite serialises the tests that touch them. +Both `density()` and `widget_scaling()` are process globals read during layout; set them at startup (or, for density, whenever the surface moves to an output with a different DPI). Because they are global, ltk's own test suite serialises the tests that touch them. Density is also overridable **per canvas**: `Canvas::set_density` pins a canvas (and every sub-canvas derived from it) to its own factor, and all canvas-routed resolution — `geom_px` / `font_px` for stock-widget design pixels, `Canvas::resolve_geom` / `resolve_font` for explicit `Length` values — uses the local density when one is pinned and the process global otherwise. This is the hook for a surface sitting on an output whose DPI differs from the one the global was derived from (an overlay on a second monitor, an embedder with several `UiSurface`s). `Length` adapts *sizes* to the orientation; to adapt the *structure* of a layout (a row of panels in landscape, the same panels stacked in portrait), branch the view on `ltk::orientation()`. The runtime records the main surface's physical dimensions on every configure (also readable as `ltk::viewport_size()`) and rebuilds the view after each resize, so a `match ltk::orientation() { Landscape => row()…, Portrait => column()… }` follows the window live. The portrait/landscape rule matches `Length::orient` (a square surface counts as portrait). `examples/clip_path.rs` shows the pattern. @@ -322,3 +322,5 @@ Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1 **xdg-activation-v1 — wired in.** Both directions work: a token found in `$XDG_ACTIVATION_TOKEN` at startup is used to activate the app's own window once it maps (so an external launcher can raise an ltk window with focus), and an app that spawns children requests fresh tokens through `App::take_activation_requests` and receives them via `App::on_activation_token` to place in the child's environment. **Fractional scale — deferred.** `wp_fractional_scale_v1` (so 125 % / 150 % outputs render natively instead of via compositor downscale) remains tracked as upcoming protocol work. + +**Software/GLES parity gaps — see [`docs/backends.md`](./backends.md).** The software backend renders gradients as a flat fill from the first stop, skips outer and inset shadows and backdrop blur, and hard-cuts the bottom-edge fade; `oklab` gradient interpolation falls back to linear-light on both backends. The capability matrix is the canonical per-feature table and must be updated in the same patch that closes any of these gaps. diff --git a/docs/backends.md b/docs/backends.md new file mode 100644 index 0000000..312207d --- /dev/null +++ b/docs/backends.md @@ -0,0 +1,33 @@ +# Backend capability matrix + +ltk renders through one of two interchangeable backends behind the same `Canvas` API: **software** (CPU rasterisation with tiny-skia + fontdue into a `wl_shm` buffer) and **GLES** (GPU rasterisation via EGL + OpenGL ES 2/3). `ltk::run()` selects GLES when an EGL context can be created and falls back to software; an embedder driving `core::UiSurface` can force either. `Canvas::is_software()` lets a caller branch on the active backend at draw time. + +The two backends are kept at visual parity for the common primitives — solid fills, strokes, lines, text, images, vector paths. This page is the canonical statement of where they differ, so the information does not have to be reassembled from per-method rustdoc. When a difference listed here is closed, update this table in the same patch. + +## Matrix + +| Capability | Software | GLES | Notes | +| --- | --- | --- | --- | +| Solid fills, strokes, lines (`fill_rect`, `stroke_rect`, `draw_line`) | Yes | Yes | Visual parity. | +| Text (`draw_text`, `draw_text_with_font`) | Yes | Yes | Both snap pen positions to integer pixels; glyph caches differ (per-canvas hashmap with eviction vs. shared atlas texture) but output is at parity. | +| Images (`draw_image_data`) | Yes | Yes | Both snap destinations to integer pixels. GLES caches uploaded textures content-keyed, bounded to 32 MiB with LRU eviction; software draws directly from the buffer. | +| External GL texture (`draw_external_texture`, `External::texture`) | No-op | Yes | Software has no GL state to sample from. Use `External::cpu` for a backend-independent immediate-mode draw hook. | +| Vector paths (`fill_path`, `stroke_path`) | Yes | Yes | Both rasterise through the same tiny-skia code, so shapes are at parity. GLES uploads the rasterised path as a texture per draw — costly for large animated vectors. | +| Rect clipping (`set_clip_rects`) | Exact multi-rect mask | Bounding-box scissor | On GLES several dirty rects clip to their union; software masks each rect exactly. Branch on `is_software()` when exactness matters. | +| Path clipping (`set_clip_path`) | Yes (coverage mask) | Yes (offscreen layer + anti-aliased coverage composite) | Visual parity; different mechanisms. | +| Gradients (`fill_paint_rect` with `Linear` / `Radial`) | Flat fill from the first stop | Dedicated shaders | Software gradient rendering is a known gap; tiny-skia could render them natively but is not wired up yet. | +| Gradient interpolation spaces | `srgb`, `linear-rgb` | `srgb`, `linear-rgb` | `oklab` is declared in the schema but currently falls back to linear-light **on both backends** (shared LUT builder). | +| Outer drop shadows (`fill_shadow_outer`) | No-op | Analytic soft-shadow shader | Software shadow rendering is a known gap. | +| Inset shadows (`fill_shadow_inset`) | No-op | Yes, with `normal` / `plus-lighter` / `multiply` / `screen` / `overlay` blends | `overlay` routes through an FBO snapshot on GLES. | +| Backdrop blur (Glass surfaces with a `backdrop` block) | Not rendered | Yes (aux-FBO Gaussian pipeline) | Themes ship `-flat` surface variants without `backdrop`; nothing selects them per backend automatically — pass one explicitly when targeting software. | +| Bottom-edge fade (`blit_fade_bottom`, `viewport.fade_bottom`) | Hard edge | Feathered | Software ignores `fade_bottom_px`. | +| Cross-backend blit (`blit` between a software and a GLES canvas) | — | — | Unimplemented (panics). Never occurs in practice: sub-canvases always share their parent's backend. | +| Pixel readback (`read_rgba_pixels`) | Yes (un-premultiplies) | Yes | Both return tightly packed straight-alpha RGBA8. | +| Presentation | `write_to_wayland_buf` into `wl_shm` | `eglSwapBuffers` (with damage when available) | `write_to_wayland_buf` is a no-op on GLES; `present` is the GLES path. | +| Animation frame-rate cap | ~30 Hz default (overridable via `App::cap_software_animation`) | Uncapped (compositor frame callbacks) | The cap protects the CPU path from sustained full-rate rasterisation. | + +## Practical guidance + +For a theme or app that must look identical on both backends, stay within the parity set: solid fills, strokes, text, images, paths, path clips. The visible divergences are gradients, shadows, backdrop blur and the bottom fade — all degrade gracefully on software (flat fill, no shadow, no blur, hard edge) rather than failing, so a software rendering of a GLES-designed theme is flatter but functional. + +The pixel-level test suite runs on the software backend; the GLES paths that need a live GL context (layer composites, shaders) are exercised by the examples. There are currently no golden tests comparing the two backends' output — parity for the common primitives is maintained by review and by the shared rasterisation code where it exists (paths). diff --git a/docs/onboarding.md b/docs/onboarding.md index e6cc5e4..474cf84 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -446,6 +446,7 @@ then the cookbook, then architecture: theming, animation and performance - [`docs/theming.md`](./theming.md) — JSON theme schema, slot conventions, runtime APIs +- [`docs/backends.md`](./backends.md) — software/GLES capability matrix - [`examples/showcase.rs`](../examples/showcase.rs) — smallest visual tour - [`examples/widgets.rs`](../examples/widgets.rs) — broader widget coverage - [`examples/mini_shell.rs`](../examples/mini_shell.rs) — overlays and shell diff --git a/src/draw/layout.rs b/src/draw/layout.rs index 5378eba..749cf63 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -212,12 +212,10 @@ pub( crate ) fn layout_and_draw( { canvas.stroke_rect( rect, color, width, c.corners ); } - let vp = canvas.viewport_layout(); - let em = crate::types::Length::EM_BASE_DEFAULT; - let pad_l = c.pad_left.resolve( vp, em ); - let pad_r = c.pad_right.resolve( vp, em ); - let pad_t = c.pad_top.resolve( vp, em ); - let pad_b = c.pad_bottom.resolve( vp, em ); + let pad_l = canvas.resolve_geom( c.pad_left ); + let pad_r = canvas.resolve_geom( c.pad_right ); + let pad_t = canvas.resolve_geom( c.pad_top ); + let pad_b = canvas.resolve_geom( c.pad_bottom ); let inner = crate::types::Rect { x: rect.x + pad_l, diff --git a/src/gles_render/image.rs b/src/gles_render/image.rs index 4618cb5..1164fec 100644 --- a/src/gles_render/image.rs +++ b/src/gles_render/image.rs @@ -15,6 +15,14 @@ //! serve the stale texture for the new content. Content-keying makes //! that impossible: identical bytes → identical key, regardless of //! where they live in memory. +//! +//! The cache is bounded to [`IMAGE_CACHE_MAX_BYTES`] of estimated GPU +//! memory (RGBA8: `w × h × 4` per texture) with least-recently-drawn +//! eviction, so a stream of distinct buffers (a photo carousel, video +//! thumbnails) recycles textures instead of growing GPU memory for the +//! canvas' lifetime. The most recent entry is never evicted, so a +//! single texture larger than the whole budget still draws — the cache +//! then holds that one entry until something replaces it. use std::collections::hash_map::DefaultHasher; use std::hash::{ Hash, Hasher }; @@ -26,6 +34,12 @@ use crate::types::Rect; use super::helpers::{ ortho_rect, upload_rgba_texture }; use super::GlesCanvas; +/// Byte budget for the image texture cache. 32 MiB holds a phone-sized +/// wallpaper (~10 MB at 1080×2400) plus a working set of icons and +/// thumbnails; sized for mobile GPUs where this memory competes with +/// the compositor. +pub const IMAGE_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024; + /// Compute a 64-bit fingerprint of an RGBA buffer for the texture /// cache. Hashes the full byte slice for small buffers (icons, /// thumbnails — below 16 KB ≈ 64×64 RGBA), and falls back to a @@ -85,6 +99,13 @@ impl GlesCanvas { let tex = upload_rgba_texture( &self.gl, self.version, rgba_data, img_w as i32, img_h as i32 ); self.image_cache.insert( cache_key, ( tex, img_w, img_h ) ); + self.image_cache_bytes += ( img_w as usize ) * ( img_h as usize ) * 4; + self.image_cache_lru.push_back( cache_key ); + self.evict_image_cache_overflow(); + } else if let Some( pos ) = self.image_cache_lru.iter().position( |k| *k == cache_key ) + { + self.image_cache_lru.remove( pos ); + self.image_cache_lru.push_back( cache_key ); } // Snap to integer pixels. With GL_LINEAR sampling, a @@ -107,9 +128,9 @@ impl GlesCanvas let mvp = ortho_rect( self.width, self.height, dest ); let alpha = opacity * self.global_alpha; // SAFETY: see `primitives.rs` module doc. `*tex` is owned by - // `self.image_cache` so it outlives the call. The image cache - // stays valid as long as `&mut self` is held — no eviction - // path runs concurrently with the draw. + // `self.image_cache` so it outlives the call. Eviction ran + // before this borrow and never removes the most-recent key, + // which is `cache_key`. unsafe { self.gl.use_program( Some( self.tex_program ) ); @@ -126,6 +147,27 @@ impl GlesCanvas } } + /// Delete least-recently-drawn cached textures until the estimated + /// byte total fits [`IMAGE_CACHE_MAX_BYTES`]. The back of the LRU — + /// the entry the current draw is about to use — is never removed. + fn evict_image_cache_overflow( &mut self ) + { + while self.image_cache_bytes > IMAGE_CACHE_MAX_BYTES && self.image_cache_lru.len() > 1 + { + let Some( key ) = self.image_cache_lru.pop_front() else { break }; + if let Some( ( tex, w, h ) ) = self.image_cache.remove( &key ) + { + self.image_cache_bytes = self.image_cache_bytes + .saturating_sub( ( w as usize ) * ( h as usize ) * 4 ); + // SAFETY: `tex` was created through `self.gl` in the insert + // path above and just left the map, so it is deleted exactly + // once. Deleting a bound texture is defined in GLES (the + // binding reverts to 0); no draw is in flight here. + unsafe { self.gl.delete_texture( tex ); } + } + } + } + /// Draw an externally-owned GL texture into `dest`. /// /// The caller owns the texture and is responsible for keeping it valid diff --git a/src/gles_render/mod.rs b/src/gles_render/mod.rs index 2114e51..dd5fe3d 100644 --- a/src/gles_render/mod.rs +++ b/src/gles_render/mod.rs @@ -45,7 +45,7 @@ //! method — see the module's own doc for when to use the guards //! and when not to. -use std::collections::HashMap; +use std::collections::{ HashMap, VecDeque }; use std::sync::Arc; use fontdue::Font; @@ -157,6 +157,11 @@ pub struct GlesCanvas /// the same inside offscreen content (scroll viewports, clip layers) /// as outside it. pub( crate ) layout_viewport: Option<( f32, f32 )>, + /// Canvas-local pixel density for `Dp` resolution, when the owning + /// surface sits on an output whose density differs from the process + /// [`crate::density`]. `None` falls back to the global. Inherited by + /// sub-canvases. + pub( crate ) density: Option, pub global_alpha: f32, pub width: u32, pub height: u32, @@ -349,8 +354,13 @@ pub struct GlesCanvas // frame (the cache would happily serve the stale texture). // Content-keying tolerates that case at the cost of one // `DefaultHasher` pass over the bytes per draw call — fast for - // any reasonable icon size. + // any reasonable icon size. Bounded by `image.rs`'s + // `IMAGE_CACHE_MAX_BYTES` with LRU eviction. image_cache: HashMap<(u32, u32, u64), (glow::Texture, u32, u32)>, + // LRU order of `image_cache` keys, least-recent at the front. + image_cache_lru: VecDeque<(u32, u32, u64)>, + // Estimated GPU bytes held by `image_cache` (RGBA8: w × h × 4). + image_cache_bytes: usize, // Gradient LUT cache: FNV-ish hash of the 512×RGBA8 LUT bytes → texture. // Gradients are theme-derived and constant across frames; caching avoids diff --git a/src/gles_render/setup.rs b/src/gles_render/setup.rs index 273c404..25e7cd4 100644 --- a/src/gles_render/setup.rs +++ b/src/gles_render/setup.rs @@ -337,6 +337,7 @@ impl GlesCanvas font_registry: None, dpi_scale: 1.0, layout_viewport: None, + density: None, global_alpha: 1.0, width, height, @@ -459,6 +460,8 @@ impl GlesCanvas atlas_row_height: 0, glyph_cache: HashMap::new(), image_cache: HashMap::new(), + image_cache_lru: std::collections::VecDeque::new(), + image_cache_bytes: 0, gradient_lut_cache: HashMap::new(), clip_scissor: None, clip_layer: None, @@ -550,6 +553,7 @@ impl GlesCanvas dpi_scale: self.dpi_scale, layout_viewport: Some( self.layout_viewport.unwrap_or( ( self.width as f32, self.height as f32 ) ) ), + density: self.density, global_alpha: self.global_alpha, width, height, @@ -672,6 +676,8 @@ impl GlesCanvas atlas_row_height: 0, glyph_cache: HashMap::new(), image_cache: HashMap::new(), + image_cache_lru: std::collections::VecDeque::new(), + image_cache_bytes: 0, gradient_lut_cache: HashMap::new(), clip_scissor: None, clip_layer: None, diff --git a/src/layout/column.rs b/src/layout/column.rs index 6718eb6..bfde951 100644 --- a/src/layout/column.rs +++ b/src/layout/column.rs @@ -122,19 +122,19 @@ impl Column #[ inline ] fn resolved_spacing( &self, canvas: &Canvas ) -> f32 { - self.spacing.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) + canvas.resolve_geom( self.spacing ) } #[ inline ] fn resolved_padding( &self, canvas: &Canvas ) -> f32 { - self.padding.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) + canvas.resolve_geom( self.padding ) } #[ inline ] fn resolved_max_width( &self, canvas: &Canvas ) -> Option { - self.max_width.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) + self.max_width.map( |l| canvas.resolve_geom( l ) ) } /// Report the intrinsic content width as preferred width instead of filling diff --git a/src/layout/row.rs b/src/layout/row.rs index 295bc36..f2c207b 100644 --- a/src/layout/row.rs +++ b/src/layout/row.rs @@ -92,13 +92,13 @@ impl Row #[ inline ] fn resolved_spacing( &self, canvas: &Canvas ) -> f32 { - self.spacing.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) + canvas.resolve_geom( self.spacing ) } #[ inline ] fn resolved_padding( &self, canvas: &Canvas ) -> f32 { - self.padding.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) + canvas.resolve_geom( self.padding ) } /// Push the content block to the right edge of the available width. diff --git a/src/layout/spacer.rs b/src/layout/spacer.rs index 0b5e226..f92c27a 100644 --- a/src/layout/spacer.rs +++ b/src/layout/spacer.rs @@ -115,11 +115,9 @@ impl Spacer /// weighted by `weight`. pub fn preferred_size( &self, canvas: &Canvas ) -> ( f32, f32 ) { - let vp = canvas.viewport_layout(); - let em = Length::EM_BASE_DEFAULT; ( - self.fixed_width .map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ), - self.fixed_height.map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ), + self.fixed_width .map( |l| canvas.resolve_geom( l ) ).unwrap_or( 0.0 ), + self.fixed_height.map( |l| canvas.resolve_geom( l ) ).unwrap_or( 0.0 ), ) } @@ -128,12 +126,12 @@ impl Spacer /// layout only needs the main-axis size for one orientation. pub fn resolved_height( &self, canvas: &Canvas ) -> Option { - self.fixed_height.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) + self.fixed_height.map( |l| canvas.resolve_geom( l ) ) } pub fn resolved_width( &self, canvas: &Canvas ) -> Option { - self.fixed_width.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) + self.fixed_width.map( |l| canvas.resolve_geom( l ) ) } /// No-op — spacers are invisible. diff --git a/src/layout/wrap_grid.rs b/src/layout/wrap_grid.rs index 01b3fb8..32c1fe1 100644 --- a/src/layout/wrap_grid.rs +++ b/src/layout/wrap_grid.rs @@ -116,7 +116,7 @@ impl WrapGrid { Some( m ) => { - let m = m.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ).max( 1.0 ); + let m = canvas.resolve_geom( m ).max( 1.0 ); let cols = ( ( ( inner_w + sx ) / ( m + sx ) ).floor() as usize ).max( 1 ); match self.max_columns { @@ -130,12 +130,10 @@ impl WrapGrid fn resolved( &self, canvas: &Canvas ) -> ( f32, f32, f32 ) { - let vp = canvas.viewport_layout(); - let em = Length::EM_BASE_DEFAULT; ( - self.spacing_x.resolve( vp, em ), - self.spacing_y.resolve( vp, em ), - self.padding.resolve( vp, em ), + canvas.resolve_geom( self.spacing_x ), + canvas.resolve_geom( self.spacing_y ), + canvas.resolve_geom( self.padding ), ) } diff --git a/src/lib.rs b/src/lib.rs index 2939f6d..f564197 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -115,8 +115,9 @@ //! - [`Length`] — a size/distance that may be absolute pixels //! ([`LengthBase::Px`]), relative to the surface viewport //! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] / -//! [`LengthBase::Vmax`] / [`LengthBase::Orient`]) or to the root font -//! size ([`LengthBase::Em`]). Every setter that takes a size, padding, +//! [`LengthBase::Vmax`] / [`LengthBase::Orient`]), to the root font +//! size ([`LengthBase::Em`]) or to the pixel density +//! ([`LengthBase::Dp`]). Every setter that takes a size, padding, //! spacing or font height now accepts `impl Into`, so legacy //! `.size( 24.0 )` keeps working while new code can write //! `.size( Length::vmin( 4.0 ).clamp( 16.0, 32.0 ) )` for a typeface @@ -188,8 +189,9 @@ //! millimetres — and legibility is a function of physical (angular) size, //! not of what fraction of the screen a glyph fills. When a size must stay //! a **constant physical size** across very different displays, use the -//! other mode: [`Length::dp`] (a density-independent pixel — `n ×` -//! [`density`], the mainstream HiDPI unit), or [`LengthBase::Em`] for text +//! other mode: [`Length::dp`] (a density-independent pixel — `n ×` the +//! pixel density, applied when the value is resolved — the mainstream +//! HiDPI unit), or [`LengthBase::Em`] for text //! relative to the root font size. The pre-calibrated //! [`theme::typography`] scale //! ([`theme::typography::h0`]…[`theme::typography::body_xs`]) is built on diff --git a/src/render/mod.rs b/src/render/mod.rs index f5968a9..19dcfa1 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -143,6 +143,11 @@ pub struct SoftwareCanvas /// the same inside offscreen content (scroll viewports, clip layers) /// as outside it. pub( crate ) layout_viewport: Option<( f32, f32 )>, + /// Canvas-local pixel density for `Dp` resolution, when the owning + /// surface sits on an output whose density differs from the process + /// [`crate::density`]. `None` falls back to the global. Inherited by + /// sub-canvases. + pub( crate ) density: Option, /// Global alpha multiplier for all drawing operations (0.0 = /// transparent, 1.0 = opaque). pub global_alpha: f32, @@ -264,6 +269,51 @@ impl Canvas } } + /// Pixel density used to resolve [`crate::LengthBase::Dp`] values on + /// this canvas: the canvas-local density when one was pinned with + /// [`Self::set_density`], the process [`crate::density`] otherwise. + pub fn density( &self ) -> f32 + { + let local = match self + { + Canvas::Software( c ) => c.density, + Canvas::Gles( c ) => c.density, + }; + local.unwrap_or_else( crate::types::density ) + } + + /// Pin this canvas — and every sub-canvas later derived from it — to + /// a pixel density, overriding the process [`crate::density`] for + /// `Dp` resolution. For a surface sitting on an output whose DPI + /// differs from the one the process global was derived from. + pub fn set_density( &mut self, d: f32 ) + { + let d = d.max( 0.0 ); + match self + { + Canvas::Software( c ) => c.density = Some( d ), + Canvas::Gles( c ) => c.density = Some( d ), + } + } + + /// Resolve an explicit [`Length`] in **geometry** space: against + /// [`Self::viewport_layout`], with this canvas' [`Self::density`]. + /// Widgets resolve caller-supplied geometry lengths through this so + /// a `Length::dp` override follows the canvas the widget draws on. + pub fn resolve_geom( &self, l: Length ) -> f32 + { + l.resolve_with_density( self.viewport_layout(), Length::EM_BASE_DEFAULT, self.density() ) + } + + /// Resolve an explicit [`Length`] in **font** space: against + /// [`Self::viewport_logical`], with this canvas' [`Self::density`]. + /// Counterpart of [`Self::resolve_geom`] for font sizes, which are + /// handed to the raster path pre-`dpi_scale`. + pub fn resolve_font( &self, l: Length ) -> f32 + { + l.resolve_with_density( self.viewport_logical(), Length::EM_BASE_DEFAULT, self.density() ) + } + /// Resolve a stock-widget **geometry** design pixel (height, padding, /// box size, gap…) through the process-wide [`crate::WidgetScaling`] /// mode, into a concrete physical-pixel value for the layout tree. @@ -275,7 +325,7 @@ impl Canvas /// [`Self::viewport_layout`]. pub fn geom_px( &self, design_px: f32 ) -> f32 { - Length::widget( design_px ).resolve( self.viewport_layout(), Length::EM_BASE_DEFAULT ) + self.resolve_geom( Length::widget( design_px ) ) } /// Resolve a stock-widget **font** design pixel through the process-wide @@ -292,13 +342,13 @@ impl Canvas { WidgetScaling::Fluid => { - Length::fluid( design_px ).resolve( self.viewport_logical(), Length::EM_BASE_DEFAULT ) + self.resolve_font( Length::fluid( design_px ) ) } WidgetScaling::Physical => { let scale = self.dpi_scale(); let scale = if scale > 0.0 { scale } else { 1.0 }; - design_px * crate::types::density() / scale + design_px * self.density() / scale } } } @@ -890,6 +940,40 @@ mod viewport_tests set_widget_scaling( WidgetScaling::Fluid ); } + #[ test ] + fn canvas_density_overrides_process_density() + { + use crate::types::{ set_widget_scaling, set_density, WidgetScaling, Length }; + let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); + + set_widget_scaling( WidgetScaling::Physical ); + set_density( 1.0 ); + let mut c = Canvas::new( 412, 900 ); + + // No local density → the process global applies. + assert_eq!( c.geom_px( 48.0 ), 48.0 ); + + // A pinned canvas density wins over the global, for stock-widget + // design pixels and explicit dp lengths alike. + c.set_density( 2.0 ); + assert_eq!( c.density(), 2.0 ); + assert_eq!( c.geom_px( 48.0 ), 96.0 ); + assert_eq!( c.resolve_geom( Length::dp( 10.0 ) ), 20.0 ); + + set_widget_scaling( WidgetScaling::Fluid ); + } + + #[ test ] + fn sub_canvas_inherits_density() + { + let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); + + let mut c = Canvas::new( 412, 900 ); + c.set_density( 3.0 ); + let sub = c.sub_canvas( 100, 100 ); + assert_eq!( sub.density(), 3.0 ); + } + #[ test ] fn font_px_is_constant_physical_in_physical_mode() { diff --git a/src/render/setup.rs b/src/render/setup.rs index 12ea198..d221aca 100644 --- a/src/render/setup.rs +++ b/src/render/setup.rs @@ -37,6 +37,7 @@ impl SoftwareCanvas font_registry: None, dpi_scale: 1.0, layout_viewport: None, + density: None, global_alpha: 1.0, glyph_cache: HashMap::new(), clip_mask: None, @@ -57,6 +58,7 @@ impl SoftwareCanvas dpi_scale: self.dpi_scale, layout_viewport: Some( self.layout_viewport.unwrap_or( ( self.pixmap.width() as f32, self.pixmap.height() as f32 ) ) ), + density: self.density, global_alpha: self.global_alpha, glyph_cache: HashMap::new(), clip_mask: None, diff --git a/src/types.rs b/src/types.rs index cd831e0..b9cef78 100644 --- a/src/types.rs +++ b/src/types.rs @@ -480,11 +480,15 @@ pub enum LengthBase /// Multiple of the root font size (typographic hierarchy: a heading /// of `Em(2.0)` is twice the body size, regardless of viewport). Em( f32 ), + /// Density-independent pixel: the design value times the pixel + /// density in effect **when the length is resolved** (the canvas' + /// own density, or the process [`density`]). See [`Length::dp`]. + Dp( f32 ), } impl LengthBase { - fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32 + fn resolve( &self, viewport: ( f32, f32 ), em_base: f32, density: f32 ) -> f32 { let ( vw, vh ) = viewport; match self @@ -504,6 +508,7 @@ impl LengthBase } } LengthBase::Em( mul ) => em_base * mul, + LengthBase::Dp( v ) => *v * density, } } } @@ -519,7 +524,8 @@ impl LengthBase /// Resolution requires a viewport — passed in as `(width, height)` in /// **logical** pixels — and an `em_base` (the body-text font size that /// `Em` is a multiple of). All resolution funnels through -/// [`Length::resolve`], so widgets can stay backend-agnostic. +/// [`Length::resolve`] (or [`Length::resolve_with_density`] where a +/// canvas-local density applies), so widgets can stay backend-agnostic. /// /// Construct directly via the [`LengthBase`] variants /// (`Length::vmin( 18.0 )`, `Length::px( 24.0 )`, …) or implicitly from @@ -584,15 +590,22 @@ impl Length } /// **Density-independent** pixel (the [`WidgetScaling::Physical`] mode). - /// `px` is multiplied by the process [`density`] (derived from the - /// output's DPI, or set with [`set_density`]) to yield a **constant - /// physical size** across displays — the mainstream `dp` of Android / - /// Flutter / CSS. Unlike [`Length::fluid`] it does **not** scale with - /// the surface size, only with pixel density. Density defaults to - /// `1.0`, so `dp( n )` == `n` px until a density is set. - pub fn dp( px: f32 ) -> Self + /// `px` is multiplied by the pixel density (derived from the output's + /// DPI, or set with [`set_density`]) to yield a **constant physical + /// size** across displays — the mainstream `dp` of Android / Flutter / + /// CSS. Unlike [`Length::fluid`] it does **not** scale with the + /// surface size, only with pixel density. Density defaults to `1.0`, + /// so `dp( n )` == `n` px until a density is set. + /// + /// The multiplication happens at **resolution time**, not here: the + /// value carries its design pixels, and [`Length::resolve`] applies + /// the process [`density`] — or the canvas' own density + /// ([`crate::Canvas::set_density`]) on canvas-routed resolution — so + /// a density change takes effect on the next paint without + /// reconstructing the view's lengths. + pub const fn dp( px: f32 ) -> Self { - Length::px( px * density() ) + Self::from_base( LengthBase::Dp( px ) ) } /// Resolve a stock-widget design pixel through the process-wide @@ -613,9 +626,21 @@ impl Length /// Resolve to a concrete logical-pixel value given a viewport and an /// `em_base` (the root font size that `Em` is a fraction of). + /// [`LengthBase::Dp`] values use the process [`density`]; resolution + /// paths that know a more local density (a canvas tied to a specific + /// output) go through [`Self::resolve_with_density`] instead. pub fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32 { - let raw = self.base.resolve( viewport, em_base ); + self.resolve_with_density( viewport, em_base, density() ) + } + + /// [`Self::resolve`] with an explicit pixel density for + /// [`LengthBase::Dp`], instead of the process [`density`]. This is + /// what [`crate::Canvas`]-routed resolution calls with the canvas' + /// own density. + pub fn resolve_with_density( &self, viewport: ( f32, f32 ), em_base: f32, density: f32 ) -> f32 + { + let raw = self.base.resolve( viewport, em_base, density ); let lo = self.min_px; let hi = self.max_px; // If both bounds present, normalise their order so swapped args @@ -912,9 +937,33 @@ mod length_tests assert_eq!( Length::dp( 48.0 ).resolve( ( 3840.0, 2160.0 ), 16.0 ), 48.0 ); } - // Serialised: this is the only test that mutates the process-wide density - // and widget-scaling globals, so it owns them start-to-finish and restores - // the defaults, keeping the other (read-only-default) tests deterministic. + #[ test ] + fn dp_resolves_against_explicit_density() + { + let l = Length::dp( 48.0 ); + assert_eq!( l.resolve_with_density( ( 412.0, 900.0 ), 16.0, 2.0 ), 96.0 ); + assert_eq!( l.resolve_with_density( ( 412.0, 900.0 ), 16.0, 1.0 ), 48.0 ); + } + + #[ test ] + fn dp_is_applied_at_resolution_time_not_construction() + { + use super::set_density; + + let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); + + // Construct while density is 1.0, resolve after it changes: the + // length must follow the new density. + let l = Length::dp( 48.0 ); + set_density( 2.0 ); + assert_eq!( l.resolve( ( 412.0, 900.0 ), 16.0 ), 96.0 ); + set_density( 1.0 ); + assert_eq!( l.resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 ); + } + + // Serialised: mutates the process-wide density and widget-scaling + // globals, so it owns them start-to-finish and restores the defaults, + // keeping the other (read-only-default) tests deterministic. #[ test ] fn density_and_widget_scaling_modes() { diff --git a/src/widget/button/mod.rs b/src/widget/button/mod.rs index c6f989a..cd58de7 100644 --- a/src/widget/button/mod.rs +++ b/src/widget/button/mod.rs @@ -279,7 +279,7 @@ impl Button fn label_font_size( &self, canvas: &Canvas ) -> f32 { self.font_size - .map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) + .map( |l| canvas.resolve_font( l ) ) .unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) ) } @@ -289,7 +289,7 @@ impl Button fn resolved_height( &self, canvas: &Canvas ) -> f32 { self.height - .map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) + .map( |l| canvas.resolve_geom( l ) ) .unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) ) } @@ -348,7 +348,7 @@ impl Button { let w = match self.width { - Some( l ) => l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ).min( max_width ), + Some( l ) => canvas.resolve_geom( l ).min( max_width ), None => { let text_w = canvas.measure_text( label, self.label_font_size( canvas ) ); diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs index a3b032a..5e87d14 100644 --- a/src/widget/container/mod.rs +++ b/src/widget/container/mod.rs @@ -275,12 +275,10 @@ impl Container /// Return the preferred `(width, height)` accounting for padding. pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) { - let vp = canvas.viewport_layout(); - let em = Length::EM_BASE_DEFAULT; - let pad_l = self.pad_left.resolve( vp, em ); - let pad_r = self.pad_right.resolve( vp, em ); - let pad_t = self.pad_top.resolve( vp, em ); - let pad_b = self.pad_bottom.resolve( vp, em ); + let pad_l = canvas.resolve_geom( self.pad_left ); + let pad_r = canvas.resolve_geom( self.pad_right ); + let pad_t = canvas.resolve_geom( self.pad_top ); + let pad_b = canvas.resolve_geom( self.pad_bottom ); let avail = self.max_width.map( |m| max_width.min( m ) ).unwrap_or( max_width ); let pad_x = pad_l + pad_r; let pad_y = pad_t + pad_b; diff --git a/src/widget/image/mod.rs b/src/widget/image/mod.rs index 1ab9ce2..d492a93 100644 --- a/src/widget/image/mod.rs +++ b/src/widget/image/mod.rs @@ -108,7 +108,7 @@ impl Image if let Some( extent ) = &self.short_side { let ( vw, vh ) = canvas.viewport_layout(); - let s = extent.resolve( ( vw, vh ), Length::EM_BASE_DEFAULT ).max( 0.0 ); + let s = canvas.resolve_geom( *extent ).max( 0.0 ); let sw = self.width as f32; let sh = self.height as f32; if sw <= 0.0 || sh <= 0.0 { return ( s, s ); } @@ -123,10 +123,8 @@ impl Image } if let Some( ( w, h ) ) = &self.display_size { - let vp = canvas.viewport_layout(); - let em = Length::EM_BASE_DEFAULT; - let rw = w.resolve( vp, em ).max( 0.0 ); - let rh = h.resolve( vp, em ).max( 0.0 ); + let rw = canvas.resolve_geom( *w ).max( 0.0 ); + let rh = canvas.resolve_geom( *h ).max( 0.0 ); return ( rw, rh ); } if self.cover diff --git a/src/widget/list_item/mod.rs b/src/widget/list_item/mod.rs index 5f0b457..6629199 100644 --- a/src/widget/list_item/mod.rs +++ b/src/widget/list_item/mod.rs @@ -208,7 +208,7 @@ impl ListItem let label_size = canvas.font_px( theme::LABEL_SIZE ); let pad_h = self.pad_h - .map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) + .map( |l| canvas.resolve_geom( l ) ) .unwrap_or_else( || canvas.geom_px( theme::PAD_H ) ); let has_sub = self.subtitle.is_some(); let label_y = if has_sub diff --git a/src/widget/rich_text/mod.rs b/src/widget/rich_text/mod.rs index e7bfa53..0f95a45 100644 --- a/src/widget/rich_text/mod.rs +++ b/src/widget/rich_text/mod.rs @@ -97,7 +97,7 @@ impl RichText #[ inline ] fn resolved_size( &self, canvas: &Canvas ) -> f32 { - self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + canvas.resolve_font( self.size ) } fn resolve_font( &self, canvas: &Canvas ) -> Option> diff --git a/src/widget/separator/mod.rs b/src/widget/separator/mod.rs index cb3743c..61a65f1 100644 --- a/src/widget/separator/mod.rs +++ b/src/widget/separator/mod.rs @@ -61,7 +61,7 @@ impl Separator fn resolved_thickness( &self, canvas: &Canvas ) -> f32 { self.thickness - .map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) + .map( |l| canvas.resolve_geom( l ) ) .unwrap_or_else( || canvas.geom_px( theme::THICKNESS ) ) } @@ -70,7 +70,7 @@ impl Separator fn resolved_pad_v( &self, canvas: &Canvas ) -> f32 { self.pad_v - .map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) + .map( |l| canvas.resolve_geom( l ) ) .unwrap_or_else( || canvas.geom_px( theme::PAD_V ) ) } diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 0d3aecb..b599fbd 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -82,7 +82,7 @@ impl Text #[ inline ] fn resolved_size( &self, canvas: &Canvas ) -> f32 { - self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + canvas.resolve_font( self.size ) } /// Paint the full string even when it overflows, instead of truncating diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index 4f034e5..8c3aeae 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -34,7 +34,7 @@ pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_ pub( crate ) fn resolve_font_size( canvas: &Canvas, fs: Option ) -> f32 { fs - .map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) + .map( |l| canvas.resolve_font( l ) ) .unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) ) } @@ -444,10 +444,10 @@ impl TextEdit ( max_width, h ) } else { let w = self.fixed_width - .map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ).min( max_width ) ) + .map( |l| canvas.resolve_geom( l ).min( max_width ) ) .unwrap_or( max_width ); let h = self.height - .map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) + .map( |l| canvas.resolve_geom( l ) ) .unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) ); ( w, h ) } diff --git a/src/widget/vslider/mod.rs b/src/widget/vslider/mod.rs index cac765f..44e21b7 100644 --- a/src/widget/vslider/mod.rs +++ b/src/widget/vslider/mod.rs @@ -139,10 +139,8 @@ impl VSlider /// the type-level docs on intrinsic sizing. pub fn preferred_size( &self, _max_width: f32, canvas: &Canvas ) -> (f32, f32) { - let vp = canvas.viewport_layout(); - let em = Length::EM_BASE_DEFAULT; - let w = self.width.resolve( vp, em ).max( 2.0 ); - let h = self.height.resolve( vp, em ).max( 2.0 ); + let w = canvas.resolve_geom( self.width ).max( 2.0 ); + let h = canvas.resolve_geom( self.height ).max( 2.0 ); ( w, h ) }