diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b400e4f..178152e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: ci on: - push: - branches: [main] pull_request: # The merge queue runs everything; a PR runs only what its diff touches. # See docs/ci.md. @@ -29,11 +27,16 @@ jobs: outputs: rust: ${{ steps.c.outputs.rust }} gallery: ${{ steps.c.outputs.gallery }} + fixtures: ${{ steps.c.outputs.fixtures }} full: ${{ steps.c.outputs.full }} rust_full: ${{ steps.c.outputs.rust_full }} rust_packages: ${{ steps.c.outputs.rust_packages }} + readme: ${{ steps.c.outputs.readme }} + tokens: ${{ steps.c.outputs.tokens }} + icons: ${{ steps.c.outputs.icons }} + deny: ${{ steps.c.outputs.deny }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - id: c @@ -44,10 +47,11 @@ jobs: run: | set -euo pipefail if [[ "$EVENT" != pull_request ]]; then - printf 'full=true\nrust=true\ngallery=true\nrust_full=true\nrust_packages=\n' >> "$GITHUB_OUTPUT" + printf 'full=true\nrust=true\ngallery=true\nfixtures=true\nrust_full=true\nrust_packages=\nreadme=true\ntokens=true\nicons=true\ndeny=true\n' >> "$GITHUB_OUTPUT" exit 0 fi - rust=false gallery=false full=false + rust=false gallery=false fixtures=false full=false + readme=false tokens=false icons=false deny=false packages=() add_package() { local pkg @@ -60,49 +64,76 @@ jobs: case "$f" in # Anything that changes how the workspace resolves or builds takes # the full workspace and the Windows matrix. - .github/*|Cargo.toml|Cargo.lock|rustfmt.toml|rust-toolchain.toml|.cargo/*) - rust=true gallery=true full=true ;; - crates/gallery/*|gallery/*|docs/images/gallery/*|scripts/gallery-screenshots.sh) + .github/*|Cargo.toml|Cargo.lock|rustfmt.toml|rust-toolchain.toml|.cargo/*|deny.toml) + rust=true gallery=true fixtures=true full=true + readme=true tokens=true icons=true deny=true ;; + fixtures/*) + rust=true fixtures=true ;; + crates/gallery/*|gallery/*|docs/images/gallery/*|scripts/gallery-screenshots.sh|scripts/wasm-size-check.sh|ci/wasm-budget.txt|docs/wasm-size.md) rust=true gallery=true add_package gallery ;; crates/fluent2-egui/*) - rust=true + rust=true fixtures=true add_package fluent2-egui # The gallery enables every optional feature, so running its # tests is what exercises the feature-gated components. add_package gallery ;; - crates/fluent2-icons/*) - rust=true - add_package fluent2-icons ;; - crates/fluent2-tokens/*) - rust=true - add_package fluent2-tokens ;; + crates/fluent2-icons/*|assets/icons/*|xtask/src/icons.rs) + rust=true icons=true + add_package fluent2-icons + # Icons feed the component interaction tests. + add_package fluent2-egui + add_package gallery ;; + crates/fluent2-tokens/*|design-tokens/*|scripts/gen-tokens*|scripts/snapshot-tokens*) + rust=true tokens=true + add_package fluent2-tokens + add_package fluent2-egui + add_package gallery ;; xtask/*) - rust=true - add_package xtask ;; + rust=true tokens=true + add_package xtask + add_package fluent2-tokens + add_package fluent2-egui + add_package gallery ;; + scripts/*) + # Generators rewrite committed sources; check that they still match. + rust=true tokens=true + add_package xtask + add_package fluent2-tokens ;; + README.md|README*) + # The component index is checked by fluent2-egui's readme test. + rust=true readme=true + add_package fluent2-egui ;; crates/*) # A new or unrecognised crate is not safe to narrow. - rust=true full=true ;; + rust=true fixtures=true full=true ;; + *) + # An unclassified path fails safe: the full PR set, not nothing. + rust=true gallery=true fixtures=true full=true + readme=true tokens=true icons=true deny=true ;; esac done < <(git diff --name-only "$(git merge-base "$BASE" HEAD)" HEAD) # A Rust change we could not attribute to a package still needs the # full workspace rather than no check at all. - if [[ "$rust" == true && "$full" == false && ${#packages[@]} -eq 0 ]]; then + if [[ "$rust" == true && "$full" == false && "$fixtures" == false && ${#packages[@]} -eq 0 ]]; then full=true fi - printf 'full=%s\nrust=%s\ngallery=%s\nrust_full=%s\nrust_packages=%s\n' \ - "$full" "$rust" "$gallery" "$full" "${packages[*]}" | tee -a "$GITHUB_OUTPUT" + printf 'full=%s\nrust=%s\ngallery=%s\nfixtures=%s\nrust_full=%s\nrust_packages=%s\nreadme=%s\ntokens=%s\nicons=%s\ndeny=%s\n' \ + "$full" "$rust" "$gallery" "$fixtures" "$full" "${packages[*]}" "$readme" "$tokens" "$icons" "$deny" | tee -a "$GITHUB_OUTPUT" fmt: needs: changes if: needs.changes.outputs.rust == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, resolves stable with: + toolchain: stable components: rustfmt - - run: cargo xtask gen-tokens && git diff --exit-code + - name: Regeneration drift + if: needs.changes.outputs.tokens == 'true' || needs.changes.outputs.full == 'true' + run: cargo xtask gen-tokens && git diff --exit-code - run: cargo fmt --all --check test: @@ -111,13 +142,14 @@ jobs: strategy: fail-fast: false matrix: - # The Windows leg runs in the queue and on main only. + # The Windows leg runs in the merge queue only. os: ${{ fromJSON(needs.changes.outputs.full == 'true' && '["ubuntu-latest","windows-latest"]' || '["ubuntu-latest"]') }} runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, resolves stable with: + toolchain: stable components: clippy - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 with: @@ -186,15 +218,19 @@ jobs: - name: Serde feature tests if: steps.scope.outputs.full == 'true' || contains(format(' {0} ', needs.changes.outputs.rust_packages), ' fluent2-egui ') run: cargo test -p fluent2-egui --features serde + - name: README index + if: needs.changes.outputs.readme == 'true' || steps.scope.outputs.full == 'true' || contains(format(' {0} ', needs.changes.outputs.rust_packages), ' fluent2-egui ') + run: cargo test -p fluent2-egui --test readme_index --locked wasm: needs: changes if: needs.changes.outputs.rust == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, resolves stable with: + toolchain: stable targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 with: @@ -207,8 +243,10 @@ jobs: if: needs.changes.outputs.rust == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, resolves stable + with: + toolchain: stable - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 with: shared-key: ci @@ -217,33 +255,111 @@ jobs: env: RUSTDOCFLAGS: -D warnings + # Downstream fixtures: default features off, each optional feature alone, and wasm. + # Not the gallery (it turns most features on together). Runs when Cargo.toml, + # feature flags or the fixture itself change — the same class as a full resolve. + fixtures: + needs: changes + if: needs.changes.outputs.fixtures == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + features: + - "" + - bundled-font + - web-a11y + - serde + - terminal + - diff + - highlight + - file-dialog + - docking + - markdown + - html + - web-find + - chart + - chrono + - serde,docking + - markdown,html + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, resolves stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 + with: + shared-key: ci + save-if: ${{ github.event_name != 'pull_request' }} + - name: Check the consumer + env: + FEATURES: ${{ matrix.features }} + run: | + set -euo pipefail + args=(-p fluent2-egui-consumer --no-default-features --locked) + if [[ -n "$FEATURES" ]]; then + args+=(--features "$FEATURES") + fi + cargo check "${args[@]}" + + fixtures-wasm: + needs: changes + if: needs.changes.outputs.fixtures == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, resolves stable + with: + toolchain: stable + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 + with: + shared-key: ci + save-if: ${{ github.event_name != 'pull_request' }} + - run: cargo check -p fluent2-egui-consumer --no-default-features --locked --target wasm32-unknown-unknown + + # The declared rust-version, on the minimal consumer rather than the workspace. msrv: needs: changes - if: needs.changes.outputs.rust == 'true' + if: needs.changes.outputs.fixtures == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@1.95 - - run: cargo check --workspace + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, resolves 1.95 + with: + toolchain: "1.95" + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 + with: + shared-key: ci + save-if: ${{ github.event_name != 'pull_request' }} + - run: cargo check -p fluent2-egui-consumer --no-default-features --locked gallery-wasm: needs: changes if: needs.changes.outputs.gallery == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, resolves stable with: + toolchain: stable targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 with: shared-key: ci save-if: ${{ github.event_name != 'pull_request' }} - - uses: jetli/trunk-action@v0.5.0 + - uses: jetli/trunk-action@69821d4eec12c308c4b811e459e37556cf920057 # v0.5.0 with: version: v0.21.14 - run: trunk build index.html --release working-directory: crates/gallery + - name: compressed size budget + run: scripts/wasm-size-check.sh crates/gallery/dist + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: wasm-size + path: crates/gallery/dist/wasm-size.txt # The README gallery images (WI-1910): re-render every page in the pinned image the # script uses, and fail when a committed PNG differs. The rendered set is uploaded so a @@ -252,11 +368,11 @@ jobs: needs: changes if: needs.changes.outputs.gallery == 'true' runs-on: ubuntu-latest - container: rust:1.95-trixie + container: rust@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 # 1.95-trixie env: GALLERY_SHOTS_IN_CONTAINER: "1" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 with: key: gallery-screenshots @@ -264,7 +380,7 @@ jobs: - id: shots run: scripts/gallery-screenshots.sh - if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: gallery-screenshots path: docs/images/gallery/ @@ -272,11 +388,37 @@ jobs: run: | echo "::error::gallery snapshots changed: run scripts/gallery-screenshots.sh --update and commit the images" + + # Offline: rebuild generated.rs from the committed SVGs and fail on drift. + # gen-icons itself fetches the pinned upstream revision; this job must not. + icon-drift: + needs: changes + if: needs.changes.outputs.icons == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, resolves stable + with: + toolchain: stable + - run: cargo xtask check-icons + + # Advisories and licences. deny.toml is the policy release.yml also uses. + cargo-deny: + needs: changes + if: needs.changes.outputs.deny == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 + with: + command: check + arguments: "" + command-arguments: advisories bans licenses sources # The one context to require: passes when every job passed or was skipped # by path scope, fails on any failure or cancellation. ci: if: always() - needs: [changes, fmt, test, wasm, doc, msrv, gallery-wasm, gallery-screenshots] + needs: [changes, fmt, test, wasm, doc, fixtures, fixtures-wasm, msrv, gallery-wasm, gallery-screenshots, icon-drift, cargo-deny] runs-on: ubuntu-latest steps: - env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 862f8e5..d8d4f7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,17 +49,16 @@ jobs: needs: gates if: github.event_name == 'workflow_dispatch' && inputs.publish_crates && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + environment: crates-io + concurrency: + group: crates-io-publish + cancel-in-progress: false steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - # Dependency order. crates.io indexes each crate before the next one resolves it; cargo - # publish waits for that by default. - - run: cargo publish -p fluent2-tokens - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - run: cargo publish -p fluent2-icons - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - run: cargo publish -p fluent2-egui + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 + # scripts/publish-crates.sh checks the tag, a clean tree, licences, and + # skips a crate whose version is already on crates.io. + - run: scripts/publish-crates.sh --publish env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 57f8f7b..91cdd73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ module layout, the component API, theming, testing and gates. 1. Find the item in Vogt (index: `docs/BACKLOG.md`) and read it fully. It names the files to create or change, the upstream references (pinned URLs), the acceptance criteria and the commands to run. 2. Branch `wi--` from `main`, or from the branch the item says it stacks on. -3. Touch only the files the item lists, plus `CHANGELOG.md`. If you need to touch more, or need +3. Touch only the files the item lists, plus a fragment in `changelog.d/` (see [`changelog.d/README.md`](changelog.d/README.md) and [`docs/ci.md`](docs/ci.md)). Do not edit `CHANGELOG.md`; the release PR folds fragments in. If you need to touch more, or need a design choice the item and `docs/ARCHITECTURE.md` don't settle, **stop and ask on the work item**. Do not guess. 4. Run the gates in `docs/ARCHITECTURE.md` until they pass. diff --git a/Cargo.lock b/Cargo.lock index 1d461d4..296a3a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -637,6 +637,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -744,6 +755,21 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -1252,6 +1278,7 @@ dependencies = [ "imara-diff", "log", "pollster 0.4.0", + "proptest", "pulldown-cmark", "rfd", "serde", @@ -1266,6 +1293,13 @@ dependencies = [ "zbus", ] +[[package]] +name = "fluent2-egui-consumer" +version = "0.2.0" +dependencies = [ + "fluent2-egui", +] + [[package]] name = "fluent2-icons" version = "0.2.0" @@ -1450,6 +1484,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", ] [[package]] @@ -2723,6 +2758,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bitflags 2.13.2", + "chacha20", + "core_detect", + "num-traits", + "rand", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "pulldown-cmark" version = "0.13.5" @@ -2776,6 +2827,31 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -3598,6 +3674,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.10.0" diff --git a/Cargo.toml b/Cargo.toml index 9bd7c4d..dd8abd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*", "xtask"] +members = ["crates/*", "xtask", "fixtures/*"] [workspace.package] version = "0.2.0" diff --git a/README.md b/README.md index da1459e..bec7d67 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ the pieces desktop productivity apps need: title bar, quick access toolbar, ribb backstage/File view, command bar, status bar, data grid and tree view. It runs natively on Windows and Linux, and on the web through wasm. -**Live gallery:** https://thedancingdeveloper.github.io/fluent2-egui/ +**Hosted gallery:** unavailable. GitHub Pages is not enabled on this repository, so there is no live site. Run the gallery locally with `cargo run -p gallery`. The wasm build in `.github/workflows/pages.yml` still runs; its deploy step is quarantined until Pages is turned on (see [`docs/ci.md`](docs/ci.md)). **Status: 0.2.0.** Milestones: @@ -169,7 +169,6 @@ What the crate offers, by module, mapped to Fluent 2. Rows are sorted by type na | Fluent 2 | status | |---|---| -| Accordion | WI-1937 | | Toolbar, Rating, InfoLabel, Image, Select, SwatchPicker, Carousel, TeachingPopover | not planned yet | ## Crates @@ -185,6 +184,7 @@ What the crate offers, by module, mapped to Fluent 2. Rows are sorted by type na | fluent2-egui | egui | |---|---| | 0.1.x | 0.36 | +| 0.2.x | 0.36 | ## CI diff --git a/changelog.d/gh-147-dialog-action-ids.md b/changelog.d/gh-147-dialog-action-ids.md new file mode 100644 index 0000000..b86620b --- /dev/null +++ b/changelog.d/gh-147-dialog-action-ids.md @@ -0,0 +1,2 @@ +### Changed +- Dialog actions take a stable id and a display text (`DialogActions::primary(id, text)` and `secondary`). `CloseReason::Button` carries that id, and the button's widget id is derived from it, so two actions may share a label and a translated label still closes as the same action. This is a breaking change: call sites that passed only a label now pass an id as well, and `CloseReason` is no longer `Copy` (gh:TheDancingDeveloper-org/fluent2-egui#147). diff --git a/changelog.d/gh-148-dialog-dismissible.md b/changelog.d/gh-148-dialog-dismissible.md new file mode 100644 index 0000000..949084c --- /dev/null +++ b/changelog.d/gh-148-dialog-dismissible.md @@ -0,0 +1,2 @@ +### Changed +- Dialogs are always modal. `DialogKind` only chooses what dismisses the surface: `Modal` closes on a backdrop click and on Escape, `Dismissible` on Escape only, `Alert` on neither. `DialogKind::NonModal` remains as a deprecated alias of `Dismissible`; it never let background input through (gh:TheDancingDeveloper-org/fluent2-egui#148). diff --git a/changelog.d/gh-150-contrast.md b/changelog.d/gh-150-contrast.md new file mode 100644 index 0000000..57f59cc --- /dev/null +++ b/changelog.d/gh-150-contrast.md @@ -0,0 +1,2 @@ +### Changed +- Light and dark theme strokes, status borders and the dark primary fill now meet WCAG 2.2 contrast (3:1 non-text, 4.5:1 text) instead of keeping Fluent colours that failed those pairs (issue #150). diff --git a/changelog.d/gh-151-slider-tokens.md b/changelog.d/gh-151-slider-tokens.md new file mode 100644 index 0000000..898e36d --- /dev/null +++ b/changelog.d/gh-151-slider-tokens.md @@ -0,0 +1,2 @@ +### Changed +- Slider rail, thumb and inner-dot sizes come from `Theme.slider` (`SliderMetrics`). A disabled slider paints the disabled alias colours for the rail, fill and thumb instead of the resting brand colours (issue #151). diff --git a/changelog.d/gh-152-combobox-borrowed-options.md b/changelog.d/gh-152-combobox-borrowed-options.md new file mode 100644 index 0000000..bf56362 --- /dev/null +++ b/changelog.d/gh-152-combobox-borrowed-options.md @@ -0,0 +1,5 @@ +### Changed +- `Combobox<'a, T = &'a str>` gains a generic parameter. Options are borrowed for `'a` and are not copied into the widget. `ComboboxOptions`, `SliceOptions` and `ModelCombobox` are new, and `filter_probe` counts real filter passes for tests (gh:TheDancingDeveloper-org/fluent2-egui#152). + +### Fixed +- Combobox filter cache for a plain slice (`Combobox::new`) was keyed only on the query, so a caller that rebuilt the option slice while the popup stayed open reused stale indexes and could panic. The cache now fingerprints the slice, and a cached index past the list is dropped. Closed popups still do not filter (gh:TheDancingDeveloper-org/fluent2-egui#152). diff --git a/changelog.d/gh-153-wasm-budget.md b/changelog.d/gh-153-wasm-budget.md new file mode 100644 index 0000000..b73db1e --- /dev/null +++ b/changelog.d/gh-153-wasm-budget.md @@ -0,0 +1,7 @@ +### Changed + +- The gallery wasm size budget is enforced. `gallery-wasm` runs `scripts/wasm-size-check.sh` against `ci/wasm-budget.txt` (gzip and brotli, each 4,500,000 bytes) and uploads the size report. Raise the budget in that file when a change needs the room (gh:TheDancingDeveloper-org/fluent2-egui#153). + +### Added + +- `cargo bench -p fluent2-egui --bench large_views` prints frame time for a 10,000-row list, a 10,000-row data grid and a combobox of 10,000 options. It is not a required check (gh:TheDancingDeveloper-org/fluent2-egui#153). diff --git a/changelog.d/gh-154-feature-fixtures.md b/changelog.d/gh-154-feature-fixtures.md new file mode 100644 index 0000000..e783592 --- /dev/null +++ b/changelog.d/gh-154-feature-fixtures.md @@ -0,0 +1,2 @@ +### Added +- Downstream build fixtures check fluent2-egui with default features off, each optional feature on its own, a wasm consumer, and the declared MSRV (gh:TheDancingDeveloper-org/fluent2-egui#154). diff --git a/changelog.d/gh-163-docs-drift.md b/changelog.d/gh-163-docs-drift.md new file mode 100644 index 0000000..c5e5e4d --- /dev/null +++ b/changelog.d/gh-163-docs-drift.md @@ -0,0 +1,3 @@ +### Fixed + +- Docs now match the changelog-fragment rule, the Dialog `show` signature, Field parent naming, and the README: Accordion is listed only as shipped, the egui table includes 0.2, and the hosted gallery is marked unavailable because GitHub Pages is not enabled. diff --git a/ci/wasm-budget.txt b/ci/wasm-budget.txt new file mode 100644 index 0000000..e8cf9e9 --- /dev/null +++ b/ci/wasm-budget.txt @@ -0,0 +1,6 @@ +# Gallery wasm compressed-size budget (issue #153). +# scripts/wasm-size-check.sh fails when a measured size is over its line. +# Raise a number here in the same change that grows the wasm, and say why in +# docs/wasm-size.md. Bytes, not megabytes. +gzip 4500000 +brotli 4500000 diff --git a/crates/fluent2-egui/Cargo.toml b/crates/fluent2-egui/Cargo.toml index 08474c0..de8f8ff 100644 --- a/crates/fluent2-egui/Cargo.toml +++ b/crates/fluent2-egui/Cargo.toml @@ -105,8 +105,15 @@ zbus = { version = "5", optional = true, default-features = false, features = [" [dev-dependencies] egui_kittest = "0.36" +# Property tests for numeric and parser boundaries. Dev-only; not a published dependency. +proptest = { version = "1", default-features = false, features = ["std"] } + # Text layout needs a font: without egui's default fonts every galley is empty and has no size, # so selection tests (tests/text_selection.rs) could not drag across glyphs. egui = { workspace = true, features = ["default_fonts"] } # Round-trips the serde state in tests (the `serde` feature). serde_json = "1" + +[[bench]] +name = "large_views" +harness = false diff --git a/crates/fluent2-egui/benches/large_views.rs b/crates/fluent2-egui/benches/large_views.rs new file mode 100644 index 0000000..2665325 --- /dev/null +++ b/crates/fluent2-egui/benches/large_views.rs @@ -0,0 +1,87 @@ +//! Frame time for large data views (issue #153). +//! +//! Not a required CI check. From the workspace root: +//! +//! ```sh +//! cargo bench -p fluent2-egui --bench large_views +//! ``` +//! +//! Each case paints a 10 000-row view for 60 frames in a fixed-size harness and prints the +//! wall time per frame. The first frame is not timed: it pays for font setup and the first +//! layout. The numbers are a record, not a gate. A few percent either way between machines +//! is expected. Allocation counts are not measured here: the library forbids `unsafe`, and a +//! counting allocator needs it. + +use std::cell::RefCell; +use std::time::Instant; + +use egui::Vec2; +use egui_kittest::Harness; +use fluent2_egui::Combobox; +use fluent2_egui::data::data_grid::{Column, DataGrid, DataGridState}; +use fluent2_egui::data::list::{List, ListState}; +use fluent2_egui::{Theme, install}; + +const ROWS: usize = 10_000; +const FRAMES: u32 = 60; + +fn theme(ui: &mut egui::Ui) { + install(ui.ctx(), &Theme::light(fluent2_tokens::FLUENT)); +} + +fn bench(name: &str, mut step: impl FnMut()) { + step(); + let start = Instant::now(); + for _ in 0..FRAMES { + step(); + } + let per_frame = start.elapsed() / FRAMES; + println!("{name}: {per_frame:?}/frame over {FRAMES} frames ({ROWS} rows)"); +} + +fn main() { + let options: Vec = (0..ROWS).map(|i| format!("Option {i}")).collect(); + + let list_state = RefCell::new(ListState::default()); + let mut list = Harness::builder() + .with_size(Vec2::new(480.0, 640.0)) + .build_ui(move |ui| { + theme(ui); + let mut state = list_state.borrow_mut(); + List::new("bench-list", ROWS) + .label("Bench list") + .show(ui, &mut state, |cell, ui| { + ui.label(format!("Row {}", cell.row)); + }); + }); + + let grid_state = RefCell::new(DataGridState::default()); + let mut grid = Harness::builder() + .with_size(Vec2::new(640.0, 480.0)) + .build_ui(move |ui| { + theme(ui); + let columns = [ + Column::new("name", "Name"), + Column::new("value", "Value"), + ]; + let mut state = grid_state.borrow_mut(); + DataGrid::new("bench-grid", &columns, ROWS) + .label("Bench grid") + .show(ui, &mut state, |cell, ui| { + ui.label(format!("{} {}", cell.column, cell.row)); + }); + }); + + let text = RefCell::new(String::new()); + let mut combo = Harness::builder() + .with_size(Vec2::new(320.0, 480.0)) + .build_ui(move |ui| { + theme(ui); + let mut value = text.borrow_mut(); + ui.add(Combobox::new("bench-combo", &mut value, options.as_slice()).label("Bench")); + }); + + bench("list-10k", || list.step()); + bench("data-grid-10k", || grid.step()); + bench("combobox-10k", || combo.step()); +} diff --git a/crates/fluent2-egui/src/controls/combobox.rs b/crates/fluent2-egui/src/controls/combobox.rs index 062d0b4..8678f5d 100644 --- a/crates/fluent2-egui/src/controls/combobox.rs +++ b/crates/fluent2-egui/src/controls/combobox.rs @@ -4,12 +4,13 @@ //! [`super::Dropdown`]. Metrics are the WI-1549 brief: 32 px rows, a check on the selected //! option, and [`egui::ScrollArea::show_rows`] past 50 options. +use std::sync::atomic::{AtomicU64, Ordering}; + use egui::{Id, Rect, Response, Sense, TextEdit, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType}; use super::ButtonSize; use super::dropdown::{ - PreviewFn, field_height, filter_options, finite, icon_points, icon_size, paint_outline, rounded_u8, - show_listbox, step, + PreviewFn, field_height, finite, icon_points, icon_size, paint_outline, rounded_u8, show_listbox, step, }; use crate::foundation::focus::{focus_visible, paint_focus_ring}; use crate::foundation::icon::{IconName, IconStyle, paint_icon}; @@ -18,6 +19,72 @@ use egui::CornerRadius; use fluent2_tokens::generated::global::spacing_horizontal; +/// Options the combobox filters without copying the list. +/// +/// `revision` changes when the list changes. The filtered result is cached against it and the +/// query, so a stable list is not scanned again while the popup stays open on the same text. +/// A plain slice has no revision of its own; its cache key is a fingerprint of the labels. +pub trait ComboboxOptions { + /// How many options the list holds. + fn len(&self) -> usize; + + /// Whether the list holds no options. + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The option at `index`. + fn get(&self, index: usize) -> &str; + + /// A value that changes when the list changes. + fn revision(&self) -> u64; +} + +/// A borrowed option list with an explicit revision. +/// +/// Bump `revision` when the list changes so the filter cache rebuilds. [`Combobox::new`] borrows +/// a slice at revision 0 and does not copy the strings. [`ModelCombobox`] takes a model whose +/// revision can change. +pub struct SliceOptions<'a, T: AsRef> { + options: &'a [T], + revision: u64, +} + +impl<'a, T: AsRef> SliceOptions<'a, T> { + /// Borrow `options`. `revision` is the cache key for this list. + pub fn new(options: &'a [T], revision: u64) -> Self { + Self { options, revision } + } +} + +impl> ComboboxOptions for SliceOptions<'_, T> { + fn len(&self) -> usize { + self.options.len() + } + + fn get(&self, index: usize) -> &str { + self.options[index].as_ref() + } + + fn revision(&self) -> u64 { + self.revision + } +} + +/// Counts how often the combobox actually filters. Tests read it; production code leaves it +/// unset and pays nothing. +#[derive(Clone, Copy)] +pub struct FilterProbe(pub &'static AtomicU64); + +/// Filtered indexes stored on the widget id. Rebuilt only while the popup is open and the +/// query or model revision changed. +#[derive(Clone)] +struct FilterCache { + query: String, + revision: u64, + indexes: Vec, +} + /// An editable field that filters a list of options. /// /// ``` @@ -26,43 +93,67 @@ use fluent2_tokens::generated::global::spacing_horizontal; /// let _ = ui.add(Combobox::new("city", text, &["Perth", "Paris"]).freeform(true)); /// # } /// ``` -pub struct Combobox<'a> { +pub struct Combobox<'a, T: AsRef = &'a str> { id_salt: Id, text: &'a mut String, - options: Vec, + options: &'a [T], + model: Option<&'a dyn ComboboxOptions>, placeholder: Option, + label: Option, size: ButtonSize, width: Option, freeform: bool, preview: Option>, + probe: Option<&'static AtomicU64>, } -impl<'a> Combobox<'a> { +impl<'a, T: AsRef> Combobox<'a, T> { /// A combobox bound to `text`. `id_salt` names the widget. + /// + /// Borrows `options` for this frame. The strings are not copied into the widget. A changed + /// slice invalidates the filter cache even when the query stays the same. pub fn new( id_salt: impl std::hash::Hash + std::fmt::Debug, text: &'a mut String, - options: &[impl AsRef], + options: &'a [T], ) -> Self { Self { id_salt: Id::new(id_salt), text, - options: options.iter().map(|option| option.as_ref().to_owned()).collect(), + options, + model: None, placeholder: None, + label: None, size: ButtonSize::Medium, width: None, freeform: false, preview: None, + probe: None, } } - /// Grey hint while `text` is empty. + /// Count real filter passes. For tests, not application code. + #[must_use] + pub fn filter_probe(mut self, probe: FilterProbe) -> Self { + self.probe = Some(probe.0); + self + } + + /// Grey hint while `text` is empty. Not the accessible name. #[must_use] pub fn placeholder(mut self, placeholder: impl Into) -> Self { self.placeholder = Some(placeholder.into()); self } + /// Accessible name, read by screen readers. Not drawn. Inside a [`super::Field`] the + /// Field's label is the default; otherwise the placeholder, then "Combobox". + #[must_use] + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + /// Field height. Defaults to [`ButtonSize::Medium`]. #[must_use] pub fn size(mut self, size: ButtonSize) -> Self { @@ -77,8 +168,8 @@ impl<'a> Combobox<'a> { self } - /// When set, Enter on a typed string that matches no active option keeps the typed text. - /// Otherwise Enter only accepts an option. + /// When set, Enter keeps the typed text unless the user has moved the highlight with the + /// arrow keys since the text last changed. Otherwise Enter only accepts an option. #[must_use] pub fn freeform(mut self, freeform: bool) -> Self { self.freeform = freeform; @@ -93,7 +184,76 @@ impl<'a> Combobox<'a> { } } -impl Widget for Combobox<'_> { +/// Combobox over a reusable [`ComboboxOptions`] model. +pub struct ModelCombobox<'a> { + id_salt: Id, + text: &'a mut String, + model: &'a dyn ComboboxOptions, + placeholder: Option, + label: Option, + size: ButtonSize, + width: Option, + freeform: bool, + preview: Option>, + probe: Option<&'static AtomicU64>, +} + +impl<'a> ModelCombobox<'a> { + /// A combobox over a reusable model. The model is not copied. + pub fn new( + id_salt: impl std::hash::Hash + std::fmt::Debug, + text: &'a mut String, + model: &'a dyn ComboboxOptions, + ) -> Self { + Self { + id_salt: Id::new(id_salt), + text, + model, + placeholder: None, + label: None, + size: ButtonSize::Medium, + width: None, + freeform: false, + preview: None, + probe: None, + } + } + + /// Count real filter passes. For tests, not application code. + #[must_use] + pub fn filter_probe(mut self, probe: FilterProbe) -> Self { + self.probe = Some(probe.0); + self + } + + /// Grey hint while `text` is empty. Not the accessible name. + #[must_use] + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = Some(placeholder.into()); + self + } +} + +impl Widget for ModelCombobox<'_> { + fn ui(self, ui: &mut Ui) -> Response { + let combobox = Combobox { + id_salt: self.id_salt, + text: self.text, + options: &[] as &[&str], + model: Some(self.model), + placeholder: self.placeholder, + label: self.label, + size: self.size, + width: self.width, + freeform: self.freeform, + preview: self.preview, + probe: self.probe, + }; + combobox.ui(ui) + } +} + +impl> Widget for Combobox<'_, T> { fn ui(self, ui: &mut Ui) -> Response { let theme = Theme::get(ui.ctx()); let enabled = ui.is_enabled(); @@ -102,6 +262,7 @@ impl Widget for Combobox<'_> { let id = ui.make_persistent_id(self.id_salt); let open_id = id.with("open"); let active_id = id.with("active"); + let navigated_id = id.with("navigated"); let rect = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover()).0; let icon_px = icon_points(self.size); @@ -140,17 +301,34 @@ impl Widget for Combobox<'_> { let mut open = ui .ctx() .data(|data| data.get_temp::(open_id).unwrap_or(false)); - let filtered = filter_options(&self.options, self.text); + let filter_id = id.with("filter"); + let view = OptionView { + slice: self.options, + model: self.model, + }; + let filtered = if open { + cached_filter(ui, filter_id, &view, self.text, self.probe) + } else { + Vec::new() + }; let mut active = ui .ctx() .data(|data| data.get_temp::(active_id)) .unwrap_or(0) .min(filtered.len().saturating_sub(1)); + // Set only by arrow keys, and cleared when the text changes, so a freeform Enter can + // tell a keyboard selection from the default highlight on the first filtered option. + let mut navigated = ui + .ctx() + .data(|data| data.get_temp::(navigated_id)) + .unwrap_or(false) + && !filtered.is_empty(); let focused = response.has_focus() && enabled; if response.changed() && enabled && !self.text.is_empty() { open = true; active = 0; + navigated = false; } if enabled && focused { let input = ui.input(|i| i.clone()); @@ -158,28 +336,39 @@ impl Widget for Combobox<'_> { open = true; } if open && input.key_pressed(egui::Key::ArrowDown) { - active = step(Some(active), filtered.len(), 1); + // The first arrow selects the highlighted option; later arrows move it. + if navigated { + active = step(Some(active), filtered.len(), 1); + } + navigated = !filtered.is_empty(); } if open && input.key_pressed(egui::Key::ArrowUp) { - active = step(Some(active), filtered.len(), -1); + if navigated { + active = step(Some(active), filtered.len(), -1); + } + navigated = !filtered.is_empty(); } if input.key_pressed(egui::Key::Escape) { open = false; } if enter { if open { - if self.freeform { - // The typed text is already in `self.text`. Keep it. - response.mark_changed(); - } else if let Some((_, label)) = filtered.get(active) { - *self.text = (*label).to_owned(); - response.mark_changed(); + let commit_active = if self.freeform { navigated } else { true }; + if commit_active { + if let Some((_, label)) = filtered.get(active) { + *self.text = (*label).to_owned(); + response.mark_changed(); + } else if !self.freeform { + self.text.clear(); + response.mark_changed(); + } } else { - self.text.clear(); + // The typed text is already in `self.text`. Keep it. response.mark_changed(); } } open = false; + navigated = false; } } @@ -195,13 +384,15 @@ impl Widget for Combobox<'_> { response.request_focus(); } if open && !response.has_focus() && !chevron_response.clicked() { - let over_popup = ui.input(|input| { - input - .pointer - .interact_pos() - .is_some_and(|pos| pos.y >= rect.bottom()) + // A key press moves the pointer's interact position off the field. Close only on + // a press that is not over the popup (WI-145 / #145). + let pressed_outside = ui.input(|input| { + let pointer = &input.pointer; + let pressed = pointer.any_pressed() || pointer.any_click(); + let over_popup = pointer.interact_pos().is_some_and(|pos| pos.y >= rect.bottom()); + pressed && !over_popup }); - if !over_popup { + if pressed_outside { open = false; } } @@ -236,12 +427,28 @@ impl Widget for Combobox<'_> { ); } - let accessible_name = if hint.is_empty() { - "Combobox".to_owned() - } else { - hint - }; + // Name, placeholder and value stay separate. TextEdit already wrote the AccessKit + // node (the placeholder as hint_text); a later widget_info does not replace it, so + // the name is set on that node. The value is the typed text, not the name. + let accessible_name = self + .label + .clone() + .filter(|label| !label.is_empty()) + .or_else(|| super::field::enclosing_field_label(ui.ctx()).filter(|label| !label.is_empty())) + .or_else(|| { + let placeholder = hint.trim(); + if placeholder.is_empty() { + None + } else { + Some(placeholder.to_owned()) + } + }) + .unwrap_or_else(|| "Combobox".to_owned()); let value_text = self.text.clone(); + ui.ctx().accesskit_node_builder(response.id, |builder| { + builder.set_label(accessible_name.clone()); + builder.set_value(value_text.clone()); + }); response.widget_info(|| { let mut info = WidgetInfo::labeled(WidgetType::ComboBox, enabled, accessible_name.clone()); info.current_text_value = Some(value_text.clone()); @@ -277,7 +484,470 @@ impl Widget for Combobox<'_> { ui.ctx().data_mut(|data| { data.insert_temp(open_id, open); data.insert_temp(active_id, active); + data.insert_temp(navigated_id, navigated); + if !open { + data.remove::(filter_id); + } }); response } } + +struct OptionView<'a, T: AsRef> { + slice: &'a [T], + model: Option<&'a dyn ComboboxOptions>, +} + +impl> ComboboxOptions for OptionView<'_, T> { + fn len(&self) -> usize { + if let Some(model) = self.model { + model.len() + } else { + self.slice.len() + } + } + + fn get(&self, index: usize) -> &str { + if let Some(model) = self.model { + model.get(index) + } else { + self.slice[index].as_ref() + } + } + + fn revision(&self) -> u64 { + self.model + .map(|model| model.revision()) + .unwrap_or_else(|| slice_fingerprint(self.slice)) + } +} + +/// Cheap identity for a borrowed slice. Length plus a FNV-1a of every label, so a shorter list +/// or a same-length list with different text does not reuse another slice's filtered indexes. +fn slice_fingerprint>(slice: &[T]) -> u64 { + const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + let mut hash = OFFSET; + for label in slice { + for byte in label.as_ref().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(PRIME); + } + hash ^= 0xff; + hash = hash.wrapping_mul(PRIME); + } + hash ^ (slice.len() as u64) +} + +fn filter_matches(cache: &FilterCache, query: &str, options: &dyn ComboboxOptions) -> bool { + cache.query == query + && cache.revision == options.revision() + && cache.indexes.iter().all(|index| *index < options.len()) +} + +fn cached_filter<'b>( + ui: &Ui, + filter_id: Id, + options: &'b dyn ComboboxOptions, + query: &str, + probe: Option<&'static AtomicU64>, +) -> Vec<(usize, &'b str)> { + let cached = ui.ctx().data(|data| data.get_temp::(filter_id)); + let indexes = if let Some(cache) = cached.filter(|cache| filter_matches(cache, query, options)) { + cache.indexes + } else { + if let Some(probe) = probe { + probe.fetch_add(1, Ordering::Relaxed); + } + let indexes = filter_indexes(options, query); + ui.ctx().data_mut(|data| { + data.insert_temp( + filter_id, + FilterCache { + query: query.to_owned(), + revision: options.revision(), + indexes: indexes.clone(), + }, + ); + }); + indexes + }; + indexes + .into_iter() + .filter(|index| *index < options.len()) + .map(|index| (index, options.get(index))) + .collect() +} + +fn filter_indexes(options: &dyn ComboboxOptions, query: &str) -> Vec { + let count = options.len(); + if query.is_empty() { + return (0..count).collect(); + } + let query = query.to_lowercase(); + let mut prefix = Vec::new(); + let mut rest = Vec::new(); + for index in 0..count { + let label = options.get(index); + let lower = label.to_lowercase(); + if lower.starts_with(&query) { + prefix.push(index); + } else if lower.contains(&query) { + rest.push(index); + } + } + prefix.append(&mut rest); + prefix +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + use super::Combobox; + use crate::controls::Field; + use crate::theme::{self, Theme}; + use fluent2_tokens::FLUENT; + + fn install_egui_theme(ui: &egui::Ui) { + let theme = match ui.ctx().theme() { + egui::Theme::Light => Theme::light(FLUENT), + egui::Theme::Dark => Theme::dark(FLUENT), + }; + theme::install(ui.ctx(), &theme); + } + + fn freeform_city() -> (Rc>, Harness<'static, ()>) { + let text = Rc::new(RefCell::new(String::new())); + let shown = Rc::clone(&text); + let harness = Harness::new_ui(move |ui| { + install_egui_theme(ui); + let mut value = shown.borrow().clone(); + let response = ui.add( + Combobox::new("city", &mut value, &["Perth", "Paris", "Padua"]) + .placeholder("City") + .freeform(true) + .width(160.0), + ); + response.request_focus(); + *shown.borrow_mut() = value; + }); + (text, harness) + } + + #[test] + fn freeform_enter_commits_the_arrow_key_option() { + let (text, mut harness) = freeform_city(); + harness.run(); + harness.run(); + harness.get_by_label("City").type_text("pa"); + harness.run(); + harness.key_press(egui::Key::ArrowDown); + harness.run(); + harness.key_press(egui::Key::Enter); + harness.run(); + assert_eq!(text.borrow().as_str(), "Paris"); + } + + #[test] + fn freeform_second_arrow_commits_the_next_option() { + let (text, mut harness) = freeform_city(); + harness.run(); + harness.run(); + harness.get_by_label("City").type_text("pa"); + harness.run(); + harness.key_press(egui::Key::ArrowDown); + harness.run(); + harness.key_press(egui::Key::ArrowDown); + harness.run(); + harness.key_press(egui::Key::Enter); + harness.run(); + assert_eq!(text.borrow().as_str(), "Padua"); + } + + #[test] + fn freeform_typing_after_navigation_keeps_the_text() { + let (text, mut harness) = freeform_city(); + harness.run(); + harness.run(); + harness.get_by_label("City").type_text("pa"); + harness.run(); + harness.key_press(egui::Key::ArrowDown); + harness.run(); + harness.get_by_label("City").type_text("d"); + harness.run(); + harness.key_press(egui::Key::Enter); + harness.run(); + assert_eq!(text.borrow().as_str(), "pad"); + } + + #[test] + fn field_label_names_a_combobox_without_a_placeholder() { + let mut harness = Harness::new_ui(|ui| { + install_egui_theme(ui); + let mut value = String::new(); + Field::new("Home city").show(ui, |ui| { + ui.add(Combobox::new("city", &mut value, &["Perth", "Paris"]).width(160.0)); + }); + }); + harness.run(); + harness.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Home city"); + } + + #[test] + fn explicit_label_wins_over_the_field() { + let mut harness = Harness::new_ui(|ui| { + install_egui_theme(ui); + let mut value = String::new(); + Field::new("Home city").show(ui, |ui| { + ui.add( + Combobox::new("city", &mut value, &["Perth", "Paris"]) + .label("Destination") + .width(160.0), + ); + }); + }); + harness.run(); + harness.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Destination"); + harness.get_by_role_and_label(egui::accesskit::Role::Label, "Home city"); + } + + use std::sync::atomic::AtomicU64; + + use super::{ComboboxOptions, FilterProbe, ModelCombobox, SliceOptions}; + + struct Counting<'a> { + options: &'a [&'a str], + revision: u64, + } + + impl ComboboxOptions for Counting<'_> { + fn len(&self) -> usize { + self.options.len() + } + + fn get(&self, index: usize) -> &str { + self.options[index] + } + + fn revision(&self) -> u64 { + self.revision + } + } + + struct ProbeState { + text: String, + options: &'static [&'static str], + revision: u64, + open: bool, + probe: &'static AtomicU64, + } + + #[test] + fn closed_popup_does_not_filter() { + static PROBE: AtomicU64 = AtomicU64::new(0); + let mut harness = Harness::new_ui_state( + |ui, state: &mut ProbeState| { + install_egui_theme(ui); + let model = Counting { + options: state.options, + revision: state.revision, + }; + ui.add( + ModelCombobox::new("city", &mut state.text, &model) + .filter_probe(FilterProbe(state.probe)), + ); + }, + ProbeState { + text: "P".to_owned(), + options: &["Perth", "Paris"], + revision: 1, + open: false, + probe: &PROBE, + }, + ); + harness.run(); + harness.run(); + assert_eq!(PROBE.load(std::sync::atomic::Ordering::Relaxed), 0); + } + + #[test] + fn filter_reruns_when_the_query_or_model_changes_and_stays_cached() { + static PROBE: AtomicU64 = AtomicU64::new(0); + let mut harness = Harness::new_ui_state( + |ui, state: &mut ProbeState| { + install_egui_theme(ui); + let model = Counting { + options: state.options, + revision: state.revision, + }; + let response = ui.add( + ModelCombobox::new("city", &mut state.text, &model) + .filter_probe(FilterProbe(state.probe)) + .placeholder("City"), + ); + if state.open { + ui.ctx() + .data_mut(|data| data.insert_temp(response.id.with("open"), true)); + } + }, + ProbeState { + text: "P".to_owned(), + options: &["Perth", "Paris", "Rome"], + revision: 1, + open: true, + probe: &PROBE, + }, + ); + harness.run(); + assert_eq!( + PROBE.load(std::sync::atomic::Ordering::Relaxed), + 1, + "opening filters once" + ); + harness.run(); + assert_eq!( + PROBE.load(std::sync::atomic::Ordering::Relaxed), + 1, + "same query stays cached" + ); + harness.state_mut().text = "Pa".to_owned(); + harness.run(); + assert_eq!( + PROBE.load(std::sync::atomic::Ordering::Relaxed), + 2, + "a new query filters again" + ); + harness.state_mut().revision = 2; + harness.state_mut().options = &["Paris", "Padua", "Rome"]; + harness.run(); + assert_eq!( + PROBE.load(std::sync::atomic::Ordering::Relaxed), + 3, + "a new revision filters again" + ); + let options = harness.state().options; + let shown = SliceOptions::new(options, harness.state().revision); + assert_eq!(shown.get(0), "Paris"); + harness.state_mut().open = false; + let before = PROBE.load(std::sync::atomic::Ordering::Relaxed); + harness.run(); + assert_eq!( + PROBE.load(std::sync::atomic::Ordering::Relaxed), + before, + "closed does not filter" + ); + } + + struct SliceProbe { + text: String, + options: Vec<&'static str>, + open: bool, + shown: Vec, + } + + fn slice_harness(options: Vec<&'static str>) -> Harness<'static, SliceProbe> { + Harness::new_ui_state( + |ui, state: &mut SliceProbe| { + install_egui_theme(ui); + let response = + ui.add(Combobox::new("city", &mut state.text, &state.options).placeholder("City")); + if state.open { + ui.ctx() + .data_mut(|data| data.insert_temp(response.id.with("open"), true)); + } + let view = super::OptionView { + slice: state.options.as_slice(), + model: None, + }; + state.shown = super::cached_filter(ui, response.id.with("filter"), &view, &state.text, None) + .into_iter() + .map(|(_, label)| label.to_owned()) + .collect(); + }, + SliceProbe { + text: "P".to_owned(), + options, + open: true, + shown: Vec::new(), + }, + ) + } + + #[test] + fn shrinking_an_open_slice_with_the_same_query_shows_the_new_options() { + let mut harness = slice_harness(vec!["Perth", "Paris", "Padua", "Portland"]); + harness.run(); + harness.run(); + assert_eq!(harness.state().shown, ["Perth", "Paris", "Padua", "Portland"]); + harness.state_mut().options = vec!["Perth", "Paris"]; + harness.run(); + assert_eq!(harness.state().shown, ["Perth", "Paris"]); + } + + #[test] + fn a_same_length_slice_with_new_contents_is_shown() { + let mut harness = slice_harness(vec!["Perth", "Paris", "Padua"]); + harness.run(); + harness.run(); + harness.state_mut().options = vec!["Pisa", "Prague", "Porto"]; + harness.run(); + assert_eq!(harness.state().shown, ["Pisa", "Prague", "Porto"]); + } + + #[test] + fn time_picker_step_change_while_open_lists_the_new_times() { + use crate::controls::calendar::TimeOfDay; + use crate::controls::date_picker::TimePicker; + + struct StepState { + value: Option, + step: u16, + open: bool, + shown: Vec, + } + + let mut harness = Harness::new_ui_state( + |ui, state: &mut StepState| { + install_egui_theme(ui); + let built = TimePicker::new("start", &mut state.value).step_minutes(state.step); + let options: Vec = built.options().iter().map(|time| time.format(false)).collect(); + let response = TimePicker::new("start", &mut state.value) + .step_minutes(state.step) + .show(ui) + .response; + if state.open { + ui.ctx() + .data_mut(|data| data.insert_temp(response.id.with("open"), true)); + } + let view = super::OptionView { + slice: options.as_slice(), + model: None, + }; + state.shown = super::cached_filter(ui, response.id.with("filter"), &view, "", None) + .into_iter() + .map(|(_, label)| label.to_owned()) + .collect(); + }, + StepState { + value: TimeOfDay::new(9, 0), + step: 60, + open: true, + shown: Vec::new(), + }, + ); + harness.run(); + assert!(harness.state().shown.iter().any(|label| label == "09:00")); + assert!(!harness.state().shown.iter().any(|label| label == "09:30")); + let hourly = harness.state().shown.len(); + harness.state_mut().step = 30; + harness.run(); + assert!(harness.state().shown.iter().any(|label| label == "09:00")); + assert!(harness.state().shown.iter().any(|label| label == "09:30")); + assert!(harness.state().shown.len() > hourly); + } +} diff --git a/crates/fluent2-egui/src/controls/dropdown.rs b/crates/fluent2-egui/src/controls/dropdown.rs index c6308fe..5455f87 100644 --- a/crates/fluent2-egui/src/controls/dropdown.rs +++ b/crates/fluent2-egui/src/controls/dropdown.rs @@ -500,6 +500,7 @@ pub(super) fn paint_outline( } /// Options whose text contains `query`, prefix matches first. An empty query returns every option. +#[cfg(test)] pub(super) fn filter_options<'a>(options: &'a [String], query: &str) -> Vec<(usize, &'a str)> { if query.is_empty() { return options diff --git a/crates/fluent2-egui/src/controls/field.rs b/crates/fluent2-egui/src/controls/field.rs index 4d936d0..8ee9555 100644 --- a/crates/fluent2-egui/src/controls/field.rs +++ b/crates/fluent2-egui/src/controls/field.rs @@ -7,11 +7,11 @@ //! //! # Accessible name //! -//! egui 0.36's [`egui::Widget`] does not accept a name from its parent, and -//! AccessKit node builders are not exposed on the public [`egui::Context`]. -//! The field therefore announces its own label (with the required marker) as -//! a label widget. The child keeps the accessible name its own widget sets. -//! Callers that need the child named should pass that label into the child. +//! The field announces its own label (with the required marker) as a label +//! widget. While the child runs, that label is stored on the context, and a +//! child control with no label of its own reads it through +//! [`enclosing_field_label`]. The previous value is restored afterwards, so +//! nested fields keep their own names. use egui::{Sense, TextStyle, Ui, WidgetInfo, WidgetText, WidgetType}; diff --git a/crates/fluent2-egui/src/controls/mod.rs b/crates/fluent2-egui/src/controls/mod.rs index 0a8ff57..22ca16b 100644 --- a/crates/fluent2-egui/src/controls/mod.rs +++ b/crates/fluent2-egui/src/controls/mod.rs @@ -109,7 +109,7 @@ pub use checkbox::{ CheckState, Checkbox, }; -pub use combobox::Combobox; +pub use combobox::{Combobox, ComboboxOptions, FilterProbe, ModelCombobox, SliceOptions}; pub use compound_button::CompoundButton; #[rustfmt::skip] pub use date_picker::{ diff --git a/crates/fluent2-egui/src/controls/slider.rs b/crates/fluent2-egui/src/controls/slider.rs index 58797cb..bed9d67 100644 --- a/crates/fluent2-egui/src/controls/slider.rs +++ b/crates/fluent2-egui/src/controls/slider.rs @@ -8,10 +8,6 @@ use egui::{Key, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Widget, WidgetInf use crate::foundation::focus::paint_focus_ring; use crate::theme::{self, Theme}; -const RAIL: f32 = 4.0; -const THUMB: f32 = 20.0; -const DOT: f32 = 12.0; - /// A continuous or stepped slider. /// /// ``` @@ -93,16 +89,15 @@ impl Widget for Slider<'_> { let label = label .or_else(|| super::field::enclosing_field_label(ui.ctx())) .unwrap_or_else(|| "Slider".to_owned()); - let current = snap(clamp(*value, min, max), min, max, step); - *value = current; - + let metrics = theme.slider; let length = if vertical { ui.available_height().clamp(80.0, 160.0) } else { ui.available_width().clamp(120.0, 240.0) }; - // Density grows the cross axis only (44 in Touch); the thumb stays 20. - let cross = crate::theme::density_of(ui).height(THUMB); + // Density grows the cross axis only (44 in Touch); the thumb diameter stays a + // component token and is not scaled. + let cross = crate::theme::density_of(ui).height(metrics.thumb); let size = if vertical { Vec2::new(cross, length) } else { @@ -112,14 +107,17 @@ impl Widget for Slider<'_> { let enabled = ui.is_enabled(); let span = (max - min).max(f64::EPSILON); + // Snap the incoming value, then apply this frame's drag or keys before paint + // and AccessKit see it. Reading the value before input paints and reports the + // previous frame's thumb (issue #143). + let before = *value; + let mut current = snap(clamp(*value, min, max), min, max, step); + if enabled && response.dragged() { let pointer = response.interact_pointer_pos().unwrap_or(rect.center()); - let t = fraction_at(pointer, rect, vertical); - *value = snap(min + t * span, min, max, step); - response.mark_changed(); - } - - if enabled && response.has_focus() { + let t = fraction_at(pointer, rect, vertical, metrics.thumb); + current = snap(value_at(t, min, max), min, max, step); + } else if enabled && response.has_focus() { let increment = step.unwrap_or(span * 0.01); let (home, end, left, right, up, down) = ui.input(|input| { ( @@ -143,16 +141,23 @@ impl Widget for Slider<'_> { None }; if let Some(next) = next { - *value = snap(clamp(next, min, max), min, max, step); - response.mark_changed(); + current = snap(clamp(next, min, max), min, max, step); } } + *value = current; + // Changed only when the value the caller holds actually moved, including a snap + // of an out-of-range or off-step value they wrote in. A drag or key that lands + // on the same value is not a change. + if current != before { + response.mark_changed(); + } + paint( ui, &theme, rect, - fraction(current, min, span), + fraction(current, min, max), vertical, enabled, response.has_focus(), @@ -174,12 +179,21 @@ impl Widget for Slider<'_> { fn paint(ui: &Ui, theme: &Theme, rect: Rect, fraction: f32, vertical: bool, enabled: bool, focused: bool) { let color = &theme.color; let painter = ui.painter(); + let metrics = theme.slider; + let rail_size = metrics.rail; + let thumb_size = metrics.thumb; + let dot_size = metrics.dot; let rail = if vertical { - Rect::from_center_size(rect.center(), Vec2::new(RAIL, rect.height() - THUMB)) + Rect::from_center_size(rect.center(), Vec2::new(rail_size, rect.height() - thumb_size)) + } else { + Rect::from_center_size(rect.center(), Vec2::new(rect.width() - thumb_size, rail_size)) + }; + let rail_fill = if enabled { + color.neutral_stroke_accessible } else { - Rect::from_center_size(rect.center(), Vec2::new(rect.width() - THUMB, RAIL)) + color.neutral_stroke_disabled }; - painter.rect_filled(rail, RAIL / 2.0, theme::c(color.neutral_stroke_accessible)); + painter.rect_filled(rail, rail_size / 2.0, theme::c(rail_fill)); let filled = if vertical { Rect::from_min_max( Pos2::new(rail.left(), rail.bottom() - rail.height() * fraction), @@ -191,38 +205,48 @@ fn paint(ui: &Ui, theme: &Theme, rect: Rect, fraction: f32, vertical: bool, enab Pos2::new(rail.left() + rail.width() * fraction, rail.bottom()), ) }; + let brand = if enabled { + color.compound_brand_background + } else { + color.neutral_foreground_disabled + }; if fraction > 0.0 { - painter.rect_filled(filled, RAIL / 2.0, theme::c(color.compound_brand_background)); + painter.rect_filled(filled, rail_size / 2.0, theme::c(brand)); } let thumb_center = if vertical { Pos2::new(rect.center().x, rail.bottom() - rail.height() * fraction) } else { Pos2::new(rail.left() + rail.width() * fraction, rect.center().y) }; - let thumb = Rect::from_center_size(thumb_center, Vec2::splat(THUMB)); - painter.circle_filled(thumb.center(), THUMB / 2.0, theme::c(color.neutral_background_1)); + let thumb = Rect::from_center_size(thumb_center, Vec2::splat(thumb_size)); + let thumb_fill = if enabled { + color.neutral_background_1 + } else { + color.neutral_background_disabled + }; + let thumb_stroke = if enabled { + color.neutral_stroke_1 + } else { + color.neutral_stroke_disabled + }; + painter.circle_filled(thumb.center(), thumb_size / 2.0, theme::c(thumb_fill)); painter.circle_stroke( thumb.center(), - THUMB / 2.0 - 0.5, - Stroke::new(1.0, theme::c(color.neutral_stroke_1)), - ); - painter.circle_filled( - thumb.center(), - DOT / 2.0, - theme::c(color.compound_brand_background), + thumb_size / 2.0 - 0.5, + Stroke::new(1.0, theme::c(thumb_stroke)), ); + painter.circle_filled(thumb.center(), dot_size / 2.0, theme::c(brand)); if focused { - paint_focus_ring(ui, thumb, egui::CornerRadius::same((THUMB / 2.0) as u8)); + paint_focus_ring(ui, thumb, egui::CornerRadius::same((thumb_size / 2.0) as u8)); } - let _ = enabled; } -fn fraction_at(pointer: Pos2, rect: Rect, vertical: bool) -> f64 { +fn fraction_at(pointer: Pos2, rect: Rect, vertical: bool, thumb: f32) -> f64 { // The rail is inset by half a thumb on both ends, matching `paint`. let (along, length) = if vertical { - (rect.bottom() - THUMB / 2.0 - pointer.y, rect.height() - THUMB) + (rect.bottom() - thumb / 2.0 - pointer.y, rect.height() - thumb) } else { - (pointer.x - rect.left() - THUMB / 2.0, rect.width() - THUMB) + (pointer.x - rect.left() - thumb / 2.0, rect.width() - thumb) }; if length <= f32::EPSILON { return 0.0; @@ -230,8 +254,66 @@ fn fraction_at(pointer: Pos2, rect: Rect, vertical: bool) -> f64 { (along as f64 / length as f64).clamp(0.0, 1.0) } -fn fraction(value: f64, min: f64, span: f64) -> f32 { - ((value - min) / span).clamp(0.0, 1.0) as f32 +fn fraction(value: f64, min: f64, max: f64) -> f32 { + unit_position(value, min, max) as f32 +} + +/// Place `t` in `[0, 1]` onto `[min, max]` without forming `max - min`. +/// +/// `max - min` overflows to infinity for extreme finite endpoints such as +/// `-f64::MAX..=f64::MAX`, and a later division then becomes NaN. +fn value_at(t: f64, min: f64, max: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + if !min.is_finite() || !max.is_finite() || min == max { + return min; + } + let mixed = min * (1.0 - t) + max * t; + if mixed.is_finite() { + return mixed.clamp(min, max); + } + // Opposite-sign extremes overflow the weighted sum. Split at zero. + if min < 0.0 && max > 0.0 { + let zero = (-min) / (max + (-min)); + if zero.is_finite() { + let placed = if t <= zero { + min * (1.0 - t / zero) + } else { + max * ((t - zero) / (1.0 - zero)) + }; + if placed.is_finite() { + return placed.clamp(min, max); + } + } + } + min +} + +/// `(value - min) / (max - min)` without a subtraction that can overflow. +fn unit_position(value: f64, min: f64, max: f64) -> f64 { + if !value.is_finite() || !min.is_finite() || !max.is_finite() || min == max { + return 0.0; + } + let value = value.clamp(min, max); + let direct = (value - min) / (max - min); + if direct.is_finite() { + return direct.clamp(0.0, 1.0); + } + if min < 0.0 && max > 0.0 { + if value <= 0.0 { + let to_zero = (-min) / (max + (-min)); + let along = if value == min { 0.0 } else { 1.0 - value / min }; + if to_zero.is_finite() && along.is_finite() { + return (to_zero * along).clamp(0.0, 1.0); + } + } else { + let from_zero = max / (max + (-min)); + let along = value / max; + if from_zero.is_finite() && along.is_finite() { + return (1.0 - from_zero + from_zero * along).clamp(0.0, 1.0); + } + } + } + 0.0 } fn clamp(value: f64, min: f64, max: f64) -> f64 { @@ -242,9 +324,121 @@ fn clamp(value: f64, min: f64, max: f64) -> f64 { } fn snap(value: f64, min: f64, max: f64, step: Option) -> f64 { - let Some(step) = step else { + let Some(step) = step.filter(|step| step.is_finite() && *step > 0.0) else { return clamp(value, min, max); }; + let value = clamp(value, min, max); let steps = ((value - min) / step).round(); - clamp(min + steps * step, min, max) + if !steps.is_finite() { + return value; + } + let snapped = min + steps * step; + if !snapped.is_finite() { + return value; + } + clamp(snapped, min, max) +} + +#[cfg(test)] +mod tests { + use super::Slider; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + struct Frame { + value: f64, + reported: f64, + changed: bool, + } + + fn slider_frame(start: f64, step: Option, drive: impl FnOnce(&Harness)) -> Frame { + let mut harness = Harness::builder().build_ui_state( + |ui, frame: &mut Frame| { + let response = ui.add(Slider::new(&mut frame.value, 0.0..=100.0).step(step)); + let mut reported = f64::NAN; + ui.ctx().accesskit_node_builder(response.id, |node| { + if let Some(value) = node.numeric_value() { + reported = value; + } + }); + frame.reported = reported; + frame.changed = response.changed(); + }, + Frame { + value: start, + reported: f64::NAN, + changed: false, + }, + ); + drive(&harness); + harness.step(); + Frame { + value: harness.state().value, + reported: harness.state().reported, + changed: harness.state().changed, + } + } + + #[test] + fn arrow_key_updates_value_and_a11y_in_the_same_frame() { + let frame = slider_frame(40.0, Some(10.0), |harness| { + harness.get_by_label("Slider").focus(); + harness.key_down(egui::Key::ArrowRight); + }); + assert_eq!(frame.value, 50.0); + assert_eq!(frame.reported, 50.0); + assert!(frame.changed); + } + + #[test] + fn drag_updates_value_and_a11y_in_the_same_frame() { + let frame = slider_frame(0.0, None, |harness| { + let node = harness.get_by_label("Slider"); + let rect = node.rect(); + // Press at the rail start, then move to the far end in the same queued step. + let start = rect.left_center(); + let end = rect.right_center(); + harness.event(egui::Event::PointerMoved(start)); + harness.event(egui::Event::PointerButton { + pos: start, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }); + harness.event(egui::Event::PointerMoved(end)); + }); + assert!(frame.value > 90.0, "dragged value {}", frame.value); + assert!((frame.reported - frame.value).abs() < 1e-9); + assert!(frame.changed); + } + + #[test] + fn unchanged_drag_does_not_mark_changed() { + // A press that does not move the pointer stays at the snapped start. + let frame = slider_frame(0.0, Some(10.0), |harness| { + let rect = harness.get_by_label("Slider").rect(); + let pos = rect.left_center(); + harness.event(egui::Event::PointerMoved(pos)); + harness.event(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }); + }); + assert_eq!(frame.value, 0.0); + assert_eq!(frame.reported, 0.0); + assert!(!frame.changed); + } + + #[test] + fn key_that_does_not_move_does_not_mark_changed() { + let frame = slider_frame(0.0, Some(10.0), |harness| { + harness.get_by_label("Slider").focus(); + harness.key_down(egui::Key::ArrowLeft); + }); + assert_eq!(frame.value, 0.0); + assert_eq!(frame.reported, 0.0); + assert!(!frame.changed); + } } diff --git a/crates/fluent2-egui/src/data/html/data_uri.rs b/crates/fluent2-egui/src/data/html/data_uri.rs index 5403d8e..4053e68 100644 --- a/crates/fluent2-egui/src/data/html/data_uri.rs +++ b/crates/fluent2-egui/src/data/html/data_uri.rs @@ -53,10 +53,24 @@ fn extension(mime: &str) -> &'static str { } /// Standard or URL-safe base64, ignoring whitespace (mail wraps long lines) and padding. +/// +/// The encoded length is checked before any allocation. Four encoded characters produce at most +/// three bytes, so an input whose decoded size must exceed [`MAX_BYTES`] is rejected with no +/// buffer. Whitespace is skipped, so a whitespace-heavy payload under the decoded limit still +/// decodes. The initial buffer is capped at [`MAX_BYTES`]; that cap is the decoded-byte limit, +/// not an image-pixel limit. pub(crate) fn decode_base64(input: &str) -> Option> { - let mut out = Vec::with_capacity(input.len() * 3 / 4); + // Four significant characters decode to three bytes. Reject before allocating when even a + // payload with no whitespace would exceed the decoded-byte limit. + let max_significant = MAX_BYTES.checked_mul(4)?.checked_div(3)?.saturating_add(2); + if input.len() > max_significant { + return None; + } + let capacity = input.len().checked_mul(3)?.checked_div(4)?.min(MAX_BYTES); + let mut out = Vec::with_capacity(capacity); let mut buffer: u32 = 0; let mut bits = 0; + let mut saw_padding = false; for byte in input.bytes() { let value = match byte { b'A'..=b'Z' => byte - b'A', @@ -64,7 +78,10 @@ pub(crate) fn decode_base64(input: &str) -> Option> { b'0'..=b'9' => byte - b'0' + 52, b'+' | b'-' => 62, b'/' | b'_' => 63, - b'=' => break, + b'=' => { + saw_padding = true; + break; + } b' ' | b'\n' | b'\r' | b'\t' => continue, _ => return None, }; @@ -78,6 +95,17 @@ pub(crate) fn decode_base64(input: &str) -> Option> { } } } + if saw_padding { + let mut after = input.split('=').nth(1).unwrap_or("").bytes(); + if after.any(|byte| !matches!(byte, b'=' | b' ' | b'\n' | b'\r' | b'\t')) { + return None; + } + } + // One significant character cannot complete a byte: truncated (`A`) or bad padding (`A===`). + // '=' alone never enters the sextet loop, so it stays an empty payload. + if bits == 6 { + return None; + } Some(out) } @@ -92,6 +120,52 @@ mod tests { assert_eq!(decode_base64("a$b"), None); } + #[test] + fn oversized_encoded_input_is_rejected_without_a_decoded_buffer() { + // Longer than any payload that can decode to MAX_BYTES, including padding slack. + let input = "A".repeat(MAX_BYTES.saturating_mul(4) / 3 + 3); + assert!(decode_base64(&input).is_none()); + } + + #[test] + fn whitespace_heavy_input_under_the_limit_decodes() { + let mut input = String::new(); + for (index, byte) in b"aGVsbG8=".iter().enumerate() { + if index > 0 { + input.push_str(" \n\r\t"); + } + input.push(*byte as char); + } + assert!(input.len() > 8); + assert_eq!(decode_base64(&input).as_deref(), Some(b"hello".as_slice())); + } + + #[test] + fn malformed_padding_outcomes() { + // Extra '=' stops decoding and does not change a complete payload. + assert_eq!(decode_base64("aGVsbG8===").as_deref(), Some(b"hello".as_slice())); + // Non-alphabet after padding, or a single significant character, is an error. + assert!(decode_base64("aGVsbG8=!!!!").is_none()); + assert_eq!(decode_base64("=").as_deref(), Some(b"".as_slice())); + assert!(decode_base64("A").is_none()); + assert!(decode_base64("A===").is_none()); + } + + #[test] + fn decoded_length_at_and_one_byte_over_the_limit() { + // 'A' decodes to 0x00. The largest multiple of 3 that fits in MAX_BYTES is the exact + // boundary the quartet decoder can land on. + let exact = MAX_BYTES / 3 * 3; + let at_limit = "A".repeat(exact / 3 * 4); + let decoded = decode_base64(&at_limit); + let len = decoded.as_ref().map(Vec::len); + assert_eq!(len, Some(exact)); + assert!(len.is_some_and(|len| len <= MAX_BYTES && MAX_BYTES - len < 3)); + // One more quartet is three more bytes, which crosses MAX_BYTES. + let one_over = format!("{at_limit}AAAA"); + assert!(decode_base64(&one_over).is_none()); + } + #[test] fn data_uris() { let (mime, bytes) = parse("data:image/png;base64,iVBORw0KGgo=").unwrap_or_default(); @@ -101,4 +175,28 @@ mod tests { assert!(parse("data:image/png,raw").is_none()); assert!(parse("https://example.com/a.png").is_none()); } + + fn prop_config() -> proptest::test_runner::Config { + proptest::test_runner::Config { + cases: 64, + failure_persistence: None, + rng_seed: proptest::test_runner::RngSeed::Fixed(0xF2E6_0158), + ..proptest::test_runner::Config::default() + } + } + + proptest::proptest! { + #![proptest_config(prop_config())] + + /// Arbitrary input never panics, and a successful decode never exceeds [`MAX_BYTES`]. + #[test] + fn decode_never_panics_and_stays_within_max( + bytes in proptest::collection::vec(proptest::num::u8::ANY, 0..512), + ) { + let input = String::from_utf8_lossy(&bytes); + if let Some(out) = decode_base64(&input) { + proptest::prop_assert!(out.len() <= MAX_BYTES); + } + } + } } diff --git a/crates/fluent2-egui/src/lib.rs b/crates/fluent2-egui/src/lib.rs index 61e213f..79a5932 100644 --- a/crates/fluent2-egui/src/lib.rs +++ b/crates/fluent2-egui/src/lib.rs @@ -136,6 +136,7 @@ pub use surfaces::{ }; #[rustfmt::skip] pub use theme::{ + SliderMetrics, Theme, ThemeKind, }; diff --git a/crates/fluent2-egui/src/surfaces/dialog.rs b/crates/fluent2-egui/src/surfaces/dialog.rs index b8d5598..45facf1 100644 --- a/crates/fluent2-egui/src/surfaces/dialog.rs +++ b/crates/fluent2-egui/src/surfaces/dialog.rs @@ -26,32 +26,67 @@ const CHROME_ESTIMATE: f32 = 96.0; const MIN_BODY_HEIGHT: f32 = 48.0; /// How the dialog reacts to the backdrop and to Escape. +/// +/// Every dialog is modal: while one is open the backdrop blocks input to the UI behind it, +/// whatever this variant is. The variant only chooses what dismisses the dialog. `Modal` is the +/// default and is the only kind a backdrop click closes. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum DialogKind { /// A backdrop click and Escape both close the dialog. The default. + /// + /// The dialog still blocks the UI behind it, as every kind does. #[default] Modal, /// Escape closes the dialog. A backdrop click does not. - NonModal, + /// + /// The dialog is still modal: the backdrop blocks input to the UI behind it. The name is the + /// dismissal policy, not a non-modal window. [`DialogKind::NonModal`] is the old name. + Dismissible, /// Only a button closes the dialog. Backdrop clicks and Escape are ignored. + /// + /// The dialog is still modal: the backdrop blocks input to the UI behind it. Alert, } +#[allow(non_upper_case_globals)] +impl DialogKind { + /// The previous name of [`DialogKind::Dismissible`]. + /// + /// It never meant the dialog let background input through. Prefer [`DialogKind::Dismissible`]. + #[deprecated( + since = "0.3.0", + note = "renamed to DialogKind::Dismissible; every dialog is modal" + )] + pub const NonModal: Self = Self::Dismissible; + + /// Whether a backdrop click closes a dialog of this kind. Only [`DialogKind::Modal`] does. + /// + /// Every kind blocks background input. This is the dismissal policy, not modality. + #[must_use] + pub const fn backdrop_dismiss(self) -> bool { + matches!(self, Self::Modal) + } +} + /// Why a dialog closed. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// +/// [`CloseReason::Button`] carries the action's stable id, not its display text, so two actions +/// can share a label and a translated label still closes as the same action. +#[derive(Clone, Debug, Eq, PartialEq)] pub enum CloseReason { - /// Escape, on a [`DialogKind::Modal`] or [`DialogKind::NonModal`] with no popup open. + /// Escape, on a [`DialogKind::Modal`] or [`DialogKind::Dismissible`] with no popup open. Escape, /// A click on the backdrop of a [`DialogKind::Modal`]. Backdrop, - /// An action button. The label is the button's text. - Button(&'static str), + /// An action button. The string is the id passed to [`DialogActions::primary`] or + /// [`DialogActions::secondary`], never the label. + Button(String), /// `open` was cleared by the app, or [`DialogActions::close`] was called. Programmatic, } /// Whether the dialog is still open. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum DialogResult { /// The body ran. `R` is what the body closure returned. Open(R), @@ -62,10 +97,16 @@ pub enum DialogResult { /// The action declaration closure accepted by [`Dialog::actions`]. type ActionBuild<'a> = Box) + 'a>; +/// One footer button: a stable id and the text drawn on it. +struct DialogAction { + id: String, + text: WidgetText, +} + /// Footer buttons declared by [`Dialog::actions`]. pub struct DialogActions<'a> { - primary: Option<&'static str>, - secondary: Vec<&'static str>, + primary: Option, + secondary: Vec, close_requested: bool, dialog: Id, _lifetime: std::marker::PhantomData<&'a mut Ui>, @@ -74,13 +115,23 @@ pub struct DialogActions<'a> { impl DialogActions<'_> { /// The primary action. Enter activates it unless focus is in a multiline field or on another /// button. Drawn first. - pub fn primary(&mut self, label: &'static str) { - self.primary = Some(label); + /// + /// `id` is stable across frames and languages. It is what [`CloseReason::Button`] returns and + /// what the button's widget id is derived from. `text` is only what the button shows, and may + /// be borrowed or owned. + pub fn primary(&mut self, id: impl Into, text: impl Into) { + self.primary = Some(DialogAction { + id: id.into(), + text: text.into(), + }); } - /// A secondary action, drawn after the primary. - pub fn secondary(&mut self, label: &'static str) { - self.secondary.push(label); + /// A secondary action, drawn after the primary. `id` and `text` are as for [`Self::primary`]. + pub fn secondary(&mut self, id: impl Into, text: impl Into) { + self.secondary.push(DialogAction { + id: id.into(), + text: text.into(), + }); } /// Close without a button, as [`CloseReason::Programmatic`]. @@ -89,9 +140,11 @@ impl DialogActions<'_> { } /// The egui id of a button in this dialog, for example to focus it or drive it in a test. + /// + /// `id` is the action id passed to [`Self::primary`] or [`Self::secondary`], not the label. #[must_use] - pub fn button_id(&self, label: &'static str) -> egui::Id { - self.dialog.with("action").with(label) + pub fn button_id(&self, id: impl std::hash::Hash + std::fmt::Debug) -> egui::Id { + self.dialog.with("action").with(id) } } @@ -104,8 +157,8 @@ impl DialogActions<'_> { /// .title("Save") /// .kind(DialogKind::Modal) /// .actions(|actions| { -/// actions.primary("Save"); -/// actions.secondary("Cancel"); +/// actions.primary("save", "Save"); +/// actions.secondary("cancel", "Cancel"); /// }) /// .show(ctx, open, |ui| { /// ui.label("Save this document?"); @@ -141,7 +194,10 @@ impl<'a> Dialog<'a> { self } - /// Modality. The default is [`DialogKind::Modal`]. + /// Dismissal policy. The default is [`DialogKind::Modal`]. + /// + /// Every dialog is modal and blocks background input. The kind only chooses whether a + /// backdrop click or Escape closes it. #[must_use] pub fn kind(mut self, kind: DialogKind) -> Self { self.kind = kind; @@ -259,7 +315,7 @@ impl<'a> Dialog<'a> { if let Some(build) = self.actions { build(&mut declared); } - let primary = declared.primary; + let primary_id = declared.primary.as_ref().map(|action| action.id.clone()); let shown_actions = show_actions(ui, declared, stack_actions, opening); // Measured from the layout cursor, not `min_rect`: the surface keeps last // frame's size, so its rect would feed the old height back in and never shrink. @@ -274,13 +330,13 @@ impl<'a> Dialog<'a> { // A focusable surface, so Tab has a stop even when the body has no widgets. // It is allocated last, which makes it the last stop; the title is not a stop. let surface = ui.interact(ui.min_rect(), surface_id, Sense::focusable_noninteractive()); - if opening && primary.is_none() { + if opening && primary_id.is_none() { surface.request_focus(); } let role = match self.kind { DialogKind::Alert => egui::accesskit::Role::AlertDialog, - DialogKind::Modal | DialogKind::NonModal => egui::accesskit::Role::Dialog, + DialogKind::Modal | DialogKind::Dismissible => egui::accesskit::Role::Dialog, }; ui.ctx().accesskit_node_builder(surface_id, |node| { node.set_role(role); @@ -295,13 +351,13 @@ impl<'a> Dialog<'a> { } }); - (value, primary, shown_actions) + (value, primary_id, shown_actions) }); - let (value, primary, action_state) = shown.inner; + let (value, primary_id, action_state) = shown.inner; let mut close = None; - if let Some(label) = action_state.clicked { - close = Some(CloseReason::Button(label)); + if let Some(id) = action_state.clicked { + close = Some(CloseReason::Button(id)); } if action_state.close_requested { close = Some(CloseReason::Programmatic); @@ -339,9 +395,9 @@ impl<'a> Dialog<'a> { if enter && !on_other && !ctx.text_edit_focused() - && let Some(label) = primary + && let Some(id) = primary_id { - close = Some(CloseReason::Button(label)); + close = Some(CloseReason::Button(id)); } } @@ -375,7 +431,7 @@ fn reveal_new_focus(ui: &Ui, key: Id) { } struct ActionState { - clicked: Option<&'static str>, + clicked: Option, close_requested: bool, } @@ -450,44 +506,65 @@ fn show_actions(ui: &mut Ui, actions: DialogActions<'_>, stack: bool, opening: b // Recomputed every frame: `note_button` sets it again if focus is on a non-primary button. ui.ctx() .data_mut(|data| data.insert_temp(dialog_scope.with("focus_other_button"), false)); - let labels: Vec<(&'static str, Appearance)> = actions + let buttons: Vec<(DialogAction, Appearance)> = actions .primary .into_iter() - .map(|label| (label, Appearance::Primary)) + .map(|action| (action, Appearance::Primary)) .chain( actions .secondary - .iter() - .copied() - .map(|label| (label, Appearance::Secondary)), + .into_iter() + .map(|action| (action, Appearance::Secondary)), ) .collect(); let mut clicked = None; - let primary_label = actions.primary; + let primary_id = buttons + .first() + .and_then(|(action, appearance)| (*appearance == Appearance::Primary).then(|| action.id.clone())); if stack { ui.vertical(|ui| { - for (label, appearance) in &labels { + for (action, appearance) in &buttons { let response = ui.add_sized( Vec2::new(ui.available_width(), 32.0), - Button::new(*label).appearance(*appearance).id_salt(*label), + Button::new(action.text.clone()) + .appearance(*appearance) + .id_salt(&action.id), + ); + note_button( + ui, + dialog_scope, + response.id, + &action.id, + primary_id.as_deref(), + opening, ); - note_button(ui, dialog_scope, response.id, label, primary_label, opening); if response.clicked() { - clicked = Some(*label); + clicked = Some(action.id.clone()); } } }); - } else if !labels.is_empty() { + } else if !buttons.is_empty() { // `Align::Min`, not `Center`: a centred cross axis spans all the height left in the // surface, which is last frame's height, so the row (and the surface) never shrank. // The buttons share one height, so top alignment looks the same (WI-1912). ui.with_layout(Layout::right_to_left(Align::Min), |ui| { ui.spacing_mut().item_spacing.x = spacing_horizontal::S; - for (label, appearance) in labels.iter().rev() { - let response = ui.add(Button::new(*label).appearance(*appearance).id_salt(*label)); - note_button(ui, dialog_scope, response.id, label, primary_label, opening); + for (action, appearance) in buttons.iter().rev() { + let response = ui.add( + Button::new(action.text.clone()) + .appearance(*appearance) + .id_salt(&action.id), + ); + note_button( + ui, + dialog_scope, + response.id, + &action.id, + primary_id.as_deref(), + opening, + ); if response.clicked() { - clicked = Some(*label); + clicked = Some(action.id.clone()); } } }); @@ -499,16 +576,23 @@ fn show_actions(ui: &mut Ui, actions: DialogActions<'_>, stack: bool, opening: b } /// Remember which button is focused so Enter can tell the primary from another button. -fn note_button(ui: &Ui, dialog_scope: Id, button_id: Id, label: &str, primary: Option<&str>, opening: bool) { +fn note_button( + ui: &Ui, + dialog_scope: Id, + button_id: Id, + action_id: &str, + primary: Option<&str>, + opening: bool, +) { let ctx = ui.ctx(); - if Some(label) == primary { + if Some(action_id) == primary { ctx.data_mut(|data| data.insert_temp(dialog_scope.with("primary_focus"), button_id)); if opening { ui.memory_mut(|memory| memory.request_focus(button_id)); } } let focused = ui.memory(|memory| memory.focused()) == Some(button_id); - if focused && Some(label) != primary { + if focused && Some(action_id) != primary { ctx.data_mut(|data| data.insert_temp(dialog_scope.with("focus_other_button"), true)); } } diff --git a/crates/fluent2-egui/src/surfaces/drawer.rs b/crates/fluent2-egui/src/surfaces/drawer.rs index 450b913..0f360e2 100644 --- a/crates/fluent2-egui/src/surfaces/drawer.rs +++ b/crates/fluent2-egui/src/surfaces/drawer.rs @@ -218,7 +218,7 @@ impl Drawer { if header_closed { *open = false; ctx.data_mut(|data| data.insert_temp(seen_key, false)); - return DialogResult::Closed(CloseReason::Button(CLOSE_LABEL)); + return DialogResult::Closed(CloseReason::Button(CLOSE_LABEL.to_owned())); } DialogResult::Open(value) } @@ -327,7 +327,7 @@ pub(crate) fn show_overlay( } let role = match overlay.kind { DialogKind::Alert => egui::accesskit::Role::AlertDialog, - DialogKind::Modal | DialogKind::NonModal => egui::accesskit::Role::Dialog, + DialogKind::Modal | DialogKind::Dismissible => egui::accesskit::Role::Dialog, }; ui.ctx().accesskit_node_builder(surface_id, |node| { node.set_role(role); @@ -341,7 +341,7 @@ pub(crate) fn show_overlay( let (value, header_closed) = shown.inner; let mut close = None; if header_closed { - close = Some(CloseReason::Button(CLOSE_LABEL)); + close = Some(CloseReason::Button(CLOSE_LABEL.to_owned())); } if close.is_none() && shown.backdrop_response.clicked() && overlay.kind == DialogKind::Modal { close = Some(CloseReason::Backdrop); diff --git a/crates/fluent2-egui/src/theme/mod.rs b/crates/fluent2-egui/src/theme/mod.rs index 0969072..e25fa17 100644 --- a/crates/fluent2-egui/src/theme/mod.rs +++ b/crates/fluent2-egui/src/theme/mod.rs @@ -42,6 +42,35 @@ pub struct Theme { /// Compact, medium or touch sizing (`docs/specs/density.md`). Medium by default. Change it /// with [`Self::with_density`], which also resizes [`Self::data`]. pub density: Density, + /// Slider rail, thumb and inner-dot sizes. Component metrics, not Fluent global tokens. + pub slider: SliderMetrics, +} + +/// Slider sizes, in points. Defaults match the Fluent 2 web slider (4 px rail, 20 px thumb, +/// 12 px inner dot). They are component metrics: Fluent publishes no global token for them. +/// +/// ``` +/// let metrics = fluent2_egui::theme::SliderMetrics::default(); +/// assert_eq!(metrics.thumb, 20.0); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SliderMetrics { + /// Rail thickness. + pub rail: f32, + /// Thumb diameter. + pub thumb: f32, + /// Inner brand dot diameter. + pub dot: f32, +} + +impl Default for SliderMetrics { + fn default() -> Self { + Self { + rail: 4.0, + thumb: 20.0, + dot: 12.0, + } + } } /// Sizes of the Office chrome, in points. Fixed by `docs/specs/office-chrome.md` §10 (D2). @@ -207,6 +236,7 @@ impl Theme { chrome: ChromeMetrics::default(), data: DataMetrics::default(), density: Density::Medium, + slider: SliderMetrics::default(), } } @@ -221,6 +251,7 @@ impl Theme { chrome: ChromeMetrics::default(), data: DataMetrics::default(), density: Density::Medium, + slider: SliderMetrics::default(), } } @@ -235,6 +266,7 @@ impl Theme { chrome: ChromeMetrics::default(), data: DataMetrics::default(), density: Density::Medium, + slider: SliderMetrics::default(), } } @@ -266,37 +298,51 @@ impl Default for Theme { } } -/// Dark-theme departures from Fluent's tokens, so text passes WCAG AA (WI-1844, operator -/// decision 2026-10-19). Fluent's dark `status_danger_foreground_1` is 4.16:1 on -/// `neutral_background_1` and `neutral_foreground_2_brand_hover` is 4.18:1 on -/// `subtle_background_hover`. They take `status_danger_foreground_3` (about 8:1) and -/// `neutral_foreground_2_hover` instead. Fluent's dark `status_danger_foreground_inverted` is -/// the fill's own red (177,14,28) on `status_danger_background_3` (197,15,31), so a filled -/// danger Badge is unreadable (issue #124). It takes white, about 6:1 on that fill. -/// High contrast keeps Fluent's values. +/// Dark-theme departures from Fluent's tokens so text passes WCAG AA (WI-1844) and the +/// recorded non-text pairs pass 3:1 (issue #150, operator decision 2026-10-26). +/// +/// `status_danger_foreground_1` was 4.16:1 and `neutral_foreground_2_brand_hover` was 4.18:1; +/// they take `status_danger_foreground_3` and `neutral_foreground_2_hover`. +/// `status_danger_foreground_inverted` was the fill's own red on `status_danger_background_3` +/// (issue #124); it takes white. The stroke and status-border overrides, and the brighter +/// `brand_background`, are the #150 pairs. High contrast keeps Fluent's values. fn dark_aa_overrides(mut color: AliasColors) -> AliasColors { color.status_danger_foreground_1 = color.status_danger_foreground_3; color.status_danger_foreground_inverted = white(); color.neutral_foreground_2_brand_hover = color.neutral_foreground_2_hover; + // Issue #150: recorded non-text pairs must reach 3:1. Values are the nearest step from + // Fluent's own token toward the next accessible step on the same ramp. + color.neutral_stroke_1 = rgba(114, 114, 114); // was (102, 102, 102), 2.53:1 + color.neutral_stroke_2 = rgba(114, 114, 114); // was (82, 82, 82), 1.86:1 + color.status_success_border_1 = rgba(28, 132, 28); // was (16, 124, 16), 2.71:1 + color.status_danger_border_1 = rgba(209, 56, 70); // was (197, 15, 31), 2.40:1 + // Fluent dark brand step 70 (17, 94, 163) is 2.18:1 on the surface. No ramp step keeps + // both 3:1 against the surface and 4.5:1 for white text; this blend of steps 80 and 90 + // is the first that clears 3:1 (white label falls to about 4.82:1). + color.brand_background = rgba(22, 116, 199); color } -/// Light-theme departure from Fluent's tokens (issue #124). Fluent's light -/// `status_danger_foreground_inverted` is (220,98,109) on `status_danger_background_3` -/// (197,15,31), about 1.7:1, so a filled danger Badge's label fails AA. It takes white, -/// about 6:1 on that fill, as the dark theme does. +/// Light-theme departures from Fluent's tokens. `status_danger_foreground_inverted` was +/// (220,98,109) on `status_danger_background_3` (197,15,31), about 1.7:1 (issue #124); it +/// takes white. The stroke and status-border overrides are the #150 pairs, moved toward +/// Fluent's accessible step until each clears 3:1 on `neutral_background_1`. fn light_aa_overrides(mut color: AliasColors) -> AliasColors { color.status_danger_foreground_inverted = white(); + // Issue #150. The same pairs, from Fluent's light tokens toward the accessible step. + color.neutral_stroke_1 = rgba(148, 148, 148); // was (209, 209, 209), 1.53:1 + color.neutral_stroke_2 = rgba(148, 148, 148); // was (224, 224, 224), 1.32:1 + color.status_success_border_1 = rgba(82, 166, 82); // was (159, 216, 159), 1.64:1 + color.status_danger_border_1 = rgba(223, 116, 125); // was (238, 172, 178), 1.88:1 color } fn white() -> Rgba { - Rgba { - r: 255, - g: 255, - b: 255, - a: 255, - } + rgba(255, 255, 255) +} + +fn rgba(r: u8, g: u8, b: u8) -> Rgba { + Rgba { r, g, b, a: 255 } } /// `Rgba` as an unmultiplied [`egui::Color32`]. diff --git a/crates/fluent2-egui/tests/a11y_contrast.rs b/crates/fluent2-egui/tests/a11y_contrast.rs index 808c4af..20b3408 100644 --- a/crates/fluent2-egui/tests/a11y_contrast.rs +++ b/crates/fluent2-egui/tests/a11y_contrast.rs @@ -3,8 +3,8 @@ //! `cargo test -p fluent2-egui --test a11y_contrast -- --ignored --nocapture`. //! //! Text pairs need 4.5:1 (WCAG 1.4.3) and non-text pairs 3:1 (WCAG 1.4.11). Disabled pairs -//! are listed but exempt, as WCAG exempts inactive components. A pair that fails is either -//! in [`KNOWN_FAILURES`] (a Fluent token choice, written up in the report) or a bug. +//! are listed but exempt, as WCAG exempts inactive components. Every other pair must pass: +//! the theme overrides the Fluent values that used to fall short (issue #150). use fluent2_egui::theme::Theme; use fluent2_tokens::{AliasColors, FLUENT, Rgba}; @@ -180,29 +180,6 @@ fn pairs() -> Vec { ] } -/// (theme, component, part) pairs that fail because of Fluent's own token values. Each has a -/// row in `docs/a11y-report.md`. -const KNOWN_FAILURES: &[(&str, &str, &str)] = &[ - // Decorative 1 px edges: Fluent's neutral and status strokes sit below 3:1 in light and - // dark. Each control is still identified by its text, fill or icon (report §2.1). - ("light", "Button", "border"), - ("dark", "Button", "border"), - ("light", "Menu / Popover", "surface border"), - ("dark", "Menu / Popover", "surface border"), - ("light", "Card", "border"), - ("dark", "Card", "border"), - ("light", "MessageBar", "success border"), - ("dark", "MessageBar", "success border"), - ("light", "MessageBar", "error border"), - ("dark", "MessageBar", "error border"), - ("light", "KeyTips", "badge border"), - ("dark", "KeyTips", "badge border"), - ("light", "SplitView", "splitter"), - ("dark", "SplitView", "splitter"), - // Dark-only token values (report §2.2). - ("dark", "Button", "primary fill vs surface"), -]; - fn themes() -> [(&'static str, AliasColors); 3] { [ ("light", Theme::light(FLUENT).color), @@ -281,19 +258,22 @@ fn fails(row: &Row) -> bool { } #[test] -fn every_failing_pair_is_a_known_token_failure() { - let failing: Vec<(&str, &str, &str)> = rows() +fn every_pair_meets_its_threshold() { + let failing: Vec = rows() .iter() .filter(|row| fails(row)) - .map(|row| (row.theme, row.pair.component, row.pair.part)) + .map(|row| { + format!( + "{} {} / {}: {:.2} (needs {:.1})", + row.theme, + row.pair.component, + row.pair.part, + row.ratio, + row.pair.kind.minimum() + ) + }) .collect(); - let unexpected: Vec<_> = failing.iter().filter(|f| !KNOWN_FAILURES.contains(f)).collect(); - let fixed: Vec<_> = KNOWN_FAILURES.iter().filter(|k| !failing.contains(k)).collect(); - assert!(unexpected.is_empty(), "new contrast failures: {unexpected:#?}"); - assert!( - fixed.is_empty(), - "no longer failing, drop from KNOWN_FAILURES: {fixed:#?}" - ); + assert!(failing.is_empty(), "contrast failures: {failing:#?}"); } #[test] diff --git a/crates/fluent2-egui/tests/a11y_keyboard.rs b/crates/fluent2-egui/tests/a11y_keyboard.rs index 26cb6d3..009c0e7 100644 --- a/crates/fluent2-egui/tests/a11y_keyboard.rs +++ b/crates/fluent2-egui/tests/a11y_keyboard.rs @@ -296,7 +296,7 @@ fn escape_returns_focus_to_a_keyboard_opener() { } let _ = Dialog::new("walk") .title("Save changes?") - .actions(|actions| actions.secondary("Cancel")) + .actions(|actions| actions.secondary("cancel", "Cancel")) .show(ui.ctx(), open, |ui| { ui.label("Body"); }); @@ -381,7 +381,7 @@ fn dialog_is_described_by_its_body_on_open() { let _ = Dialog::new("described") .title("Save changes?") .description("Your document has unsaved changes.") - .actions(|actions| actions.primary("Save")) + .actions(|actions| actions.primary("save", "Save")) .show(ui.ctx(), &mut open, |ui| { ui.label("Your document has unsaved changes."); }); diff --git a/crates/fluent2-egui/tests/dialog.rs b/crates/fluent2-egui/tests/dialog.rs index 52849b2..5167d0a 100644 --- a/crates/fluent2-egui/tests/dialog.rs +++ b/crates/fluent2-egui/tests/dialog.rs @@ -7,7 +7,7 @@ use std::rc::Rc; use egui::accesskit::Role; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use fluent2_egui::controls::{Button, Dropdown}; use fluent2_egui::surfaces::{CloseReason, Dialog, DialogKind, DialogResult}; use fluent2_egui::{Theme, install}; @@ -17,6 +17,8 @@ use fluent2_egui::{Theme, install}; struct Probe { open: bool, closed_with: Option, + /// Clicks that reached a control on the page behind the dialog. + behind_clicks: u32, } /// Store the dialog's open flag. The close reason is kept only on the frame that closed it: @@ -46,8 +48,8 @@ fn dialog_harness(probe: Rc>, kind: DialogKind) -> Harness<'stati .kind(kind) .width(600.0) .actions(|actions| { - actions.primary("Save"); - actions.secondary("Cancel"); + actions.primary("save", "Save"); + actions.secondary("cancel", "Cancel"); }) .show(ui.ctx(), &mut open, |ui| { ui.label("Body"); @@ -61,6 +63,7 @@ fn open_probe() -> Rc> { Rc::new(RefCell::new(Probe { open: true, closed_with: None, + behind_clicks: 0, })) } @@ -107,7 +110,58 @@ fn enter_triggers_primary() { harness.key_press(egui::Key::Enter); harness.run(); assert!(!probe.borrow().open, "Enter should trigger the primary action"); - assert_eq!(probe.borrow().closed_with, Some(CloseReason::Button("Save"))); + assert_eq!( + probe.borrow().closed_with, + Some(CloseReason::Button("save".to_owned())) + ); +} + +#[test] +fn same_label_actions_are_distinct_and_survive_a_language_switch() { + let label = Rc::new(RefCell::new("Save".to_owned())); + let probe = open_probe(); + let label_for_ui = label.clone(); + let probe_for_ui = probe.clone(); + let mut harness = Harness::new_ui(move |ui| { + install_light(ui); + let text = label_for_ui.borrow().clone(); + let mut open = probe_for_ui.borrow().open; + let was_open = open; + let result = Dialog::new("duplicate_labels") + .title("Save?") + .actions(|actions| { + actions.primary("save", text.clone()); + actions.secondary("discard", text); + }) + .show(ui.ctx(), &mut open, |_ui| {}); + record(&probe_for_ui, was_open, open, result); + }); + harness.run(); + let english: Vec<_> = harness + .get_all_by_label("Save") + .map(|node| node.accesskit_node().id()) + .collect(); + assert_eq!(english.len(), 2, "both actions show the same label"); + assert_ne!( + english[0], english[1], + "widget ids come from the action id, not the label" + ); + + *label.borrow_mut() = "Speichern".to_owned(); + harness.run(); + let translated: Vec<_> = harness + .get_all_by_label("Speichern") + .map(|node| node.accesskit_node().id()) + .collect(); + assert_eq!(translated.len(), 2, "both buttons show the translated label"); + assert_eq!(translated, english, "a language switch keeps the widget ids"); + harness.key_press(egui::Key::Enter); + harness.run(); + assert_eq!( + probe.borrow().closed_with, + Some(CloseReason::Button("save".to_owned())), + "the close result is the action id, not the translated label" + ); } #[test] @@ -130,7 +184,10 @@ fn enter_on_another_button_does_not_trigger_primary() { harness.key_press(egui::Key::Enter); harness.run(); assert!(!probe.borrow().open, "Cancel should close the dialog"); - assert_eq!(probe.borrow().closed_with, Some(CloseReason::Button("Cancel"))); + assert_eq!( + probe.borrow().closed_with, + Some(CloseReason::Button("cancel".to_owned())) + ); } /// Which focus stop of the dialog has focus: one of Save, Cancel, Inside, or the surface. @@ -215,7 +272,7 @@ fn focus_returns_to_opener() { } let result = Dialog::new("dialog") .title("Dialog title") - .actions(|actions| actions.secondary("Cancel")) + .actions(|actions| actions.secondary("cancel", "Cancel")) .show(ui.ctx(), &mut open, |ui| { ui.label("Body"); }); @@ -259,8 +316,8 @@ fn dropdown_inside_dialog_escapes_first() { let result = Dialog::new("dialog") .title("Dialog title") .actions(|actions| { - actions.primary("Save"); - actions.secondary("Cancel"); + actions.primary("save", "Save"); + actions.secondary("cancel", "Cancel"); }) .show(ui.ctx(), &mut open, |ui| { let mut choice = *selected.borrow(); @@ -308,8 +365,8 @@ fn tall_harness(probe: Rc>, rows: usize) -> Harness<'static> { .title("Tall dialog") .width(400.0) .actions(|actions| { - actions.primary("Save"); - actions.secondary("Cancel"); + actions.primary("save", "Save"); + actions.secondary("cancel", "Cancel"); }) .show(ui.ctx(), &mut open, |ui| { for row in 0..rows { @@ -366,7 +423,7 @@ fn focusing_a_lower_control_scrolls_it_into_view_and_clicks_reach_it() { let result = Dialog::new("tall") .title("Tall dialog") .width(400.0) - .actions(|actions| actions.primary("Save")) + .actions(|actions| actions.primary("save", "Save")) .show(ui.ctx(), &mut open, |ui| { for row in 0..rows { let button = ui.add(Button::new(format!("Row {row}")).id_salt(("row", row))); @@ -423,3 +480,73 @@ fn short_body_does_not_grow_the_surface() { surface.height() ); } + +/// Page content under the dialog. `behind_clicks` counts clicks that got through the backdrop. +fn page_behind(probe: Rc>, kind: DialogKind) -> Harness<'static> { + Harness::new_ui(move |ui| { + install_light(ui); + if ui.add(Button::new("Behind").id_salt("behind")).clicked() { + probe.borrow_mut().behind_clicks += 1; + } + let mut open = probe.borrow().open; + let was_open = open; + let result = Dialog::new("dialog") + .title("Dialog title") + .kind(kind) + .actions(|actions| { + actions.primary("save", "Save"); + }) + .show(ui.ctx(), &mut open, |ui| { + ui.label("Body"); + }); + record(&probe, was_open, open, result); + }) +} + +fn click_at(harness: &mut Harness<'_>, pos: egui::Pos2) { + harness.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + harness.event(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + }); + } + harness.run(); +} + +#[test] +fn every_kind_blocks_the_page_and_backdrop_dismiss_follows_the_kind() { + for kind in [DialogKind::Modal, DialogKind::Dismissible, DialogKind::Alert] { + let probe = open_probe(); + let mut harness = page_behind(probe.clone(), kind); + harness.run(); + let pos = harness.get_by_label("Behind").rect().center(); + click_at(&mut harness, pos); + assert_eq!( + probe.borrow().behind_clicks, + 0, + "{kind:?} let a click through to the page behind the dialog" + ); + match kind { + DialogKind::Modal => { + assert!(!probe.borrow().open, "Modal closes on a backdrop click"); + assert_eq!(probe.borrow().closed_with, Some(CloseReason::Backdrop)); + } + DialogKind::Dismissible | DialogKind::Alert => { + assert!(probe.borrow().open, "{kind:?} stays open on a backdrop click"); + assert_eq!(probe.borrow().closed_with, None, "{kind:?}"); + } + } + } +} + +#[test] +fn deprecated_non_modal_alias_is_dismissible() { + #![allow(deprecated)] + assert_eq!(DialogKind::NonModal, DialogKind::Dismissible); + assert!(!DialogKind::NonModal.backdrop_dismiss()); + assert!(DialogKind::Modal.backdrop_dismiss()); + assert!(!DialogKind::Alert.backdrop_dismiss()); +} diff --git a/crates/fluent2-egui/tests/drawer.rs b/crates/fluent2-egui/tests/drawer.rs index 3cedc42..65bd5d9 100644 --- a/crates/fluent2-egui/tests/drawer.rs +++ b/crates/fluent2-egui/tests/drawer.rs @@ -128,7 +128,7 @@ fn renders_in_every_theme() { #[test] fn escape_closes_modal_and_non_modal_but_not_alert() { for surface in SURFACES { - for kind in [DialogKind::Modal, DialogKind::NonModal] { + for kind in [DialogKind::Modal, DialogKind::Dismissible] { let probe = open_probe(); let mut harness = harness(probe.clone(), surface, kind, true); harness.run(); @@ -155,7 +155,10 @@ fn close_button_closes() { harness.get_by_label(CLOSE_LABEL).click(); harness.run(); assert!(!probe.borrow().open); - assert_eq!(probe.borrow().closed_with, Some(CloseReason::Button(CLOSE_LABEL))); + assert_eq!( + probe.borrow().closed_with, + Some(CloseReason::Button(CLOSE_LABEL.to_owned())) + ); } } @@ -163,7 +166,7 @@ fn close_button_closes() { fn backdrop_click_closes_only_modal() { // The Start drawer is 320 wide in an 800 wide window: (700, 300) is on the backdrop. let backdrop = egui::pos2(700.0, 300.0); - for (kind, closes) in [(DialogKind::Modal, true), (DialogKind::NonModal, false)] { + for (kind, closes) in [(DialogKind::Modal, true), (DialogKind::Dismissible, false)] { let probe = open_probe(); let mut harness = harness(probe.clone(), Surface::Drawer(DrawerPosition::Start), kind, true); harness.run(); diff --git a/crates/fluent2-egui/tests/readme_index.rs b/crates/fluent2-egui/tests/readme_index.rs index 3f499b8..b8e4c11 100644 --- a/crates/fluent2-egui/tests/readme_index.rs +++ b/crates/fluent2-egui/tests/readme_index.rs @@ -21,6 +21,10 @@ const SUPPORTING: &[&str] = &[ "CalendarResponse", "CalendarStrings", "CheckState", + "ComboboxOptions", + "FilterProbe", + "ModelCombobox", + "SliceOptions", "DayContent", "FileCardResponse", "InputAppearance", @@ -177,3 +181,36 @@ fn supporting_names_are_still_exported() { "SUPPORTING lists names no longer exported: {stale:?}" ); } + +#[test] +fn readme_does_not_list_a_shipped_widget_as_missing() { + let readme = + std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md")).unwrap_or_default(); + let components = readme + .split("## Components") + .nth(1) + .and_then(|rest| rest.split("### Not yet provided").next()) + .unwrap_or(""); + let missing = readme.split("### Not yet provided").nth(1).unwrap_or(""); + let mut contradictions = Vec::new(); + for line in missing.lines() { + let Some(name) = line + .trim() + .strip_prefix("| ") + .and_then(|rest| rest.split('|').next()) + else { + continue; + }; + let name = name.trim(); + if name.is_empty() || name == "Fluent 2" || name.starts_with('-') { + continue; + } + if components.contains(&format!("`{name}`")) { + contradictions.push(name.to_owned()); + } + } + assert!( + contradictions.is_empty(), + "README lists these as both shipped and not yet provided: {contradictions:?}" + ); +} diff --git a/crates/fluent2-egui/tests/slider.rs b/crates/fluent2-egui/tests/slider.rs index fc1dbbd..51e01e6 100644 --- a/crates/fluent2-egui/tests/slider.rs +++ b/crates/fluent2-egui/tests/slider.rs @@ -2,10 +2,13 @@ use std::cell::Cell; +use proptest::strategy::Strategy; + +use egui::{Shape, Vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use fluent2_egui::controls::Slider; -use fluent2_egui::theme::{self, Theme}; +use fluent2_egui::theme::{self, SliderMetrics, Theme, c}; fn install(ui: &egui::Ui) { theme::install(ui.ctx(), &Theme::default()); @@ -73,14 +76,66 @@ fn step_snaps() { assert_eq!(value.get(), 10.0); } -#[test] -fn inverted_range_does_not_panic() { +fn finite_slider( + start: f64, + range: std::ops::RangeInclusive, + step: Option, + press: Option, +) -> f64 { + let value = Cell::new(start); let mut harness = Harness::new_ui(|ui| { install(ui); - let mut value = f64::NAN; - ui.add(Slider::new(&mut value, 8.0..=f64::NAN).vertical()); + let mut current = value.get(); + let response = ui.add(Slider::new(&mut current, range.clone()).step(step)); + if press.is_some() { + response.request_focus(); + } + value.set(current); }); harness.run(); + if let Some(key) = press { + harness.key_press(key); + harness.run(); + } + value.get() +} + +#[test] +fn inverted_range_does_not_panic() { + let value = finite_slider(f64::NAN, 80.0..=10.0, None, None); + assert!(value.is_finite()); + assert!((10.0..=80.0).contains(&value)); +} + +#[test] +fn nan_bound_does_not_panic() { + let value = finite_slider(f64::NAN, 8.0..=f64::NAN, None, None); + assert!(value.is_finite()); +} + +#[test] +fn extreme_finite_range_stays_finite() { + let value = finite_slider(-f64::MAX, -f64::MAX..=f64::MAX, None, None); + assert!(value.is_finite(), "value {value}"); + assert_eq!(value, -f64::MAX); +} + +#[test] +fn degenerate_range_stays_finite() { + let value = finite_slider(4.0, 7.0..=7.0, None, None); + assert_eq!(value, 7.0); +} + +#[test] +fn tiny_step_stays_finite() { + let value = finite_slider( + 0.0, + 0.0..=1.0, + Some(f64::MIN_POSITIVE), + Some(egui::Key::ArrowRight), + ); + assert!(value.is_finite(), "value {value}"); + assert!((0.0..=1.0).contains(&value)); } #[test] @@ -94,3 +149,213 @@ fn renders_in_dark_and_high_contrast() { harness.run(); } } + +/// Run `build` for two frames and return the second frame's shapes, with every +/// `Shape::Vec` expanded. +fn shapes(theme: &Theme, build: impl Fn(&mut egui::Ui)) -> Vec { + let ctx = egui::Context::default(); + theme::install(&ctx, theme); + let mut last = Vec::new(); + for _ in 0..2 { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + Vec2::new(400.0, 200.0), + )), + ..Default::default() + }; + let mut output = ctx.run_ui(raw, |ui| build(ui)); + last = flatten(output.shapes.into_iter().map(|clipped| clipped.shape)); + output.textures_delta.clear(); + } + last +} + +fn flatten(shapes: impl Iterator) -> Vec { + shapes + .flat_map(|shape| match shape { + Shape::Vec(inner) => flatten(inner.into_iter()), + other => vec![other], + }) + .collect() +} + +#[test] +fn custom_slider_tokens_change_painted_geometry() { + let theme = Theme { + slider: SliderMetrics { + rail: 10.0, + thumb: 32.0, + dot: 8.0, + }, + ..Theme::default() + }; + let painted = shapes(&theme, |ui| { + let mut value = 50.0; + ui.add(Slider::new(&mut value, 0.0..=100.0)); + }); + let rails: Vec<_> = painted + .iter() + .filter_map(|shape| match shape { + Shape::Rect(rect) if (rect.rect.height() - 10.0).abs() < 0.01 => Some(rect.rect), + _ => None, + }) + .collect(); + assert!( + !rails.is_empty(), + "custom rail thickness 10 must appear in painted rects" + ); + let thumbs: Vec<_> = painted + .iter() + .filter_map(|shape| match shape { + Shape::Circle(circle) if (circle.radius - 16.0).abs() < 0.01 => Some(circle.radius), + _ => None, + }) + .collect(); + assert!( + !thumbs.is_empty(), + "custom thumb diameter 32 must paint a 16 radius circle" + ); + assert!( + painted.iter().any(|shape| matches!( + shape, + Shape::Circle(circle) if (circle.radius - 4.0).abs() < 0.01 + )), + "custom inner dot diameter 8 must paint a 4 radius circle" + ); +} + +#[test] +fn disabled_slider_uses_disabled_colours() { + let theme = Theme::default(); + let rgb = |color: egui::Color32| (color.r(), color.g(), color.b()); + let paint = |enabled: bool| { + shapes(&theme, |ui| { + ui.add_enabled_ui(enabled, |ui| { + let mut value = 50.0; + ui.add(Slider::new(&mut value, 0.0..=100.0)); + }); + }) + }; + let rects = |shapes: &[Shape]| { + shapes + .iter() + .filter_map(|shape| match shape { + Shape::Rect(rect) => Some(rgb(rect.fill)), + _ => None, + }) + .collect::>() + }; + let circles = |shapes: &[Shape]| { + shapes + .iter() + .filter_map(|shape| match shape { + Shape::Circle(circle) => Some(rgb(circle.fill)), + _ => None, + }) + .collect::>() + }; + let on = paint(true); + let off = paint(false); + // egui fades a disabled `Ui` after the widget chooses colours, so the pixels are not the + // raw alias. The outcome is that rail, fill and thumb no longer match the enabled frame. + assert_ne!( + rects(&on), + rects(&off), + "disabled rail and fill must not use the enabled colours" + ); + assert_ne!( + circles(&on), + circles(&off), + "disabled thumb and dot must not use the enabled colours" + ); + let enabled_rail = rgb(c(theme.color.neutral_stroke_accessible)); + let enabled_brand = rgb(c(theme.color.compound_brand_background)); + let enabled_thumb = rgb(c(theme.color.neutral_background_1)); + assert!( + !rects(&off).contains(&enabled_rail) && !rects(&off).contains(&enabled_brand), + "disabled rail and fill are not the resting stroke or brand, got {:?}", + rects(&off) + ); + assert!( + !circles(&off).contains(&enabled_thumb) && !circles(&off).contains(&enabled_brand), + "disabled thumb and dot are not the resting background or brand, got {:?}", + circles(&off) + ); +} + +/// The theme-render test must check painted outcomes, not only that the harness runs. +#[test] +fn theme_render_paints_token_colours_and_metrics() { + let theme = Theme::default(); + let rgb = |color: egui::Color32| (color.r(), color.g(), color.b()); + let painted = shapes(&theme, |ui| { + let mut value = 40.0; + ui.add(Slider::new(&mut value, 0.0..=100.0)); + }); + let rail_h = theme.slider.rail; + let thumb_r = theme.slider.thumb / 2.0; + let dot_r = theme.slider.dot / 2.0; + let rails: Vec<_> = painted + .iter() + .filter_map(|shape| match shape { + Shape::Rect(rect) if (rect.rect.height() - rail_h).abs() < 0.01 => Some(rect.clone()), + _ => None, + }) + .collect(); + assert!( + !rails.is_empty(), + "rail thickness must come from SliderMetrics::rail ({rail_h})" + ); + let rail_colour = rgb(c(theme.color.neutral_stroke_accessible)); + let brand = rgb(c(theme.color.compound_brand_background)); + assert!( + rails.iter().any(|rect| rgb(rect.fill) == rail_colour), + "resting rail fill must be neutral_stroke_accessible, got {rails:?}" + ); + assert!( + rails.iter().any(|rect| rgb(rect.fill) == brand), + "brand fill must be compound_brand_background" + ); + assert!( + painted.iter().any(|shape| matches!( + shape, + Shape::Circle(circle) if (circle.radius - thumb_r).abs() < 0.01 + && rgb(circle.fill) == rgb(c(theme.color.neutral_background_1)) + )), + "thumb radius and fill must come from the theme thumb token and neutral_background_1" + ); + assert!( + painted.iter().any(|shape| matches!( + shape, + Shape::Circle(circle) if (circle.radius - dot_r).abs() < 0.01 + && rgb(circle.fill) == brand + )), + "inner dot radius and fill must come from the theme dot token and brand" + ); +} + +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config { + cases: 64, + failure_persistence: None, + ..proptest::test_runner::Config::default() + })] + + /// Slider fraction and the value it stores stay finite for arbitrary finite ranges. + #[test] + fn slider_fraction_and_value_stay_finite( + value in proptest::num::f64::ANY.prop_filter("finite", |v| v.is_finite()), + min in proptest::num::f64::ANY.prop_filter("finite", |v| v.is_finite()), + max in proptest::num::f64::ANY.prop_filter("finite", |v| v.is_finite()), + ) { + let (lo, hi) = if min <= max { (min, max) } else { (max, min) }; + let stored = finite_slider(value, lo..=hi, None, None); + proptest::prop_assert!(stored.is_finite(), "stored {stored}"); + if lo == hi { + proptest::prop_assert_eq!(stored, lo); + } else { + proptest::prop_assert!((lo..=hi).contains(&stored), "{stored} not in {lo}..={hi}"); + } + } +} diff --git a/crates/gallery/examples/a11y_walk.rs b/crates/gallery/examples/a11y_walk.rs index 0ec3cab..9903708 100644 --- a/crates/gallery/examples/a11y_walk.rs +++ b/crates/gallery/examples/a11y_walk.rs @@ -57,8 +57,8 @@ impl eframe::App for Walk { let _ = Dialog::new("walk-dialog") .title("Save changes?") .actions(|actions| { - actions.primary("Save"); - actions.secondary("Cancel"); + actions.primary("save", "Save"); + actions.secondary("cancel", "Cancel"); }) .show(ctx, &mut self.dialog, |ui| { ui.label("Your document has unsaved changes."); diff --git a/crates/gallery/src/pages/surfaces.rs b/crates/gallery/src/pages/surfaces.rs index 9e60766..6a22a32 100644 --- a/crates/gallery/src/pages/surfaces.rs +++ b/crates/gallery/src/pages/surfaces.rs @@ -276,9 +276,9 @@ impl SurfacesPage { .title("Save changes?") .description("Your document has unsaved changes.") .actions(|actions| { - actions.primary("Save"); - actions.secondary("Don't save"); - actions.secondary("Cancel"); + actions.primary("save", "Save"); + actions.secondary("dont-save", "Don't save"); + actions.secondary("cancel", "Cancel"); }) .show(ctx, &mut self.modal, |ui| { ui.label("Your document has unsaved changes."); @@ -290,10 +290,12 @@ impl SurfacesPage { let was = self.non_modal; let result = Dialog::new("surfaces_non_modal") .title("Find") - .kind(DialogKind::NonModal) - .actions(|actions| actions.secondary("Close")) + .kind(DialogKind::Dismissible) + .actions(|actions| actions.secondary("close", "Close")) .show(ctx, &mut self.non_modal, |ui| { - ui.label("Escape closes this dialog; the backdrop does not."); + ui.label( + "Escape closes this dialog; a backdrop click does not. The page behind stays blocked.", + ); }); if was { record(&mut self.status, "Non-modal", result); @@ -304,8 +306,8 @@ impl SurfacesPage { .title("Delete 3 files?") .kind(DialogKind::Alert) .actions(|actions| { - actions.primary("Delete"); - actions.secondary("Cancel"); + actions.primary("delete", "Delete"); + actions.secondary("cancel", "Cancel"); }) .show(ctx, &mut self.alert, |ui| { ui.label("Only a button closes an alert dialog."); @@ -319,8 +321,8 @@ impl SurfacesPage { let result = Dialog::new("surfaces_tall") .title("Connection settings") .actions(|actions| { - actions.primary("OK"); - actions.secondary("Cancel"); + actions.primary("ok", "OK"); + actions.secondary("cancel", "Cancel"); }) .show(ctx, &mut self.tall, |ui| { ui.label("The body scrolls when it is taller than the window."); diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..ecbe7bd --- /dev/null +++ b/deny.toml @@ -0,0 +1,38 @@ +# Shared by ci.yml (advisories and licences) and release.yml (licences). + +# Licence allowlist checked before publish (audit L1). +# MIT and Apache-2.0 are the workspace licences (LICENSE-MIT, LICENSE-APACHE). +# The rest are permissive licences already in the published dependency tree +# (egui, resvg, rustls, unicode-ident). A copyleft or unknown licence fails. +# Ubuntu-font-1.0 and OFL-1.1 are the font licences of epaint_default_fonts, +# which ships with egui and is not code we relicense. +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "ISC", + "Zlib", + "Unicode-3.0", + "OFL-1.1", + "Ubuntu-font-1.0", + "CDLA-Permissive-2.0", +] +confidence-threshold = 0.8 + +# Advisories. A vulnerability fails the check. +# yanked stays at its default (warn): clipboard-win 5.4.1 is yanked and comes +# in through egui-winit -> arboard -> eframe (the gallery only). Clearing it +# means bumping egui, which a feature change must not do. +# unmaintained is a scope, not a lint level. "none" turns the notices off. +# They have no fixed release the egui 0.36 tree has adopted: +# RUSTSEC-2025-0141 bincode 1.3.3 via syntect (upstream calls 1.3.3 complete) +# RUSTSEC-2024-0320 yaml-rust 0.4.5 via syntect +# RUSTSEC-2026-0371 zbus_names via accesskit (egui a11y) +# RUSTSEC-2026-0370 zvariant via accesskit +# RUSTSEC-2026-0372 zvariant_derive via accesskit +[advisories] +unmaintained = "none" +ignore = [] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa12d81..f69c846 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,7 +49,7 @@ one test file. Do not reorganise folders as part of a component item. 2. **Multi-part controls return a named struct**, never a tuple. Example: `SplitButton::show(ui)` returns `SplitButtonResponse { main: Response, menu: Response }`. 3. **Containers take a closure** and return what it returns: - `Dialog::new(id).title("…").show(ctx, |ui| { … }) -> Option` (`None` when closed). + `Dialog::new(id).title("…").show(ctx, &mut open, |ui| { … }) -> DialogResult` (`DialogResult::Closed` when closed). 4. **IDs are supplied by the caller** (`impl std::hash::Hash`) or derived from the parent `Ui` id. Never derive an id from a translated label. 5. **No command dispatch.** Components return responses or values. The app decides what @@ -84,6 +84,17 @@ one test file. Do not reorganise folders as part of a component item. selects a row. `tests/text_selection.rs` covers it. - Field names mirror Fluent token names in snake_case (`colorNeutralForeground1` becomes `color.neutral_foreground_1`), so a token can be found in Fluent's documentation by name. +- Slider sizes live on `Theme.slider` (`SliderMetrics`: rail, thumb, inner dot). They are + **component tokens**, not Fluent global tokens: Fluent web hard-codes the same 4 / 20 / 12 + px values in the slider styles. Density grows only the allocated cross-axis hit target + (44 in Touch); the thumb diameter is not scaled. Layout clamps on the long axis + (120–240 horizontal, 80–160 vertical) are **fixed constraints**, not tokens. Hover, + pressed and focus fills are **not** customisable on the slider: the painter uses resting + alias colours (`neutral_stroke_accessible`, `compound_brand_background`, + `neutral_background_1`, `neutral_stroke_1`) and the disabled aliases when `ui` is + disabled (`neutral_stroke_disabled`, `neutral_foreground_disabled`, + `neutral_background_disabled`). High contrast uses the same aliases from + `Theme::high_contrast()`. ## Fonts diff --git a/docs/a11y-report.md b/docs/a11y-report.md index b4d2703..1d47154 100644 --- a/docs/a11y-report.md +++ b/docs/a11y-report.md @@ -2,12 +2,12 @@ This audit covers high contrast, colour contrast, keyboard access, focus visibility, reduced motion and AccessKit semantics for every fluent2-egui component, ahead of 0.1. The tests are the source of truth for this report: -- `crates/fluent2-egui/tests/a11y_contrast.rs` computes the WCAG ratio of every foreground/background token pair the components paint, in light, dark and high contrast (HC). It fails on any new failure, and on any listed failure that has since been fixed. Print the table with `cargo test -p fluent2-egui --test a11y_contrast -- --ignored --nocapture`. +- `crates/fluent2-egui/tests/a11y_contrast.rs` computes the WCAG ratio of every foreground/background token pair the components paint, in light, dark and high contrast (HC). It fails when any required pair is below its threshold. Print the table with `cargo test -p fluent2-egui --test a11y_contrast -- --ignored --nocapture`. - `crates/fluent2-egui/tests/a11y_keyboard.rs` tabs through a page with every basic control in all three themes. It checks that each stop is a named AccessKit node with the right role and that the Fluent focus ring is painted. It also checks that toasts, the spinner and the indeterminate progress bar stay still under reduced motion. ## 1. Contrast table -Thresholds: text 4.5:1 (WCAG 1.4.3), non-text 3:1 (1.4.11). Disabled pairs are reported but not required. Translucent backgrounds are composed over `neutral_background_1`. **✗** marks a failure. +Thresholds: text 4.5:1 (WCAG 1.4.3), non-text 3:1 (1.4.11). Disabled pairs are reported but not required. Translucent backgrounds are composed over `neutral_background_1`. Every required pair passes. Section 2 records the pairs that used to fail and the colours they took. | component | part | kind | foreground | background | light | dark | hc | |---|---|---|---|---|---|---|---| @@ -16,11 +16,11 @@ Thresholds: text 4.5:1 (WCAG 1.4.3), non-text 3:1 (1.4.11). Disabled pairs are r | Button | label | text 4.5 | `neutral_foreground_1` | `neutral_background_1` | 15.52 | 14.55 | 21.00 | | Button | label, hover | text 4.5 | `neutral_foreground_1_hover` | `neutral_background_1_hover` | 14.24 | 10.86 | 14.37 | | Button | label, pressed | text 4.5 | `neutral_foreground_1_pressed` | `neutral_background_1_pressed` | 11.76 | 16.48 | 14.37 | -| Button | border | non-text 3 | `neutral_stroke_1` | `neutral_background_1` | **1.53 ✗** | **2.53 ✗** | 21.00 | +| Button | border | non-text 3 | `neutral_stroke_1` | `neutral_background_1` | 3.03 | 3.02 | 21.00 | | Button | primary label | text 4.5 | `neutral_foreground_on_brand` | `brand_background` | 5.38 | 6.66 | 21.00 | | Button | primary label, hover | text 4.5 | `neutral_foreground_on_brand` | `brand_background_hover` | 6.66 | 5.38 | 14.37 | | Button | primary label, pressed | text 4.5 | `neutral_foreground_on_brand` | `brand_background_pressed` | 11.65 | 11.65 | 14.37 | -| Button | primary fill vs surface | non-text 3 | `brand_background` | `neutral_background_1` | 5.38 | **2.18 ✗** | 21.00 | +| Button | primary fill vs surface | non-text 3 | `brand_background` | `neutral_background_1` | 5.38 | 3.02 | 21.00 | | Button | subtle label | text 4.5 | `neutral_foreground_2` | `subtle_background` | 10.05 | 10.01 | 21.00 | | Button | subtle label, hover | text 4.5 | `neutral_foreground_2_hover` | `subtle_background_hover` | 14.24 | 11.73 | 14.37 | | Button | subtle label, brand hover | text 4.5 | `neutral_foreground_2_brand_hover` | `subtle_background_hover` | 4.94 | 11.73 | 14.37 | @@ -57,7 +57,7 @@ Thresholds: text 4.5:1 (WCAG 1.4.3), non-text 3:1 (1.4.11). Disabled pairs are r | Menu | item | text 4.5 | `neutral_foreground_1` | `neutral_background_1` | 15.52 | 14.55 | 21.00 | | Menu | item, hover | text 4.5 | `neutral_foreground_1_hover` | `neutral_background_1_hover` | 14.24 | 10.86 | 14.37 | | Menu | secondary text | text 4.5 | `neutral_foreground_3` | `neutral_background_1` | 6.19 | 6.48 | 21.00 | -| Menu / Popover | surface border | non-text 3 | `neutral_stroke_2` | `neutral_background_1` | **1.32 ✗** | **1.86 ✗** | 21.00 | +| Menu / Popover | surface border | non-text 3 | `neutral_stroke_2` | `neutral_background_1` | 3.03 | 3.02 | 21.00 | | Tooltip | text | text 4.5 | `neutral_foreground_1` | `neutral_background_1` | 15.52 | 14.55 | 21.00 | | Tooltip | inverted text | text 4.5 | `neutral_foreground_static_inverted` | `neutral_background_static` | 12.63 | 10.86 | 21.00 | | TabList | selected label | text 4.5 | `neutral_foreground_1` | `neutral_background_1` | 15.52 | 14.55 | 21.00 | @@ -69,7 +69,7 @@ Thresholds: text 4.5:1 (WCAG 1.4.3), non-text 3:1 (1.4.11). Disabled pairs are r | Ribbon gallery | scroll button, hover | text 4.5 | `neutral_foreground_2_hover` | `subtle_background_hover` | 14.24 | 11.73 | 14.37 | | TabList | selected indicator | non-text 3 | `compound_brand_stroke` | `neutral_background_1` | 5.38 | 5.18 | 14.37 | | Card | text on filled-alternative | text 4.5 | `neutral_foreground_1` | `neutral_background_2` | 14.87 | 16.48 | 21.00 | -| Card | border | non-text 3 | `neutral_stroke_1` | `neutral_background_1` | **1.53 ✗** | **2.53 ✗** | 21.00 | +| Card | border | non-text 3 | `neutral_stroke_1` | `neutral_background_1` | 3.03 | 3.02 | 21.00 | | Avatar | neutral initials | text 4.5 | `neutral_foreground_3` | `neutral_background_6` | 4.96 | 5.63 | 21.00 | | Avatar | brand initials | text 4.5 | `neutral_foreground_on_brand` | `brand_background` | 5.38 | 6.66 | 21.00 | | Card | content, hover | text 4.5 | `neutral_foreground_1_hover` | `neutral_background_1_hover` | 14.24 | 10.86 | 14.37 | @@ -89,8 +89,8 @@ Thresholds: text 4.5:1 (WCAG 1.4.3), non-text 3:1 (1.4.11). Disabled pairs are r | MessageBar | success icon | non-text 3 | `status_success_foreground_1` | `status_success_background_1` | 5.89 | 6.06 | 21.00 | | MessageBar | warning icon | non-text 3 | `status_warning_foreground_1` | `status_warning_background_1` | 4.85 | 6.97 | 21.00 | | MessageBar | error icon | non-text 3 | `status_danger_foreground_1` | `status_danger_background_1` | 6.55 | 9.23 | 21.00 | -| MessageBar | success border | non-text 3 | `status_success_border_1` | `neutral_background_1` | **1.64 ✗** | **2.71 ✗** | 21.00 | -| MessageBar | error border | non-text 3 | `status_danger_border_1` | `neutral_background_1` | **1.88 ✗** | **2.40 ✗** | 21.00 | +| MessageBar | success border | non-text 3 | `status_success_border_1` | `neutral_background_1` | 3.03 | 3.03 | 21.00 | +| MessageBar | error border | non-text 3 | `status_danger_border_1` | `neutral_background_1` | 3.03 | 3.02 | 21.00 | | TitleBar | title | text 4.5 | `neutral_foreground_1` | `neutral_background_1` | 15.52 | 14.55 | 21.00 | | TitleBar | caption glyph, hover | text 4.5 | `neutral_foreground_1_hover` | `subtle_background_hover` | 14.24 | 11.73 | 14.37 | | TitleBar | close glyph, hover | text 4.5 | `neutral_foreground_on_brand` | `status_danger_background_3` | 6.07 | 6.07 | 21.00 | @@ -106,7 +106,7 @@ Thresholds: text 4.5:1 (WCAG 1.4.3), non-text 3:1 (1.4.11). Disabled pairs are r | Ribbon gallery | item border, selected | non-text 3 | `brand_stroke_1` | `neutral_background_1` | 5.38 | 5.18 | 21.00 | | KeyTips | badge letter | text 4.5 | `neutral_foreground_1` | `neutral_background_1` | 15.52 | 14.55 | 21.00 | | KeyTips | typed prefix | text 4.5 | `brand_foreground_1` | `neutral_background_1` | 5.38 | 5.18 | 21.00 | -| KeyTips | badge border | non-text 3 | `neutral_stroke_1` | `neutral_background_1` | **1.53 ✗** | **2.53 ✗** | 21.00 | +| KeyTips | badge border | non-text 3 | `neutral_stroke_1` | `neutral_background_1` | 3.03 | 3.02 | 21.00 | | Backstage | item, selected | text 4.5 | `neutral_foreground_1_selected` | `subtle_background_selected` | 13.02 | 12.63 | 14.37 | | Backstage | selected bar | non-text 3 | `brand_foreground_1` (hc: `neutral_foreground_1_selected`) | `subtle_background_selected` | 4.52 | 4.50 | 14.37 | | StatusBar | text | text 4.5 | `neutral_foreground_2` | `neutral_background_2` | 9.63 | 11.34 | 21.00 | @@ -120,22 +120,33 @@ Thresholds: text 4.5:1 (WCAG 1.4.3), non-text 3:1 (1.4.11). Disabled pairs are r | NavigationView | item, hover | text 4.5 | `neutral_foreground_2_hover` | `neutral_background_4_hover` | 14.87 | 16.48 | 14.37 | | NavigationView | item, selected | text 4.5 | `neutral_foreground_2_brand_selected` | `neutral_background_4_selected` | 5.38 | 6.20 | 14.37 | | NavigationView | header | text 4.5 | `neutral_foreground_3` | `neutral_background_4` | 5.43 | 8.82 | 21.00 | -| SplitView | splitter | non-text 3 | `neutral_stroke_1` | `neutral_background_1` | **1.53 ✗** | **2.53 ✗** | 21.00 | +| SplitView | splitter | non-text 3 | `neutral_stroke_1` | `neutral_background_1` | 3.03 | 3.02 | 21.00 | | SplitView | splitter, focused | non-text 3 | `brand_stroke_1` | `neutral_background_1` | 5.38 | 5.18 | 21.00 | -## 2. Failures +## 2. Recorded exceptions, now fixed (issue #150) -### 2.1 Decorative 1 px edges (light and dark), accepted +The operator decided (2026-10-26) to fix the recorded exceptions rather than classify them as decorative. `Theme::light` and `Theme::dark` move each token from Fluent's value toward the next accessible step on the same ramp, stopping at the first colour that clears 3:1 on `neutral_background_1`. High contrast already passed and is unchanged. `KNOWN_FAILURES` is gone; the contrast test requires the threshold for every pair. -Fluent's `neutral_stroke_1`, `neutral_stroke_2` and `status_*_border_1` measure 1.3 to 2.7:1 against the surface. These are the borders of Button, Card, Menu/Popover, MessageBar, KeyTip badges and the SplitView splitter. WCAG 1.4.11 does not require them: each control is identified by its label, fill or icon, and the controls whose state depends on an edge (Checkbox, Radio, Switch, Input, Slider) use `neutral_stroke_accessible` (≥ 6:1). In HC all of these strokes are white (21:1). Listed in `KNOWN_FAILURES`. +| token | themes | before | after | ratio before | ratio after | used by | +|---|---|---|---|---|---|---| +| `neutral_stroke_1` | light | (209, 209, 209) | (148, 148, 148) | 1.53 | 3.03 | Button, Card and KeyTip borders, SplitView splitter | +| `neutral_stroke_1` | dark | (102, 102, 102) | (114, 114, 114) | 2.53 | 3.02 | the same | +| `neutral_stroke_2` | light | (224, 224, 224) | (148, 148, 148) | 1.32 | 3.03 | Menu and Popover surface border | +| `neutral_stroke_2` | dark | (82, 82, 82) | (114, 114, 114) | 1.86 | 3.02 | the same | +| `status_success_border_1` | light | (159, 216, 159) | (82, 166, 82) | 1.64 | 3.03 | MessageBar success border | +| `status_success_border_1` | dark | (16, 124, 16) | (28, 132, 28) | 2.71 | 3.03 | the same | +| `status_danger_border_1` | light | (238, 172, 178) | (223, 116, 125) | 1.88 | 3.03 | MessageBar error border | +| `status_danger_border_1` | dark | (197, 15, 31) | (209, 56, 70) | 2.40 | 3.02 | the same | +| `brand_background` | dark | (17, 94, 163), brand step 70 | (22, 116, 199), blend of steps 80 and 90 | 2.18 | 3.02 | primary button fill against the surface | -### 2.2 Dark-theme token values: overridden (WI-1844) +No brand step keeps both 3:1 against the dark surface and 4.5:1 for the white label: step 80 is 2.70:1 and 5.38:1, step 90 is 3.85:1 and 3.78:1. The blend is the first colour on that line that clears 3:1. White text on it is about 4.82:1, still above 4.5. Light `brand_background` stays Fluent's (15, 108, 189) at 5.38:1. -Fluent's dark values failed AA. On the operator's decision (2026-10-19), `Theme::dark` overrides both, and light and HC keep Fluent's values: +### 2.1 Dark-theme text overrides (WI-1844) + +On the operator's decision (2026-10-19), `Theme::dark` also overrides two text tokens, and light and HC keep Fluent's values: - `status_danger_foreground_1` text on the surface was **4.16:1** (Field error message, danger contextual ribbon tab, and the other danger foregrounds). Dark now uses `status_danger_foreground_3`. - `neutral_foreground_2_brand_hover` on the subtle hover fill was **4.18:1** (subtle button, hover only). Dark now uses `neutral_foreground_2_hover`, so the dark hover label loses its brand tint. -- The primary button fill against the dark surface is 2.18:1. It is not required, because the white label on the fill is 6.66:1. ### 2.3 Fixed in this PR diff --git a/docs/ci.md b/docs/ci.md index 0290347..05c286e 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,7 +1,8 @@ -# CI: pull requests, the merge queue, and `main` +# CI: pull requests and the merge queue -A pull request gets fast feedback on the packages it changed. The merge queue and `main` run -everything. The single required context is **`ci`**; see +A pull request gets fast feedback on the packages it changed. The merge queue runs +everything. A push to `main` does not re-run `ci.yml`: the queue already passed those +checks before the commit landed. The single required context is **`ci`**; see [`CONTRIBUTING.md`](../CONTRIBUTING.md#how-changes-land). ## What runs where @@ -10,7 +11,7 @@ everything. The single required context is **`ci`**; see |---|---| | `pull_request` | the jobs the diff needs; Linux only, one OS | | `merge_group` (the queue) | everything, Linux and Windows | -| `push` to `main` | everything, Linux and Windows | +| `push` to `main` | nothing from `ci.yml` (the queue already ran it) | Every job has the same id and name for a given package, so the required `ci` gate is the only context a ruleset needs. `ci` needs every job and passes when each one succeeded or was skipped @@ -22,25 +23,78 @@ The `classify changes` job diffs the PR against its merge base and turns the cha into a package list. Anything that changes workspace resolution or builds takes the full workspace; an unrecognised `crates/*` path also falls back to full, never to "no check". -| Changed paths | Runs on a PR | +| Changed paths | Checks that depend on them | |---|---| -| `.github/*`, `Cargo.toml`, `Cargo.lock`, `rustfmt.toml`, `rust-toolchain.toml`, `.cargo/*` | full workspace: every job, Linux and Windows | -| `crates/gallery/`, `gallery/`, `docs/images/gallery/`, `scripts/gallery-screenshots.sh` | `gallery` package plus the gallery jobs (`gallery-wasm`, `gallery-screenshots`) | -| `crates/fluent2-egui/*` | `fluent2-egui` **and** `gallery`, because the gallery enables every optional feature and is what exercises them | -| `crates/fluent2-icons/*` | `fluent2-icons` | -| `crates/fluent2-tokens/*` | `fluent2-tokens` | -| `xtask/*` | `xtask` | -| anything else (docs, markdown, `scripts/`, `upstream.toml`) | nothing; the `ci` gate passes | +| `.github/*`, `Cargo.toml`, `Cargo.lock`, `deny.toml`, `rustfmt.toml`, `rust-toolchain.toml`, `.cargo/*` | full workspace: every job, Linux and Windows, including the downstream fixtures, regeneration drift, icon drift, `cargo deny` and the README index | +| `fixtures/*` | the downstream fixture jobs (`fixtures`, `fixtures-wasm`, `msrv`) | +| `crates/gallery/`, `gallery/`, `docs/images/gallery/`, `scripts/gallery-screenshots.sh`, `scripts/wasm-size-check.sh`, `ci/wasm-budget.txt`, `docs/wasm-size.md` | `gallery` package plus the gallery jobs (`gallery-wasm`, which also enforces `ci/wasm-budget.txt`, and `gallery-screenshots`) | +| `crates/fluent2-egui/*` | `fluent2-egui` **and** `gallery`, because the gallery enables every optional feature and is what exercises them; the README index runs with `fluent2-egui`. Also the downstream fixtures: each optional feature on its own, a wasm consumer, and the MSRV consumer | +| `crates/fluent2-icons/*`, `assets/icons/*`, `xtask/src/icons.rs` | icon drift (`cargo xtask check-icons`, offline) plus `fluent2-icons`, `fluent2-egui` and `gallery` | +| `crates/fluent2-tokens/*`, `design-tokens/*`, `scripts/gen-tokens*`, `scripts/snapshot-tokens*` | regeneration drift (`cargo xtask gen-tokens` must leave the tree clean) plus `fluent2-tokens`, `fluent2-egui` and `gallery` | +| `xtask/*` | `xtask`, regeneration drift, and the crates the generators write (`fluent2-tokens`, `fluent2-egui`, `gallery`) | +| other `scripts/*` (generators) | regeneration drift and `xtask` plus `fluent2-tokens` | +| `README.md` | the README component index (`cargo test -p fluent2-egui --test readme_index`) | +| any other `crates/*` path | full workspace (an unrecognised crate is not safe to narrow), including the downstream fixtures | +| anything else (other docs, markdown, `upstream.toml`) | the full PR set, including the downstream fixtures. An unclassified path fails safe; it never skips every check | Clippy, nextest and the doctests run with `--workspace` or with one `-p ` per package -in the scope. The extra `serde` feature test runs when `fluent2-egui` is in scope. The Windows -leg and `gallery-wasm` for library-only changes run in the queue and on `main`, never on a PR. +in the scope. The extra `serde` feature test and the README index run when `fluent2-egui` is in +scope, and the README index also runs for a README-only change. Regeneration drift +(`cargo xtask gen-tokens && git diff --exit-code`, in the `fmt` job) runs when tokens, a +generator or `xtask` changed, and on the full workspace. Icon drift +(`cargo xtask check-icons`) rebuilds `crates/fluent2-icons/src/generated.rs` from the +committed SVGs with no network and fails when that file or a committed SVG differs. It +runs when icon sources change (`crates/fluent2-icons/`, `assets/icons/`, `xtask/src/icons.rs`) +and on the full workspace. `cargo deny check` (advisories and licences, `deny.toml`) +runs when `Cargo.toml`, `Cargo.lock`, `deny.toml` or `.github/` change, and on the full +workspace. `release.yml` uses the same `deny.toml` for its licence step. The Windows leg and `gallery-wasm` for +library-only changes run in the queue, never on a PR or on a push to `main`. + +## Downstream fixtures + +`fixtures/consumer` is a workspace member with `publish = false`. It depends on +`fluent2-egui` with `default-features = false`, so a check of that crate is a +minimal consumer rather than the gallery (which turns most optional features on +together). + +The `fixtures` job runs when `crates/fluent2-egui/`, `fixtures/`, `Cargo.toml`, +`Cargo.lock` or `.github/` change, and whenever the classifier takes the full +set. It is a matrix, one `cargo check -p fluent2-egui-consumer --no-default-features` +per cell: no features, each optional feature on its own (`bundled-font`, +`web-a11y`, `serde`, `terminal`, `diff`, `highlight`, `file-dialog`, `docking`, +`markdown`, `html`, `web-find`, `chart`, `chrono`), and two pairs (`serde,docking` +and `markdown,html`). `snapshots` and `system-preferences` stay out of the matrix: +the first is a dev-only test feature, the second pulls platform bindings that do +not resolve on every target. `fixtures-wasm` checks the same crate with +`--target wasm32-unknown-unknown` and no features. `msrv` installs the declared +`rust-version` (`1.95`) and checks the minimal consumer there, not the whole +workspace on stable. + +Locally, from the workspace root: + +```sh +cargo check -p fluent2-egui-consumer --no-default-features --locked +cargo check -p fluent2-egui-consumer --no-default-features --features serde --locked +cargo check -p fluent2-egui-consumer --no-default-features --locked --target wasm32-unknown-unknown +``` + +The wasm line needs the `wasm32-unknown-unknown` target installed. If it is not, +that check is not run locally; CI still runs it. + +`scripts/ci-classify-test.sh` feeds sample changed-file lists through the same `case` and checks +which jobs are selected. It does not need a checkout of history. ## Build cache and runner +- Action uses in `ci.yml` are pinned to a commit SHA, with the version in a comment: + `actions/checkout` v4.2.2, `actions/upload-artifact` v4.6.2, `dtolnay/rust-toolchain` + (master, which resolves the `stable` and `1.95` toolchains), `taiki-e/install-action`, + `jetli/trunk-action` v0.5.0 and `EmbarkStudios/cargo-deny-action` v2. `release.yml` + still uses floating tags except the deny action, which shares the SHA above. +- The screenshot job runs in `rust@sha256:f49565f1…` (`1.95-trixie` on the day it was pinned). - `Swatinem/rust-cache` is pinned by commit SHA (v2.9.3), with `shared-key: ci`, so every job and the queue share the build artefacts. `save-if` is false on `pull_request`: a PR reads - the cache and only the queue or `main` writes it. + the cache and only the queue writes it. - `CARGO_INCREMENTAL: 0` and `CARGO_PROFILE_TEST_DEBUG: 0` keep the cached artefacts small. - `cargo-nextest` is installed with the SHA-pinned `taiki-e/install-action` and runs the tests; doctests are a separate `cargo test --doc` step, because nextest does not run them. @@ -90,3 +144,22 @@ queue it merges once checks pass. `TheDancingDeveloper-org/fluent2-egui` is organisation-owned, so the merge queue is available and active: squash merges, up to three entries built and merged per run, all-green required, 60-minute timeout. The `main` ruleset requires the `ci` context and allows no bypass actors. + +## Release publishing + +`.github/workflows/release.yml` runs the gates on a `v*` tag and never publishes +from a tag push. Publishing is a manual `workflow_dispatch` on the tag, with +`publish_crates` ticked, and the `publish` job uses the protected `crates-io` +environment plus a `crates-io-publish` concurrency group so two publishes cannot +run at once. + +`scripts/publish-crates.sh` refuses a dirty tree and a tag that is not +`v`. It runs `cargo deny check licenses` (`deny.toml` allows MIT, Apache-2.0 and the other +permissive licences already in the tree: BSD-2-Clause, BSD-3-Clause, BSL-1.0, +ISC, Zlib, Unicode-3.0, OFL-1.1, Ubuntu-font-1.0, CDLA-Permissive-2.0; a +copyleft or unknown licence fails) before any publish, +then `cargo package` and checks the archive lists `LICENSE-MIT`, +`LICENSE-APACHE` and `NOTICE`. Each of `fluent2-tokens`, `fluent2-icons` and +`fluent2-egui` is skipped when that version is already on crates.io, so a +re-run resumes after a partial failure. Without `--publish` the script dry-runs +(`cargo publish --dry-run`) and uploads nothing. diff --git a/docs/images/gallery/avatars-dark.png b/docs/images/gallery/avatars-dark.png index 67401af..43c42e3 100644 Binary files a/docs/images/gallery/avatars-dark.png and b/docs/images/gallery/avatars-dark.png differ diff --git a/docs/images/gallery/avatars-light.png b/docs/images/gallery/avatars-light.png index 0fa7f94..547f088 100644 Binary files a/docs/images/gallery/avatars-light.png and b/docs/images/gallery/avatars-light.png differ diff --git a/docs/images/gallery/backstage-dark.png b/docs/images/gallery/backstage-dark.png index 0138105..b1c1bd8 100644 Binary files a/docs/images/gallery/backstage-dark.png and b/docs/images/gallery/backstage-dark.png differ diff --git a/docs/images/gallery/backstage-light.png b/docs/images/gallery/backstage-light.png index 5750b66..58d6fd3 100644 Binary files a/docs/images/gallery/backstage-light.png and b/docs/images/gallery/backstage-light.png differ diff --git a/docs/images/gallery/basic-controls-dark.png b/docs/images/gallery/basic-controls-dark.png index 4c7e39f..c7a2492 100644 Binary files a/docs/images/gallery/basic-controls-dark.png and b/docs/images/gallery/basic-controls-dark.png differ diff --git a/docs/images/gallery/basic-controls-light.png b/docs/images/gallery/basic-controls-light.png index 4982c04..5b43c0b 100644 Binary files a/docs/images/gallery/basic-controls-light.png and b/docs/images/gallery/basic-controls-light.png differ diff --git a/docs/images/gallery/charts-dark.png b/docs/images/gallery/charts-dark.png index 9cf9cfb..7db8ae2 100644 Binary files a/docs/images/gallery/charts-dark.png and b/docs/images/gallery/charts-dark.png differ diff --git a/docs/images/gallery/charts-light.png b/docs/images/gallery/charts-light.png index a03d5ef..d4125e1 100644 Binary files a/docs/images/gallery/charts-light.png and b/docs/images/gallery/charts-light.png differ diff --git a/docs/images/gallery/code-view-dark.png b/docs/images/gallery/code-view-dark.png index 24c1db6..44507f1 100644 Binary files a/docs/images/gallery/code-view-dark.png and b/docs/images/gallery/code-view-dark.png differ diff --git a/docs/images/gallery/code-view-light.png b/docs/images/gallery/code-view-light.png index ac0d639..64d6018 100644 Binary files a/docs/images/gallery/code-view-light.png and b/docs/images/gallery/code-view-light.png differ diff --git a/docs/images/gallery/command-palette-dark.png b/docs/images/gallery/command-palette-dark.png index d3719ac..4b77618 100644 Binary files a/docs/images/gallery/command-palette-dark.png and b/docs/images/gallery/command-palette-dark.png differ diff --git a/docs/images/gallery/command-palette-light.png b/docs/images/gallery/command-palette-light.png index 7a16313..e470c3d 100644 Binary files a/docs/images/gallery/command-palette-light.png and b/docs/images/gallery/command-palette-light.png differ diff --git a/docs/images/gallery/controls-dark.png b/docs/images/gallery/controls-dark.png index f874f26..0fd67e2 100644 Binary files a/docs/images/gallery/controls-dark.png and b/docs/images/gallery/controls-dark.png differ diff --git a/docs/images/gallery/controls-light.png b/docs/images/gallery/controls-light.png index e2c0f68..cf67cca 100644 Binary files a/docs/images/gallery/controls-light.png and b/docs/images/gallery/controls-light.png differ diff --git a/docs/images/gallery/datagrid-dark.png b/docs/images/gallery/datagrid-dark.png index b3a75b6..6baca3f 100644 Binary files a/docs/images/gallery/datagrid-dark.png and b/docs/images/gallery/datagrid-dark.png differ diff --git a/docs/images/gallery/datagrid-light.png b/docs/images/gallery/datagrid-light.png index 25bf331..9907875 100644 Binary files a/docs/images/gallery/datagrid-light.png and b/docs/images/gallery/datagrid-light.png differ diff --git a/docs/images/gallery/date-and-time-dark.png b/docs/images/gallery/date-and-time-dark.png index 3723a9c..27d884f 100644 Binary files a/docs/images/gallery/date-and-time-dark.png and b/docs/images/gallery/date-and-time-dark.png differ diff --git a/docs/images/gallery/date-and-time-light.png b/docs/images/gallery/date-and-time-light.png index 75d1d6c..0e0ceac 100644 Binary files a/docs/images/gallery/date-and-time-light.png and b/docs/images/gallery/date-and-time-light.png differ diff --git a/docs/images/gallery/diff-dark.png b/docs/images/gallery/diff-dark.png index 886a34b..f96b378 100644 Binary files a/docs/images/gallery/diff-dark.png and b/docs/images/gallery/diff-dark.png differ diff --git a/docs/images/gallery/diff-light.png b/docs/images/gallery/diff-light.png index d662834..0515039 100644 Binary files a/docs/images/gallery/diff-light.png and b/docs/images/gallery/diff-light.png differ diff --git a/docs/images/gallery/docking-dark.png b/docs/images/gallery/docking-dark.png index 2ce5a63..7b25ecb 100644 Binary files a/docs/images/gallery/docking-dark.png and b/docs/images/gallery/docking-dark.png differ diff --git a/docs/images/gallery/docking-light.png b/docs/images/gallery/docking-light.png index 30c6fb5..3939de4 100644 Binary files a/docs/images/gallery/docking-light.png and b/docs/images/gallery/docking-light.png differ diff --git a/docs/images/gallery/drawer-dark.png b/docs/images/gallery/drawer-dark.png index 04d85f1..60aac84 100644 Binary files a/docs/images/gallery/drawer-dark.png and b/docs/images/gallery/drawer-dark.png differ diff --git a/docs/images/gallery/drawer-light.png b/docs/images/gallery/drawer-light.png index 80138c4..6a002b3 100644 Binary files a/docs/images/gallery/drawer-light.png and b/docs/images/gallery/drawer-light.png differ diff --git a/docs/images/gallery/editable-list-dark.png b/docs/images/gallery/editable-list-dark.png index e6c97ad..2e58c42 100644 Binary files a/docs/images/gallery/editable-list-dark.png and b/docs/images/gallery/editable-list-dark.png differ diff --git a/docs/images/gallery/editable-list-light.png b/docs/images/gallery/editable-list-light.png index d8f54cb..9865970 100644 Binary files a/docs/images/gallery/editable-list-light.png and b/docs/images/gallery/editable-list-light.png differ diff --git a/docs/images/gallery/editor-dark.png b/docs/images/gallery/editor-dark.png index ae93194..250e13c 100644 Binary files a/docs/images/gallery/editor-dark.png and b/docs/images/gallery/editor-dark.png differ diff --git a/docs/images/gallery/editor-light.png b/docs/images/gallery/editor-light.png index 3a90c33..46d0ce8 100644 Binary files a/docs/images/gallery/editor-light.png and b/docs/images/gallery/editor-light.png differ diff --git a/docs/images/gallery/file-dialog-dark.png b/docs/images/gallery/file-dialog-dark.png index 734cecc..d2659a8 100644 Binary files a/docs/images/gallery/file-dialog-dark.png and b/docs/images/gallery/file-dialog-dark.png differ diff --git a/docs/images/gallery/file-dialog-light.png b/docs/images/gallery/file-dialog-light.png index 72c9c83..62ae0e4 100644 Binary files a/docs/images/gallery/file-dialog-light.png and b/docs/images/gallery/file-dialog-light.png differ diff --git a/docs/images/gallery/find-dark.png b/docs/images/gallery/find-dark.png index d4681a4..e3d7260 100644 Binary files a/docs/images/gallery/find-dark.png and b/docs/images/gallery/find-dark.png differ diff --git a/docs/images/gallery/find-light.png b/docs/images/gallery/find-light.png index 00ce7ca..e7184be 100644 Binary files a/docs/images/gallery/find-light.png and b/docs/images/gallery/find-light.png differ diff --git a/docs/images/gallery/html-dark.png b/docs/images/gallery/html-dark.png index b0a9cc5..ccdeaf6 100644 Binary files a/docs/images/gallery/html-dark.png and b/docs/images/gallery/html-dark.png differ diff --git a/docs/images/gallery/html-light.png b/docs/images/gallery/html-light.png index 666e293..0ebb371 100644 Binary files a/docs/images/gallery/html-light.png and b/docs/images/gallery/html-light.png differ diff --git a/docs/images/gallery/icons-dark.png b/docs/images/gallery/icons-dark.png index db897c5..1221d71 100644 Binary files a/docs/images/gallery/icons-dark.png and b/docs/images/gallery/icons-dark.png differ diff --git a/docs/images/gallery/icons-light.png b/docs/images/gallery/icons-light.png index 93df3af..7bbc566 100644 Binary files a/docs/images/gallery/icons-light.png and b/docs/images/gallery/icons-light.png differ diff --git a/docs/images/gallery/list-rows-dark.png b/docs/images/gallery/list-rows-dark.png index a7b2082..3abeb22 100644 Binary files a/docs/images/gallery/list-rows-dark.png and b/docs/images/gallery/list-rows-dark.png differ diff --git a/docs/images/gallery/list-rows-light.png b/docs/images/gallery/list-rows-light.png index d09d73c..f7af811 100644 Binary files a/docs/images/gallery/list-rows-light.png and b/docs/images/gallery/list-rows-light.png differ diff --git a/docs/images/gallery/markdown-dark.png b/docs/images/gallery/markdown-dark.png index 27f6e3b..851317b 100644 Binary files a/docs/images/gallery/markdown-dark.png and b/docs/images/gallery/markdown-dark.png differ diff --git a/docs/images/gallery/markdown-light.png b/docs/images/gallery/markdown-light.png index a76788e..3720f8f 100644 Binary files a/docs/images/gallery/markdown-light.png and b/docs/images/gallery/markdown-light.png differ diff --git a/docs/images/gallery/pager-dark.png b/docs/images/gallery/pager-dark.png index 54f1ac6..81050ac 100644 Binary files a/docs/images/gallery/pager-dark.png and b/docs/images/gallery/pager-dark.png differ diff --git a/docs/images/gallery/pager-light.png b/docs/images/gallery/pager-light.png index 20cf72c..e34b0cf 100644 Binary files a/docs/images/gallery/pager-light.png and b/docs/images/gallery/pager-light.png differ diff --git a/docs/images/gallery/pieces-dark.png b/docs/images/gallery/pieces-dark.png index 92e54ae..8ec68ac 100644 Binary files a/docs/images/gallery/pieces-dark.png and b/docs/images/gallery/pieces-dark.png differ diff --git a/docs/images/gallery/pieces-light.png b/docs/images/gallery/pieces-light.png index 040ef29..13206bc 100644 Binary files a/docs/images/gallery/pieces-light.png and b/docs/images/gallery/pieces-light.png differ diff --git a/docs/images/gallery/ribbon-dark.png b/docs/images/gallery/ribbon-dark.png index 5fcf811..890d023 100644 Binary files a/docs/images/gallery/ribbon-dark.png and b/docs/images/gallery/ribbon-dark.png differ diff --git a/docs/images/gallery/ribbon-light.png b/docs/images/gallery/ribbon-light.png index d6e1530..73ab7db 100644 Binary files a/docs/images/gallery/ribbon-light.png and b/docs/images/gallery/ribbon-light.png differ diff --git a/docs/images/gallery/rich-text-editor-dark.png b/docs/images/gallery/rich-text-editor-dark.png index c42e615..9533462 100644 Binary files a/docs/images/gallery/rich-text-editor-dark.png and b/docs/images/gallery/rich-text-editor-dark.png differ diff --git a/docs/images/gallery/rich-text-editor-light.png b/docs/images/gallery/rich-text-editor-light.png index 23648d2..01b1ecf 100644 Binary files a/docs/images/gallery/rich-text-editor-light.png and b/docs/images/gallery/rich-text-editor-light.png differ diff --git a/docs/images/gallery/row-drag-dark.png b/docs/images/gallery/row-drag-dark.png index 287a8cc..822328b 100644 Binary files a/docs/images/gallery/row-drag-dark.png and b/docs/images/gallery/row-drag-dark.png differ diff --git a/docs/images/gallery/row-drag-light.png b/docs/images/gallery/row-drag-light.png index a1929e5..ecadb77 100644 Binary files a/docs/images/gallery/row-drag-light.png and b/docs/images/gallery/row-drag-light.png differ diff --git a/docs/images/gallery/shortcuts-dark.png b/docs/images/gallery/shortcuts-dark.png index 325c05d..2886795 100644 Binary files a/docs/images/gallery/shortcuts-dark.png and b/docs/images/gallery/shortcuts-dark.png differ diff --git a/docs/images/gallery/shortcuts-light.png b/docs/images/gallery/shortcuts-light.png index 0e11e6d..05519ec 100644 Binary files a/docs/images/gallery/shortcuts-light.png and b/docs/images/gallery/shortcuts-light.png differ diff --git a/docs/images/gallery/skeleton-dark.png b/docs/images/gallery/skeleton-dark.png index 7e9eedb..58760ec 100644 Binary files a/docs/images/gallery/skeleton-dark.png and b/docs/images/gallery/skeleton-dark.png differ diff --git a/docs/images/gallery/skeleton-light.png b/docs/images/gallery/skeleton-light.png index 074dffd..a87abb5 100644 Binary files a/docs/images/gallery/skeleton-light.png and b/docs/images/gallery/skeleton-light.png differ diff --git a/docs/images/gallery/surfaces-dark.png b/docs/images/gallery/surfaces-dark.png index 8651bec..5719739 100644 Binary files a/docs/images/gallery/surfaces-dark.png and b/docs/images/gallery/surfaces-dark.png differ diff --git a/docs/images/gallery/surfaces-light.png b/docs/images/gallery/surfaces-light.png index a582d0c..eccfc7f 100644 Binary files a/docs/images/gallery/surfaces-light.png and b/docs/images/gallery/surfaces-light.png differ diff --git a/docs/images/gallery/tab-strip-dark.png b/docs/images/gallery/tab-strip-dark.png index 92604e3..1aa6f82 100644 Binary files a/docs/images/gallery/tab-strip-dark.png and b/docs/images/gallery/tab-strip-dark.png differ diff --git a/docs/images/gallery/tab-strip-light.png b/docs/images/gallery/tab-strip-light.png index 0a3dabc..5b6c3f7 100644 Binary files a/docs/images/gallery/tab-strip-light.png and b/docs/images/gallery/tab-strip-light.png differ diff --git a/docs/images/gallery/terminal-dark.png b/docs/images/gallery/terminal-dark.png index 30c51a4..3c12cef 100644 Binary files a/docs/images/gallery/terminal-dark.png and b/docs/images/gallery/terminal-dark.png differ diff --git a/docs/images/gallery/terminal-light.png b/docs/images/gallery/terminal-light.png index 2d56b31..c233b0d 100644 Binary files a/docs/images/gallery/terminal-light.png and b/docs/images/gallery/terminal-light.png differ diff --git a/docs/images/gallery/text-selection-dark.png b/docs/images/gallery/text-selection-dark.png index 22726ed..d816332 100644 Binary files a/docs/images/gallery/text-selection-dark.png and b/docs/images/gallery/text-selection-dark.png differ diff --git a/docs/images/gallery/tokens-dark.png b/docs/images/gallery/tokens-dark.png index 139a1ed..035d54e 100644 Binary files a/docs/images/gallery/tokens-dark.png and b/docs/images/gallery/tokens-dark.png differ diff --git a/docs/images/gallery/tree-view-dark.png b/docs/images/gallery/tree-view-dark.png index a3b419a..0014694 100644 Binary files a/docs/images/gallery/tree-view-dark.png and b/docs/images/gallery/tree-view-dark.png differ diff --git a/docs/images/gallery/tree-view-light.png b/docs/images/gallery/tree-view-light.png index e818dee..65bac35 100644 Binary files a/docs/images/gallery/tree-view-light.png and b/docs/images/gallery/tree-view-light.png differ diff --git a/docs/images/gallery/url-state-dark.png b/docs/images/gallery/url-state-dark.png index f53c513..7fe9f11 100644 Binary files a/docs/images/gallery/url-state-dark.png and b/docs/images/gallery/url-state-dark.png differ diff --git a/docs/images/gallery/url-state-light.png b/docs/images/gallery/url-state-light.png index 64c3f9f..48db63a 100644 Binary files a/docs/images/gallery/url-state-light.png and b/docs/images/gallery/url-state-light.png differ diff --git a/docs/specs/dialog.md b/docs/specs/dialog.md index 44ac0b4..a9ea8cc 100644 --- a/docs/specs/dialog.md +++ b/docs/specs/dialog.md @@ -1,18 +1,26 @@ # Dialog -A modal or non-modal surface built on [`egui::Modal`](https://docs.rs/egui/0.36.2/egui/containers/struct.Modal.html). -The egui modal already paints a full-screen backdrop, blocks input to the UI behind it, tracks the -topmost modal layer, and reports whether any popup was open at the start of the frame. Fluent -chrome, focus, keyboard policy and accessibility sit on top of that. A custom area is not used. +A modal surface built on [`egui::Modal`](https://docs.rs/egui/0.36.2/egui/containers/struct.Modal.html). +Every dialog is modal. The egui modal paints a full-screen backdrop, blocks input to the UI behind +it, tracks the topmost modal layer, and reports whether any popup was open at the start of the +frame. That blocking does not depend on [`DialogKind`]: a click, hover or key meant for the page +does not reach it while any dialog is open. Fluent chrome, focus, keyboard policy and +accessibility sit on top of that. A custom area is not used. ## Kinds +`DialogKind` is the dismissal policy, not modality. All three kinds block background input. + | Kind | Backdrop click | Escape | Use | |---|---|---|---| | `Modal` (default) | closes (`CloseReason::Backdrop`) | closes (`CloseReason::Escape`) when this dialog is the top modal and no popup is open | ordinary dialog | -| `NonModal` | does not close | same as Modal | a surface that stays open while the user clicks the dimmed page | +| `Dismissible` | does not close | same as Modal | a modal surface that stays open when the dimmed page is clicked | | `Alert` | ignored | ignored | a choice the user must make with a button | +`DialogKind::NonModal` is a deprecated alias of `Dismissible`. The old name described the +dismissal policy as if the dialog were not modal. It was not: the backdrop always blocked the +page. New code uses `Dismissible`. + `Dialog::show` returns `DialogResult::Closed` only on the frame the dialog closes. While it stays open the body closure runs and its value is `DialogResult::Open`. Setting `open` to `false` from outside, or calling `DialogActions::close`, is `CloseReason::Programmatic`. @@ -40,7 +48,7 @@ Enter activates the primary action, unless: Escape follows the kind table above and is also swallowed when a popup is open, so a Dropdown inside the dialog can close itself first. The next Escape, with no popup open, closes a Modal or -NonModal dialog. Alert never closes on Escape. +Dismissible dialog. Alert never closes on Escape. ## Nested popups diff --git a/docs/specs/drawer.md b/docs/specs/drawer.md index ee3657a..d59141b 100644 --- a/docs/specs/drawer.md +++ b/docs/specs/drawer.md @@ -12,9 +12,10 @@ copied. built on [`egui::Modal`](https://docs.rs/egui/0.36.2/egui/containers/struct.Modal.html), like [Dialog](dialog.md), and follow the same rules: -- `DialogKind` sets the policy: `Modal` closes on Escape and on a backdrop click, `NonModal` on +- `DialogKind` sets the dismissal policy, not modality. Every overlay blocks the page behind it. + `Modal` closes on Escape and on a backdrop click, `Dismissible` (the old name `NonModal`) on Escape only, `Alert` on neither. The header's Close button (`CLOSE_LABEL`) always closes, as - `CloseReason::Button("Close")`. + `CloseReason::Button("Close".to_owned())`. - A popup open before the body owns Escape. - Focus trap: on opening, focus moves to the surface. Tab and Shift+Tab cycle the Close button, the body's widgets and the surface, and never reach the page. diff --git a/docs/wasm-size.md b/docs/wasm-size.md index bbd7864..f0d1141 100644 --- a/docs/wasm-size.md +++ b/docs/wasm-size.md @@ -111,14 +111,40 @@ measured here; the PR records it. 4. Remove the `highlight` feature from the gallery: about 0.7 MB raw, but the CodeView page goes with it. -**Budget (recommendation for maintainers to confirm):** gzip ≤ 4.5 MB for the gallery wasm. -That leaves headroom over the 4.11 MB now, and it fails the build if a feature adds about 10% -without a conscious decision. Pick `opt-level = "z"` only if the budget needs the extra margin. +**Budget (enforced):** gzip ≤ 4,500,000 bytes and brotli ≤ 4,500,000 bytes for the gallery +wasm (`*_bg.wasm` from `trunk build index.html --release`). The numbers live in +`ci/wasm-budget.txt`. `scripts/wasm-size-check.sh` compares the build and exits 1 when either +measured size is over its line (brotli is checked only when the `brotli` command is installed). +The `gallery-wasm` job runs that script and uploads `wasm-size.txt` as the `wasm-size` artifact, +including when the gate fails. The job runs on the gallery path class from the change classifier +(`crates/gallery/`, `gallery/`, `docs/images/gallery/`, `scripts/gallery-screenshots.sh`, +`scripts/wasm-size-check.sh`, `ci/wasm-budget.txt`, `docs/wasm-size.md`) and on the full set. + +To raise the budget, change the number in `ci/wasm-budget.txt` in the same change that grows the +wasm, and say why in this section. A raise is a conscious decision, not a silent one: the gate +fails the build otherwise. 4,500,000 bytes is the previous recommendation (about 10% over the +4,109,368 byte gzip of the size-profile build). Pick `opt-level = "z"` only if a raise would +otherwise be needed. + +Frame time for a 10,000-row list, a 10,000-row data grid and a combobox of 10,000 options is +`cargo bench -p fluent2-egui --bench large_views` (`crates/fluent2-egui/benches/large_views.rs`). +It prints wall time per frame over 60 frames after a warm-up frame. It is not a required check +and it does not fail CI. Allocation counts are not reported: a counting allocator needs `unsafe`, +which the library forbids. ## Follow-ups +Still not done: + +- Throttled time-to-first-frame (4× CPU, Fast 3G). A throttled headless run was started for the + original measurement and did not return; it is not part of this gate. +- A scripted Open and Save pass of the gallery file dialog. It needs a file-chooser intercept + and a click into the egui canvas, which the gallery does not expose by URL. + +Done alongside the budget: + - Measure throttled time-to-first-frame in a headless run that waits for the wasm download to - finish, then a scripted Open and Save pass. + finish, then a scripted Open and Save pass. (Kept as the original note; both stay open, see above.) - Measure the gallery trunk job's CI time before and after the profile lands. - Add the before and after table to the gallery docs and the README defaults, as the issue asks, once the budget is agreed. diff --git a/fixtures/consumer/Cargo.toml b/fixtures/consumer/Cargo.toml new file mode 100644 index 0000000..dcd035c --- /dev/null +++ b/fixtures/consumer/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "fluent2-egui-consumer" +description = "Downstream fixture: fluent2-egui with one feature set at a time" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +fluent2-egui = { path = "../../crates/fluent2-egui", default-features = false } + +[features] +bundled-font = ["fluent2-egui/bundled-font"] +web-a11y = ["fluent2-egui/web-a11y"] +serde = ["fluent2-egui/serde"] +terminal = ["fluent2-egui/terminal"] +diff = ["fluent2-egui/diff"] +highlight = ["fluent2-egui/highlight"] +file-dialog = ["fluent2-egui/file-dialog"] +docking = ["fluent2-egui/docking"] +markdown = ["fluent2-egui/markdown"] +html = ["fluent2-egui/html"] +web-find = ["fluent2-egui/web-find"] +chart = ["fluent2-egui/chart"] +chrono = ["fluent2-egui/chrono"] diff --git a/fixtures/consumer/src/lib.rs b/fixtures/consumer/src/lib.rs new file mode 100644 index 0000000..b97242f --- /dev/null +++ b/fixtures/consumer/src/lib.rs @@ -0,0 +1,9 @@ +//! Compiles as a downstream crate of `fluent2-egui`. +//! +//! CI checks this crate with default features off, with each optional feature +//! on its own, and for `wasm32-unknown-unknown`. It does not paint anything. + +/// A function a consumer can call without turning a feature on. +pub fn uses_library() -> fluent2_egui::ThemeKind { + fluent2_egui::ThemeKind::Light +} diff --git a/scripts/ci-classify-test.sh b/scripts/ci-classify-test.sh new file mode 100755 index 0000000..945a612 --- /dev/null +++ b/scripts/ci-classify-test.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Sample changed-file lists through the PR classifier in .github/workflows/ci.yml. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +workflow="$root/.github/workflows/ci.yml" +body="$(awk ' + /while IFS= read -r f; do/ { grab=1; next } + /done &2; exit 1; } + +classify() { + local input="$1" + rust=false gallery=false fixtures=false full=false readme=false tokens=false icons=false deny=false + packages=() + add_package() { + local pkg + for pkg in "${packages[@]}"; do + [[ "$pkg" == "$1" ]] && return 0 + done + packages+=("$1") + } + while IFS= read -r f; do + [[ -z "$f" ]] && continue + eval "$body" + done <<<"$input" + if [[ "$rust" == true && "$full" == false && "$fixtures" == false && ${#packages[@]} -eq 0 ]]; then + full=true + fi + printf 'rust=%s gallery=%s fixtures=%s full=%s readme=%s tokens=%s icons=%s deny=%s packages=%s\n' \ + "$rust" "$gallery" "$fixtures" "$full" "$readme" "$tokens" "$icons" "$deny" "${packages[*]:-}" +} + +expect() { + local name="$1" files="$2" want="$3" got + got="$(classify "$files")" + if [[ "$got" != "$want" ]]; then + echo "FAIL $name" >&2 + echo " got: $got" >&2 + echo " want: $want" >&2 + exit 1 + fi + echo "ok $name" +} + +expect readme "README.md" \ + "rust=true gallery=false fixtures=false full=false readme=true tokens=false icons=false deny=false packages=fluent2-egui" +expect docs "docs/ARCHITECTURE.md" \ + "rust=true gallery=true fixtures=true full=true readme=true tokens=true icons=true deny=true packages=" +expect generator "scripts/gen-icons.sh" \ + "rust=true gallery=false fixtures=false full=false readme=false tokens=true icons=false deny=false packages=xtask fluent2-tokens" +expect tokens "crates/fluent2-tokens/data/light.json" \ + "rust=true gallery=false fixtures=false full=false readme=false tokens=true icons=false deny=false packages=fluent2-tokens fluent2-egui gallery" +expect icons "crates/fluent2-icons/src/lib.rs" \ + "rust=true gallery=false fixtures=false full=false readme=false tokens=false icons=true deny=false packages=fluent2-icons fluent2-egui gallery" +expect empty "" \ + "rust=false gallery=false fixtures=false full=false readme=false tokens=false icons=false deny=false packages=" + +expect wasm_budget "ci/wasm-budget.txt" \ + "rust=true gallery=true fixtures=false full=false readme=false tokens=false icons=false deny=false packages=gallery" +expect wasm_doc "docs/wasm-size.md" \ + "rust=true gallery=true fixtures=false full=false readme=false tokens=false icons=false deny=false packages=gallery" +expect library "crates/fluent2-egui/src/lib.rs" \ + "rust=true gallery=false fixtures=true full=false readme=false tokens=false icons=false deny=false packages=fluent2-egui gallery" +expect fixture "fixtures/consumer/src/lib.rs" \ + "rust=true gallery=false fixtures=true full=false readme=false tokens=false icons=false deny=false packages=" +expect lockfile "Cargo.lock" \ + "rust=true gallery=true fixtures=true full=true readme=true tokens=true icons=true deny=true packages=" +echo "ci-classify-test: passed" diff --git a/scripts/publish-crates.sh b/scripts/publish-crates.sh new file mode 100755 index 0000000..37fc67f --- /dev/null +++ b/scripts/publish-crates.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Publish fluent2-tokens, fluent2-icons and fluent2-egui in dependency order. +# Idempotent: a crate whose version is already on crates.io is skipped, so a +# re-run resumes after a partial failure. Never publishes unless --publish is +# given; the default is a dry run (cargo publish --dry-run). +set -euo pipefail + +publish=0 +if [[ "${1:-}" == "--publish" ]]; then + publish=1 +elif [[ -n "${1:-}" && "${1}" != "--dry-run" ]]; then + echo "usage: $0 [--dry-run | --publish]" >&2 + exit 2 +fi + +root=$(git rev-parse --show-toplevel) +cd "$root" + +if [[ -n "$(git status --porcelain)" ]]; then + echo "refusing to publish: working tree is not clean" >&2 + git status --porcelain >&2 + exit 1 +fi + +tag=${GITHUB_REF_NAME:-$(git describe --tags --exact-match 2>/dev/null || true)} +version=$(cargo metadata --no-deps --format-version 1 \ + | python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"]=="fluent2-egui"))') +if [[ "$tag" != "v$version" ]]; then + echo "refusing to publish: tag '${tag:-}' does not match workspace version v$version" >&2 + exit 1 +fi + +if ! command -v cargo-deny >/dev/null 2>&1; then + echo "cargo-deny is required for the licence check" >&2 + exit 1 +fi +# Allowlist (deny.toml): MIT, Apache-2.0, and the unicode-ident Unicode-3.0 exception. +# SIL OFL is a bundled-font licence (see NOTICE), not a Cargo crate licence, so it is +# not in the cargo-deny allowlist. A disallowed licence fails the check. +cargo deny check licenses + +crates=(fluent2-tokens fluent2-icons fluent2-egui) +for crate in "${crates[@]}"; do + published=$(cargo search "$crate" --limit 1 2>/dev/null | python3 -c 'import re,sys; name=sys.argv[1]; line=sys.stdin.readline(); m=re.match(re.escape(name) + r"\s*=\s*\"([^\"]+)\"", line); print(m.group(1) if m else "")' "$crate" || true) + if [[ "$published" == "$version" ]]; then + echo "skip $crate $version: already on crates.io" + continue + fi + echo "package $crate $version and check the archive ships licence notices" + cargo package -p "$crate" --allow-dirty=false --list \ + | python3 -c 'import sys +need=("LICENSE-MIT","LICENSE-APACHE","NOTICE") +text=sys.stdin.read().splitlines() +missing=[n for n in need if not any(line.endswith("/"+n) or line==n for line in text)] +if missing: + sys.stderr.write("crate archive missing: "+", ".join(missing)+"\n") + sys.exit(1) +' + if [[ "$publish" -eq 1 ]]; then + cargo publish -p "$crate" + else + cargo publish -p "$crate" --dry-run + fi +done diff --git a/scripts/wasm-size-check.sh b/scripts/wasm-size-check.sh new file mode 100755 index 0000000..62f423f --- /dev/null +++ b/scripts/wasm-size-check.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Compare the gallery wasm build's compressed size to ci/wasm-budget.txt. +# Usage: scripts/wasm-size-check.sh [dist-dir] +# dist-dir defaults to crates/gallery/dist. Exits 0 under budget, 1 over, 2 if no wasm. +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd)" +dist="${1:-$root/crates/gallery/dist}" +budget_file="$root/ci/wasm-budget.txt" +wasm="$(find "$dist" -maxdepth 1 -name '*_bg.wasm' -print -quit || true)" +if [[ -z "$wasm" ]]; then + echo "wasm-size-check: no *_bg.wasm under $dist" >&2 + exit 2 +fi +raw="$(wc -c < "$wasm" | tr -d ' ')" +gzip_bytes="$(gzip -9 -c "$wasm" | wc -c | tr -d ' ')" +if command -v brotli >/dev/null 2>&1; then + brotli_bytes="$(brotli -c -q 11 "$wasm" | wc -c | tr -d ' ')" +else + brotli_bytes="n/a" +fi +# Budget lines are "name bytes", comments and blanks ignored. gzip is the gate. +gzip_budget="$(awk '$1 == "gzip" { print $2; exit }' "$budget_file")" +brotli_budget="$(awk '$1 == "brotli" { print $2; exit }' "$budget_file")" +if [[ -z "$gzip_budget" ]]; then + echo "wasm-size-check: no gzip budget in $budget_file" >&2 + exit 2 +fi +report="$dist/wasm-size.txt" +{ + echo "file $(basename "$wasm")" + echo "raw $raw" + echo "gzip $gzip_bytes (budget $gzip_budget)" + echo "brotli $brotli_bytes (budget ${brotli_budget:-none})" +} | tee "$report" +fail=0 +if (( gzip_bytes > gzip_budget )); then + echo "wasm-size-check: gzip $gzip_bytes exceeds budget $gzip_budget" >&2 + fail=1 +fi +if [[ "$brotli_bytes" != "n/a" && -n "$brotli_budget" ]] && (( brotli_bytes > brotli_budget )); then + echo "wasm-size-check: brotli $brotli_bytes exceeds budget $brotli_budget" >&2 + fail=1 +fi +exit "$fail" diff --git a/xtask/src/icons.rs b/xtask/src/icons.rs index 6136146..742f816 100644 --- a/xtask/src/icons.rs +++ b/xtask/src/icons.rs @@ -20,6 +20,15 @@ struct Icon { } pub fn generate(refresh: bool) -> Result<(), String> { + generate_with(refresh, true) +} + +/// Rebuild the committed catalogue from `icons.txt` and the committed SVGs. +/// +/// `fetch` is what `gen-icons` uses: missing files are downloaded from the pinned +/// Fluent System Icons revision. The drift check passes `false`, so a missing or +/// stale committed SVG fails instead of touching the network. +pub fn generate_with(refresh: bool, allow_fetch: bool) -> Result<(), String> { let root = repo_root(); let list_path = root.join("crates/fluent2-icons/icons.txt"); let svg_dir = root.join("crates/fluent2-icons/svg"); @@ -64,6 +73,11 @@ pub fn generate(refresh: bool) -> Result<(), String> { if dest.exists() && !refresh { continue; } + if !allow_fetch { + return Err(format!( + "{file}: committed SVG missing; run cargo xtask gen-icons (network)" + )); + } let folder_enc = icon.folder.replace(' ', "%20"); let url = format!( "https://raw.githubusercontent.com/microsoft/fluentui-system-icons/{REV}/assets/{folder_enc}/SVG/{file}" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 7c007e4..0e72f36 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -4,6 +4,8 @@ //! `crates/fluent2-tokens/src/generated/`. //! - `cargo xtask gen-icons` downloads the Fluent System Icons subset and writes //! `crates/fluent2-icons/src/generated.rs`. +//! - `cargo xtask check-icons` rebuilds that file from the committed SVGs, with +//! no network, and fails when the committed output differs. mod icons; @@ -49,8 +51,18 @@ fn run() -> Result<(), String> { } icons::generate(refresh) } + Some("check-icons") => { + if std::env::args().nth(2).is_some() { + return Err("usage: cargo xtask check-icons".to_string()); + } + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../crates/fluent2-icons/src/generated.rs"); + let before = std::fs::read(&path).map_err(|e| format!("{path:?}: {e}"))?; + icons::generate_with(false, false)?; + unchanged(&path, &before) + } Some(other) => Err(format!("unknown task {other}")), - None => Err("usage: cargo xtask ".to_string()), + None => Err("usage: cargo xtask ".to_string()), } } @@ -617,6 +629,16 @@ fn split_layers(text: &str) -> Vec<&str> { layers } +fn unchanged(path: &std::path::Path, before: &[u8]) -> Result<(), String> { + let after = std::fs::read(path).map_err(|e| format!("{path:?}: {e}"))?; + if after == before { + Ok(()) + } else { + std::fs::write(path, before).map_err(|e| format!("restore {path:?}: {e}"))?; + Err("icon catalogue drifted; run cargo xtask gen-icons and commit crates/fluent2-icons".to_string()) + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {