diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..402f42d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# Code owners are requested for review automatically on every PR. +# A GitHub team (e.g. @SimplifyJobs/mobile) can replace the individual owner +# once one exists — teams survive people changing roles. + +* @rehmatsg + +/.github/ @rehmatsg diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..e5601ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,110 @@ +name: Bug report +description: Something renders, animates, or behaves incorrectly. +title: "[bug]: " +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for the report. If you can reproduce it in the + [live playground](https://simplifyjobs.github.io/flutter_border_beam/), + paste the share link — it carries the exact configuration and saves + everyone a round trip. + + Security issues do **not** belong here — see + [SECURITY.md](https://github.com/SimplifyJobs/flutter_border_beam/blob/main/SECURITY.md). + + - type: textarea + id: description + attributes: + label: What happened + description: A short description of the bug. + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Minimal reproduction + description: >- + The smallest widget tree that shows the problem — ideally one that + drops straight into a fresh `flutter create` app. A playground share + link works too. + render: dart + placeholder: | + BorderBeam.rotate( + colors: BeamColors.ocean, + child: const SizedBox(width: 240, height: 120), + ) + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + + - type: input + id: variant + attributes: + label: Variant(s) and palette + description: Which `BeamVariant` and which `BeamColors` (or custom colors). + placeholder: "rotate + line; BeamColors.colorful" + validations: + required: true + + - type: textarea + id: flutter-version + attributes: + label: "`flutter --version` output" + description: Paste the whole thing — the Dart version and channel matter. + render: shell + validations: + required: true + + - type: input + id: package-version + attributes: + label: flutter_border_beam version + placeholder: "0.1.0" + validations: + required: true + + - type: input + id: platform + attributes: + label: Device / OS + description: >- + Include the web renderer (canvaskit or skwasm) and the browser if this + is on web; include the simulator/emulator vs. physical device + otherwise. + placeholder: "iPhone 16 simulator, iOS 26.0 / Chrome 141 on macOS, canvaskit" + validations: + required: true + + - type: textarea + id: media + attributes: + label: Screenshot or video + description: >- + Strongly encouraged — this package is a visual one, and a five-second + screen recording usually says more than a paragraph. Drag files in. + validations: + required: false + + - type: textarea + id: context + attributes: + label: Anything else + description: Logs, a stack trace, what you already tried. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..17ef9b4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Question or idea + url: https://github.com/SimplifyJobs/flutter_border_beam/discussions + about: Ask how to do something, or float an idea before it becomes a proposal. + - name: Live playground + url: https://simplifyjobs.github.io/flutter_border_beam/ + about: Try variants and palettes in the browser, then share the exact configuration. + - name: Security vulnerability + url: https://github.com/SimplifyJobs/flutter_border_beam/security/advisories/new + about: Report privately. Never in a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..4388223 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,74 @@ +name: Feature request +description: Propose a new variant, option, palette, or API. +title: "[feat]: " +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + This package is a port of the + [border-beam](https://github.com/Jakubantalik/border-beam) React + library. Features that exist upstream are the easiest sell — say so if + yours does, and link the source. Flutter-only additions are welcome + too; they just need to stay out of the parity tables. + + - type: textarea + id: problem + attributes: + label: The problem + description: >- + What are you trying to build, and where does the package get in the + way? Describe the situation, not the solution. + validations: + required: true + + - type: textarea + id: api + attributes: + label: Proposed API + description: >- + A sketch of the call site as you would want to write it. Rough is + fine — the shape is what matters. + render: dart + placeholder: | + BorderBeam.rotate( + style: const BeamStyle(tailLength: 0.4), + child: ..., + ) + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: >- + What can you do today instead, and why is it not enough? Include any + workaround you are currently shipping. + validations: + required: true + + - type: dropdown + id: rendering-defaults + attributes: + label: Does this change rendering defaults? + description: >- + Would an existing `BorderBeam` with no new arguments look or animate + differently? That makes it a breaking visual change, which needs a + much stronger case and regenerated goldens. + options: + - "No — purely additive, defaults unchanged" + - "Yes — existing beams would render differently" + - "Not sure" + validations: + required: true + + - type: textarea + id: context + attributes: + label: Anything else + description: >- + References, a link to the React implementation, screenshots or a + mockup of the effect. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..be1de89 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,56 @@ +## Summary + + + +## Changes + + + +- + +## Testing + + + +- [ ] `dart format .` — clean +- [ ] `flutter analyze --fatal-infos` — zero issues (package and `example/`) +- [ ] `flutter test` — green (`--exclude-tags golden` if you are not on macOS) + +**Goldens** — none regenerated / regenerated: + + + +**saveLayer budget** — unchanged / changed: + + + +## Checklist + +- [ ] No values in `lib/src/constants/` were tweaked (they are verbatim + transcriptions of the React source); Flutter-only additions live in a + clearly headed file +- [ ] New public API has doc comments (`public_member_api_docs` is enforced) +- [ ] Docs and comments describe current behavior — no change-history prose +- [ ] `CHANGELOG.md` updated +- [ ] README updated if this changes what a user reaches for first diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..41a5d27 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +# Weekly dependency updates, grouped so each ecosystem lands as a single PR +# rather than one PR per bump. +version: 2 + +updates: + # GitHub Actions used by ci.yaml and release.yaml. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: + - dependencies + commit-message: + prefix: ci + groups: + github-actions: + patterns: + - "*" + + # Dart/Flutter dependencies for the package and the example gallery. + - package-ecosystem: pub + directories: + - / + - /example + schedule: + interval: weekly + labels: + - dependencies + commit-message: + prefix: chore + groups: + pub: + patterns: + - "*" diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..7babf34 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,41 @@ +# The repository's label set, kept in version control so it is reviewable. +# +# Nothing syncs this automatically yet — applying it means either creating the +# labels by hand or wiring a sync action (e.g. crazy-max/ghaction-github-labeler) +# that reads this file. Until then, treat it as the source of truth a human +# reconciles against. +# +# `dependencies` is the one label that is already load-bearing: dependabot.yml +# applies it to every update PR it opens. + +- name: bug + color: d73a4a + description: Something renders, animates, or behaves incorrectly. + +- name: enhancement + color: a2eeef + description: A new variant, option, palette, or API. + +- name: documentation + color: 0075ca + description: README, doc comments, or the guides in this repo. + +- name: rendering + color: 8a63d2 + description: Painting, geometry, gradients, goldens — anything about the pixels. + +- name: performance + color: fbca04 + description: Frame cost, saveLayer budget, repaint churn, memory. + +- name: good first issue + color: 7057ff + description: Self-contained and well-scoped — a good place to start. + +- name: help wanted + color: 008672 + description: Maintainers would welcome a contributor picking this up. + +- name: dependencies + color: 0366d6 + description: Dependency and GitHub Actions updates (applied by dependabot). diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f3be250..e23c638 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,31 +1,193 @@ +# CI for flutter_border_beam. +# +# Two things about this workflow are deliberate and easy to get wrong: +# +# 1. The Flutter version is PINNED to 3.44.2 rather than tracking `stable`. +# The golden files in `test/` were generated on Flutter 3.44.2 (Dart 3.12) +# on macOS, and rasterization of blurs and gradients shifts between +# Flutter releases. An unpinned `stable` would turn every Flutter release +# into a spurious golden diff. Regenerate the goldens (macOS, +# `flutter test --update-goldens --tags golden`) in the same commit that +# bumps this pin. +# 2. The `min-sdk` job builds and tests on Flutter 3.35.0 — the exact lower +# bound declared by `environment: flutter: ">=3.35.0"` in pubspec.yaml. +# Without it that bound is an untested claim and consumers on the oldest +# supported SDK find the breakage for us. It is a hard gate, not an +# advisory one: no `continue-on-error`. +# +# Jobs are intentionally independent (no `needs:`) so they run in parallel. +# `goldens` is the only macOS runner, and therefore the only expensive one. + name: CI on: push: branches: [main] pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # The pinned SDK. Keep in sync with the goldens and with release.yaml. + FLUTTER_VERSION: 3.44.2 + # The declared lower bound from pubspec.yaml's `environment:` block. + FLUTTER_MIN_VERSION: 3.35.0 jobs: - analyze-and-test: + quality: + name: Format, analyze, test, coverage runs-on: ubuntu-latest + permissions: + contents: read + # Required so codecov/codecov-action can mint a GitHub OIDC token and + # upload tokenlessly (the repo is public, so there is no CODECOV_TOKEN). + id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + - uses: subosito/flutter-action@v2 with: + flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable - - run: flutter pub get - - run: dart format --output=none --set-exit-if-changed . - - run: flutter analyze - # Goldens are pinned to macOS rendering; see the goldens job. - - run: flutter test --exclude-tags golden - - run: dart pub publish --dry-run + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze + run: flutter analyze --fatal-infos + + # Goldens are pinned to macOS rendering — the `goldens` job owns them. + - name: Test with coverage + run: flutter test --exclude-tags golden --coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: coverage/lcov.info + use_oidc: true + # Coverage reporting must never be the reason a PR goes red. + fail_ci_if_error: false + + - name: Validate the publishable archive + run: dart pub publish --dry-run goldens: + name: Goldens (macOS) runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + + - uses: subosito/flutter-action@v2 + with: + # Same pin as `quality`: the goldens encode this exact renderer. + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run golden tests + run: flutter test --tags golden + + - name: Upload golden failures + if: failure() + uses: actions/upload-artifact@v4 + with: + name: golden-failures + path: test/**/failures/** + retention-days: 7 + if-no-files-found: ignore + + min-sdk: + name: Minimum supported Flutter (3.35.0) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_MIN_VERSION }} + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + # `--fatal-infos` is deliberately omitted here: lint rule sets move + # between SDKs, and an info-level diagnostic from an older analyzer is + # not a reason to block. Errors and warnings still fail the job. + - name: Analyze + run: flutter analyze + + - name: Test + run: flutter test --exclude-tags golden + + pana: + name: pub.dev score (pana) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Install pana + run: dart pub global activate pana + + # pana prints the full report to the log, then `--exit-code-threshold N` + # fails the job when MORE than N pub.dev points are lost. 20 is a floor, + # not a target: it leaves room for points only a published package can + # earn (downstream-usage and up-to-date-dependency signals) while still + # catching a real regression — a dropped example, a broken publish + # archive, undocumented public API, an unresolvable dependency. Ratchet + # it down as the package settles; the number should only get smaller. + - name: Run pana + run: dart pub global run pana --no-warning --exit-code-threshold 20 . + + example: + name: Example app + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: subosito/flutter-action@v2 with: + flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable - - run: flutter pub get - - run: flutter test --tags golden + cache: true + + # The example consumes the package by path, so it is the canary for API + # breaks in the gallery, and the web build proves the package carries no + # imports that fail to compile for web. + - name: Install dependencies + working-directory: example + run: flutter pub get + + - name: Analyze + working-directory: example + run: flutter analyze --fatal-infos + + - name: Test + working-directory: example + run: flutter test + + - name: Build web + working-directory: example + run: flutter build web --release diff --git a/.github/workflows/pages.yaml b/.github/workflows/pages.yaml new file mode 100644 index 0000000..76e2514 --- /dev/null +++ b/.github/workflows/pages.yaml @@ -0,0 +1,104 @@ +# Deploy the example gallery to GitHub Pages. +# +# The site is `example/` — the demo gallery and the interactive playground — +# built for web and served at +# +# https://simplifyjobs.github.io/flutter_border_beam/ +# +# That URL is not incidental: `example/lib/src/playground/share_codec.dart` +# hardcodes it as `playgroundSiteUrl` when it builds a share link, so the +# `--base-href` below must stay `/flutter_border_beam/` (the repo name) for +# shared links to resolve. +# +# ── ONE-TIME SETUP THE MAINTAINER MUST DO ──────────────────────────────────── +# +# Repo Settings → Pages → Build and deployment → Source: **GitHub Actions**. +# +# Without that, `actions/configure-pages` fails with "Get Pages site failed" +# and nothing deploys. There is no secret and no branch to create — the +# artifact this workflow uploads is the whole site. +# +# ── WHY THE 404 COPY ───────────────────────────────────────────────────────── +# +# Pages serves static files and has no rewrite rule, so any path that is not a +# real file 404s. The playground puts its state in the URL and the gallery +# uses path-shaped routes (`/pulse`, …), so a deep link would land on the +# Pages 404 page instead of the app. Copying `index.html` to `404.html` makes +# the 404 response *be* the app: Flutter boots, reads the location, and routes +# to it. `#state` fragments survive the redirect for free (fragments are never +# sent to the server). +# +# The Flutter pin matches ci.yaml's FLUTTER_VERSION — the site should be built +# by the same SDK the package is tested on. + +name: Pages + +on: + push: + branches: [main] + paths: + - "example/**" + - "lib/**" + - "pubspec.yaml" + - ".github/workflows/pages.yaml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deployment at a time. Queued rather than cancelled: a half-finished +# deploy that loses its slot would leave the live site on an older build. +concurrency: + group: pages + cancel-in-progress: false + +env: + # Keep in sync with ci.yaml's FLUTTER_VERSION. + FLUTTER_VERSION: 3.44.2 + +jobs: + build: + name: Build the example for web + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Install dependencies + working-directory: example + run: flutter pub get + + # `--base-href` must be the repo name: Pages serves project sites from + # `https://.github.io//`, not from the domain root. + - name: Build web + working-directory: example + run: flutter build web --release --base-href /flutter_border_beam/ + + - name: Add the SPA fallback + working-directory: example + run: cp build/web/index.html build/web/404.html + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: example/build/web + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..0c0e90b --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,195 @@ +# Publish flutter_border_beam to pub.dev and cut a GitHub Release. +# +# Trigger: pushing a `vX.Y.Z` tag (a `-suffix` prerelease tag is allowed). +# +# ── ONE-TIME SETUP THE MAINTAINER MUST DO ──────────────────────────────────── +# +# 1. Publish the FIRST version by hand from a workstation: +# flutter pub publish +# pub.dev can only automate publishing of packages that already exist — +# "Today, you can only automate publishing of existing packages. To create +# a new package, you must publish the first version using `dart pub +# publish`." (https://dart.dev/tools/pub/automated-publishing). Until +# 0.1.0 is on pub.dev there is nothing to attach automated publishing to. +# 2. On pub.dev, open +# https://pub.dev/packages/flutter_border_beam/admin +# → "Automated publishing" → "Enable publishing from GitHub Actions", and +# set: +# Repository: SimplifyJobs/flutter_border_beam +# Tag pattern: v{{version}} +# (You must be an uploader on the package to see the Admin tab.) +# 3. Nothing else. There is no PUB_CREDENTIALS / CREDENTIAL_JSON secret to +# create — authentication is a short-lived GitHub-signed OIDC token, which +# is why this workflow needs `id-token: write`. +# +# ── WHY THIS JOB IS WRITTEN OUT INSTEAD OF CALLING THE REUSABLE WORKFLOW ───── +# +# The sanctioned shortcut is +# uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1 +# but it runs a fixed pipeline (`dart pub get` → dry run → `dart pub publish +# -f`) with no hook for the tag/CHANGELOG verification, the quality gate, or +# the GitHub Release this repo wants. So the steps are inlined here, following +# the same SDK layout that reusable workflow uses — checkout, then +# `dart-lang/setup-dart`, then a Flutter SDK: +# https://github.com/dart-lang/setup-dart/blob/main/.github/workflows/publish.yml +# +# `dart-lang/setup-dart` is the step that mints the credential: it requests a +# GitHub OIDC token for the `https://pub.dev` audience, exports it as +# `PUB_TOKEN`, and runs `dart pub token add https://pub.dev --env-var +# PUB_TOKEN`. That writes the pub client's shared token store, so any later +# pub invocation — including the Flutter SDK's bundled one — is authenticated. +# Per the docs: "The workflow authenticates to pub.dev using a temporary +# GitHub-signed OIDC token, the token is created and configured in the +# `dart-lang/setup-dart` step. To publish to pub.dev, subsequent steps can run +# `dart pub publish --force`." +# (https://dart.dev/tools/pub/automated-publishing) +# +# `subosito/flutter-action` runs after setup-dart so the Flutter SDK's own +# Dart is the one on PATH for the gate and the publish — this package depends +# on the Flutter SDK, which a Dart-only SDK cannot resolve. + +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+*" + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + # Must match ci.yaml's pin. + FLUTTER_VERSION: 3.44.2 + +jobs: + publish: + name: Publish to pub.dev + runs-on: ubuntu-latest + permissions: + # Mints the pub.dev OIDC credential. + id-token: write + # Lets `gh release create` write the GitHub Release. + contents: write + steps: + - uses: actions/checkout@v5 + + # Order matters — see the header. This step configures the pub.dev + # credential; the Flutter SDK installed next provides the toolchain. + - uses: dart-lang/setup-dart@v1 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Verify the tag matches pubspec.yaml + id: version + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + PUBSPEC_VERSION="$(sed -n 's/^version:[[:space:]]*//p' pubspec.yaml \ + | head -n 1 | tr -d "\"' ")" + + if [ -z "$PUBSPEC_VERSION" ]; then + echo "::error::Could not read a 'version:' from pubspec.yaml." + exit 1 + fi + if [ "$VERSION" != "$PUBSPEC_VERSION" ]; then + echo "::error::Tag ${TAG} implies version ${VERSION}, but pubspec.yaml declares ${PUBSPEC_VERSION}. Bump one of them and re-tag." + exit 1 + fi + + { + echo "tag=${TAG}" + echo "version=${VERSION}" + } >> "$GITHUB_OUTPUT" + + if [[ "$VERSION" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + echo "Publishing ${PUBSPEC_VERSION} from tag ${TAG}." + + - name: Extract the CHANGELOG section + id: changelog + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + VERSION_RE="${VERSION//./\\.}" + if ! grep -qE "^## +${VERSION_RE}( |\$)" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## ${VERSION}' heading. Every published version needs release notes." + exit 1 + fi + + NOTES_FILE="${RUNNER_TEMP}/release-notes.md" + # Capture everything between the `## ` heading (optionally + # followed by a date) and the next `## ` heading. + awk -v heading="## ${VERSION}" ' + index($0, "## ") == 1 { + if ($0 == heading || index($0, heading " ") == 1) { + found = 1 + next + } + if (found) { exit } + next + } + found { print } + ' CHANGELOG.md > "$NOTES_FILE" + + if ! grep -q '[^[:space:]]' "$NOTES_FILE"; then + echo "::error::The '## ${VERSION}' section of CHANGELOG.md is empty." + exit 1 + fi + + echo "notes-file=${NOTES_FILE}" >> "$GITHUB_OUTPUT" + echo "--- release notes ---" + cat "$NOTES_FILE" + + - name: Install dependencies + run: flutter pub get + + - name: Verify formatting + run: dart format --set-exit-if-changed --output=none . + + - name: Analyze + run: flutter analyze --fatal-infos + + # Goldens are excluded here: they are pinned to macOS rendering and this + # job runs on ubuntu, where they would fail on renderer differences + # alone. The `goldens` job in ci.yaml (macos-latest) is the golden gate, + # and it runs on every push to `main` — so the commit being tagged has + # already had its goldens verified. + - name: Test + run: flutter test --exclude-tags golden + + - name: Publish to pub.dev + run: flutter pub publish --force + + - name: Create the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.version.outputs.tag }} + NOTES_FILE: ${{ steps.changelog.outputs.notes-file }} + PRERELEASE: ${{ steps.version.outputs.prerelease }} + run: | + set -euo pipefail + if [ "$PRERELEASE" = "true" ]; then + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file "$NOTES_FILE" \ + --prerelease + else + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file "$NOTES_FILE" + fi diff --git a/.github/workflows/upstream_drift.yaml b/.github/workflows/upstream_drift.yaml new file mode 100644 index 0000000..135cf0b --- /dev/null +++ b/.github/workflows/upstream_drift.yaml @@ -0,0 +1,125 @@ +# Upstream drift alarm for flutter_border_beam. +# +# Everything in `lib/src/constants/` is a hand transcription of the React +# border-beam library's `src/styles.ts`. `test/constants/spec_parity_test.dart` +# proves the transcription against a VENDORED copy of upstream's generated +# `spec/beam-spec.json` — which means the parity test can only ever be as +# current as that fixture. This job watches the live upstream and shouts when +# it moves. +# +# It is an ALARM, not a build gate: it never runs on push or pull_request, so +# it can fail without blocking anyone's merge. A failure means "go re-audit the +# constants", never "this PR is broken". +# +# Two independent signals, because they catch different things: +# +# 1. The spec JSON itself (minus its `generated` timestamp, which the +# extractor rewrites on every run whether or not a value changed). +# 2. The sha256 of `src/styles.ts`. The spec is generated FROM styles.ts, so a +# styles change that upstream has not regenerated the spec for would slip +# past signal 1 entirely. +# +# Markdown in the job summary uses `~~~` fences and escaped backticks so the +# shell literals stay free of unquoted backticks (shellcheck SC2016). + +name: Upstream drift + +on: + schedule: + # Mondays, 06:00 UTC. + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: upstream-drift + cancel-in-progress: false + +env: + UPSTREAM_RAW: https://raw.githubusercontent.com/Jakubantalik/Libraries/main/packages/border-beam + +jobs: + compare-vendored-spec-with-upstream: + name: Compare vendored spec with upstream + runs-on: ubuntu-latest + steps: + - name: Check out flutter_border_beam + uses: actions/checkout@v5 + + - name: Fetch upstream spec and styles.ts + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/upstream" + curl -fsSL --retry 3 --retry-delay 5 --retry-all-errors \ + "$UPSTREAM_RAW/spec/beam-spec.json" \ + -o "$RUNNER_TEMP/upstream/beam-spec.json" + curl -fsSL --retry 3 --retry-delay 5 --retry-all-errors \ + "$UPSTREAM_RAW/src/styles.ts" \ + -o "$RUNNER_TEMP/upstream/styles.ts" + jq empty < "$RUNNER_TEMP/upstream/beam-spec.json" + + - name: Diff the spec (ignoring the generated timestamp) + id: spec + run: | + set -euo pipefail + jq -S 'del(.generated)' test/fixtures/beam-spec.json \ + > "$RUNNER_TEMP/ours.json" + jq -S 'del(.generated)' "$RUNNER_TEMP/upstream/beam-spec.json" \ + > "$RUNNER_TEMP/theirs.json" + if diff -u "$RUNNER_TEMP/ours.json" "$RUNNER_TEMP/theirs.json" \ + > "$RUNNER_TEMP/spec.diff"; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Vendored spec matches upstream." + else + echo "changed=true" >> "$GITHUB_OUTPUT" + { + echo "### Upstream \`beam-spec.json\` changed" + echo + echo '~~~diff' + head -c 60000 "$RUNNER_TEMP/spec.diff" + echo + echo '~~~' + } >> "$GITHUB_STEP_SUMMARY" + cat "$RUNNER_TEMP/spec.diff" + fi + + - name: Compare the styles.ts hash + id: styles + run: | + set -euo pipefail + recorded="$(grep '^styles_sha256=' test/fixtures/UPSTREAM | cut -d= -f2)" + current="$(sha256sum "$RUNNER_TEMP/upstream/styles.ts" | cut -d' ' -f1)" + echo "recorded: $recorded" + echo "current: $current" + if [ "$recorded" = "$current" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + { + echo "### Upstream \`src/styles.ts\` changed" + echo + echo "recorded: \`$recorded\`" + echo + echo "current: \`$current\`" + } >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Report + env: + SPEC_CHANGED: ${{ steps.spec.outputs.changed }} + STYLES_CHANGED: ${{ steps.styles.outputs.changed }} + run: | + set -euo pipefail + remedy="re-audit the constants against src/styles.ts, run tool/spec/refresh.sh, then flutter test test/constants/spec_parity_test.dart" + if [ "$SPEC_CHANGED" = 'true' ]; then + echo "::error title=Upstream spec drift::beam-spec.json changed upstream — $remedy" + fi + if [ "$STYLES_CHANGED" = 'true' ]; then + echo "::warning title=Upstream styles.ts drift::styles.ts changed upstream (the spec may not have been regenerated yet) — $remedy" + fi + if [ "$SPEC_CHANGED" = 'true' ] || [ "$STYLES_CHANGED" = 'true' ]; then + exit 1 + fi + echo "No upstream drift." diff --git a/.gitignore b/.gitignore index 3313a58..cb61abb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,10 @@ migrate_working_dir/ .dart_tool/ .flutter-plugins-dependencies /build/ -/coverage/ +coverage/ + +# Golden-test diff output (flutter test writes failures next to the goldens) +test/**/failures/ # Local demo recordings (tool/record_demo.sh) .demos/ diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.pubignore b/.pubignore index 8a23fd1..054d3c1 100644 --- a/.pubignore +++ b/.pubignore @@ -1,7 +1,13 @@ # Repo-only files, excluded from the published pub package. +# `screenshots/` stays in the archive — pub.dev renders the images the +# pubspec's `screenshots:` section points at, so they must ship with the package. assets/ tool/ +# The vendored upstream spec is parity-test input only (123 KB of JSON). +test/fixtures/ CLAUDE.md .demos/ **/.dart_tool/ **/build/ +# Golden reference images (6.9 MB) only matter to the repo's own CI, not to consumers. +test/golden/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ecfbcb..52b220b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,301 @@ # Changelog +All notable changes to this package are documented here, following +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## Unreleased + +This release tracks upstream [border-beam](https://github.com/Jakubantalik/Libraries/tree/main/packages/border-beam) +**1.4.0**; the tables in `lib/src/constants/` are transcribed from that +version's `src/styles.ts`, whose spec still declares `1.3.0` as its visual +baseline. See [doc/parity.md](doc/parity.md) for the deviation list and how +the transcription is verified. + +### Added + +#### API + +- `BorderBeam` has a public generic constructor taking a `BeamVariant`, so a + variant chosen at runtime needs no switch over the five named ones. +- `BorderBeam.overlay` paints a beam with no child of its own, sized by its + parent — drop it into a `Stack` under a `Positioned.fill` and it traces the + stack's bounds. +- `BorderBeamTheme` supplies `BorderBeamThemeData` defaults to the beams below + it, one slot per value object. `of` walks every enclosing scope, depends on + each, and merges them outside-in, so nested themes compose. +- `BeamThemeConfig` is public, with `copyWith`, value equality, and + `BeamThemeConfig.presetFor(variant, brightness)` to start from a preset. +- `BeamVariant.defaultHuePeriod` and `BeamVariant.defaultBloomHuePeriod` + expose the per-variant hue timings the config resolves against. +- `BorderBeam` and `BeamPainter` implement `debugFillProperties`. + +#### Palettes + +- Seven Flutter-only presets: `aurora`, `neon`, `candy`, `ember`, `ice`, + `gold` (hue pinned, full opacity) and `holographic` (built for pairing with + a fast continuous hue drift). They live in `extra_palettes.dart`, separate + from the transcribed tables. +- `BeamColors.custom` takes a `base` palette to distribute over. +- `BeamColors.fromSeed` derives a glow-safe palette from one brand color in + four harmonies (`analogous`, `complementary`, `triadic`, `monochrome`), + lifting derived colors into a readable lightness/saturation band. +- `BeamColors.fromScheme` builds a palette from a Material `ColorScheme`'s + primary, secondary and tertiary roles, dropping near-duplicates. +- `BeamColors.lerp` blends two color choices per blob; `scaleAlpha` dims every + table entry without touching layer opacity. + +#### Shape + +- `BeamShape.all(radius)` is the const path to a uniform radius — it stores + the number instead of building a `BorderRadius`, so a theme or a widget can + hold one in a `const` expression. It compares equal to + `BeamShape.circular(radius)`. +- `BeamShape.edge` moves the line variant's beam to any `BeamEdge` (top, + right, bottom, left). +- `BeamShape.ringOffset` pushes the ring outward or pulls it inward from the + child's bounds. +- `BeamShape.contour` takes a `BeamContour` — an arbitrary closed path for the + beam to travel, replacing the rounded rectangle, with a true normal offset + for its inner ring. `BeamPathContour` wraps a `Path Function(Rect)` with an + explicit equality key. +- `BeamShape.segment` accepts a `BeamSegment` between two `BeamAnchor`s — a + raw perimeter fraction, a position along a `BeamEdge`, or a position through + a `BeamCorner` — and masks the unchanged full-ring animation to that + clockwise span. Presets cover each half and each edge with its adjacent + corner arcs; endpoint feathering is measured in perimeter pixels. +- `BeamShape.wrapCorners` places a segment-free line in border-path space so + its streak bends around the two corner arcs adjacent to its selected edge. + +#### Style + +- `BeamStyle.hueMode` picks the shape of the hue track: `BeamHueMode.pingPong` + swings across ±`hueRange`, `BeamHueMode.continuous` revolves a full 360°. + Traveling variants ping-pong and pulse variants revolve by default. +- `BeamStyle.tailLength` scales the traveling window about its head, and + `BeamStyle.glowSpread` scales how far the bloom and halo layers reach past + the ring. +- `BeamStyle.comet` re-aims the bloom into a halo trailing the rotate and + small beams' head outside the ring; `BeamStyle.sparkle` scatters + deterministic twinkles at that head, at a density of 0–1. +- `BeamStyle.segments` breaks the ring into that many evenly spaced dashes + through a feathered conic mask; null keeps it solid. +- `BeamStyle.innerSizeScale` scales pulse-inside's inner wash: below 1 it + pulls tighter to the border, above 1 it floods further in. The perimeter + ring and the bloom keep their own geometry. +- `BeamStyle.renderScale` (0.25–1) paints the beam at that fraction of the box + and magnifies it back, so a palette authored against a 350×140 card still + reads on a screen-width one. One canvas transform, no extra layer. +- `BeamStyle.pulseOutsideStock` is a ready-made style carrying the upstream + library's stock pulse-outside look — a tighter, dimmer halo — next to this + package's default, which bakes the tuning the original's demo page applies. + `BeamStyle.pulseOutsideTuning` (`BeamPulseOutsideTuning.demo` / `.stock`) + switches the glow geometry alone. + +#### Motion + +- `BeamTiming.cycleGap` rests the traveling beam between sweeps. +- `BeamTiming.direction` runs the beam forward, reversed, or alternating each + cycle (`BeamDirection`); reversed sweeps mirror the asymmetric stop tables + at runtime, so the soft tail still trails the head. +- `BeamTiming.phaseOffset` starts the timeline part way through a cycle. +- `BeamTiming.beamCount` sends several beams around the contour at once, + equally spaced — tiled into one sweep shader for rotate, one traveller per + band for line. +- `BeamTiming.huePeriod`, `bloomHuePeriod`, `breatheFactor`, `spikeFactor`, + and `spike2Factor` expose the timings that were fixed in the phase resolver. +- `BorderBeam.progress` drives the sweep from a value instead of the clock, + turning `rotate` into a progress ring and `line` into a progress bar, with + the clock still running underneath. +- `BorderBeam.follow` eases the sweep toward a normalized point in the box — + critically damped, ~150ms — and hands it back to the clock without a snap. +- `BorderBeam.strengthListenable` and `BorderBeam.speedListenable` drive layer + opacity and playback rate per frame without rebuilding. +- `BeamSync` runs every descendant beam off one shared clock: one ticker, one + timeline, identical phases, with `active` and `speed` on the group. + +#### Playback + +- `BeamPlayback.repeat` stops the beam after a set number of cycles + (`BeamRepeat.forever()`, `.once()`, `.count(n)`), fading out and firing + `onDeactivate` rather than cutting mid-sweep. +- `BeamPlayback.reducedMotion` chooses what happens under + `MediaQuery.disableAnimations`: `staticFrame` (the default), `hide` (no + painter, no ticks), `slow` (quarter speed), or `animate` to ignore the ask. +- `BeamPlayback.pauseWhenOffscreen` (default true) stops the clock while the + beam is more than 256px outside its nearest enclosing `Scrollable`, and + resumes it exactly where it left off — play state, fade, and callbacks + untouched. It does nothing when there is no enclosing scrollable. +- `BeamPlayback.fadeCurve` sets the easing both fade envelopes run on; + `BeamPlayback.cssEase` is the web's `cubic-bezier(0.25, 0.1, 0.25, 1)`. +- `BeamPlayback.debugFrozenAt` pins the beam to one instant of its timeline, + at full opacity, and never starts its clock — deterministic frames for + goldens, docs captures, and design reviews. +- `BorderBeamController.pulse()` and `flash()` are one-shot brightness accents + that ride the fade envelope without touching the timeline. + +#### Widgets + +- `BeamDecoration` paints a beam as a `Decoration`, for dropping into an + existing `Container` or `DecoratedBox` — `foregroundDecoration` for the + variants `BorderBeam` paints over its child, `decoration` for + `pulseOutside`. It takes `brightness` and a `BorderBeamThemeData` as + arguments, since a `BoxPainter` has no `BuildContext`. +- `BeamFocusRing` lights while the wrapped subtree, or a given `FocusNode`, + holds focus, honoring `FocusManager.highlightMode`. +- `BeamHover` lights on hover and steers the sweep to the cursor through + `BorderBeam.follow`, with a hold after exit. +- `BeamPress` lights while a finger is down, with a minimum duration, and + never takes a gesture from its child. + +### Changed + +#### Naming & repository + +- The package is named `flutter_border_beam`; the barrel is + `package:flutter_border_beam/flutter_border_beam.dart`. The pub.dev name + `border_beam` belongs to an unrelated package. +- The canonical repository is , + which `repository`, `issue_tracker`, and `homepage` now point at. +- `LICENSE` leads with the canonical MIT header so GitHub detects it, with the + border-beam third-party notice preserved below. +- The pubspec gains a `screenshots:` section backed by PNGs shipped in + `screenshots/`; `assets/` stays pub-ignored. + +#### API + +- `BorderBeam` takes four value objects instead of a flat parameter list: + `BeamStyle` (colors, theme, filters, layer-opacity hooks, and a + `themeConfig` that replaces the whole variant×brightness preset), + `BeamShape` (per-corner radius, border width, superellipse, edge, ring + offset, contour), `BeamTiming` (cycle, rest, speed, direction, hue periods, + line track factors), and `BeamPlayback` (active, autoPlay, startAfter, + duration, repeat, reduced motion). Every field is nullable and means + *inherit*; `controller`, `onActivate`, and `onDeactivate` stay flat on the + widget. +- `colors`, `active`, and `borderRadius` remain on the widget as shorthands + for `style.colors`, `playback.active`, and a uniform `shape.radius`; a + non-null shorthand wins over the same field in its object. +- Field resolution is one order throughout: flat shorthand, then the value + object on the widget, then `BorderBeamTheme` (inner over outer), then the + variant preset. + +#### Shape + +- Shapes are per-corner: `BeamShape.radius` is a `BorderRadiusGeometry` + resolved against the ambient `Directionality`, clamped per corner the way + `RRect.scaleRadii` clamps. `BeamShape.circular(r)` and `BeamShape.stadium()` + cover the uniform and pill cases; a stadium rounds to half the shortest + side, so a square box comes out a circle. + +#### Motion + +- `BeamTiming.cycleGap` rests the traveling beam between sweeps: the sweep + still takes `cycle`, then the beam parks at the end of its travel while its + fade envelope eases out and back in over `min(0.25s, gap / 2)` at each end. + The pulse variants ignore it. Hue, breathe, and spike tracks keep running + through the gap. +- `BeamTiming.speed` sets the playback rate when no controller is attached, + and leaves the config-cache key entirely — a rate change no longer + re-resolves the config or rebuilds the phase resolver. + +#### Palettes + +- `BeamColors`, `BeamPalette`, `BeamPresetData`, `BeamBlob`, and `LineBlob` + are value types that compare structurally, and `resolve()` memoizes by value + through a bounded LRU, so equal color choices share one `BeamPalette` and a + palette rebuilt inline in `build()` hits the widget's config cache instead + of re-deriving nine gradient tables. +- `BeamBlob` and `LineBlob` sizes are documented as the ellipse **radii** every + painter already treats them as. + +#### Engine + +- The transcribed rotate stop tables, line geometry, and pulse constants moved + into `lib/src/constants/`, with rendering unchanged. +- Every new style and shape option folds into layers each variant already + composites, so the per-variant `saveLayer` budget holds under any + combination. + +### Removed + +- `BeamPlayback.respectReducedMotion`, replaced by + `BeamPlayback.reducedMotion`; `reducedMotion: BeamReducedMotion.animate` is + what `respectReducedMotion: false` used to say. +- The `sprung` dependency. The fade envelope is eased by the in-package + `FadeSpringCurve` (mass 1, stiffness 180, damping 20 — the same spring, + reproduced bit-for-bit with a linear end correction so t=1 lands on 1), so + the package depends on the Flutter SDK alone. +- The example app's boilerplate and its unused `cupertino_icons` dependency. + +### Fixed + +- A beam laid out thinner than twice its border width no longer asserts in + `RRect` construction: `BeamRingGeometry` returns empty contours for empty or + inverted rects, floors corner radii at zero, and paints the whole outer + shape as the ring for sub-2px boxes. +- The reduced-motion static frame keeps the traveling variants' mid-cycle + geometry but no longer samples the hue ping-pong, so it shows the palette's + own colors, as documented. +- Changing `cycle` mid-run rescales the clock so every cycle-derived track + keeps its fraction, while the fixed-period hue tracks are held by a + resolver-side time offset — the beam speeds up or slows down without a snap, + mid-fade included. +- Reduced motion is tracked where its change is delivered: turning it on + pauses the clock, turning it off resumes only a pause it caused (a + controller pause wins) or starts an autoplay beam that never got to run. + `build` no longer mutates the clock. +- `BeamColors.spec` derives its fallback tables directly instead of through a + throwaway custom palette, and the phase resolver resolves `PulseParams` + once. + +### Tooling & CI + +- CI runs five independent jobs: format/analyze/test with coverage to Codecov + (tokenless OIDC) and a publish dry-run on the pinned Flutter the goldens + were rendered with; goldens on macOS with failure diffs uploaded as + artifacts; the declared 3.35.0 lower bound built and tested as a hard gate; + pana with a 20-point floor; and the example app analyzed, tested, and built + for web. +- Pushing a `vX.Y.Z` tag runs `release.yaml`: it checks the tag against the + pubspec and the changelog, re-runs the gate, publishes to pub.dev through + `dart-lang/setup-dart`'s OIDC token, and creates the GitHub Release from + that changelog section. +- The upstream library's machine-readable `spec/beam-spec.json` is vendored at + `test/fixtures/beam-spec.json` (with its provenance in + `test/fixtures/UPSTREAM`), refreshed by `tool/spec/refresh.sh`, and asserted + table by table by `test/constants/spec_parity_test.dart`. + `.github/workflows/upstream_drift.yaml` hashes upstream's `src/styles.ts`, + so a styles change that never reached the spec generator still raises the + alarm. `lib/src/constants/upstream.dart` records the tracked versions and + the source repository in one place. +- Dependabot groups weekly action and pub bumps; `codecov.yml` keeps coverage + informational. +- The example app exposes the whole API in an interactive playground with a + shareable link, and is deployed to GitHub Pages. +- Tests: a counting canvas pins each variant's per-frame `saveLayer` count + (rotate 4, small 3, line 4, pulseInside 4, pulseOutside 3) exactly, with + save/restore balance; lifecycle tests prove no ticker outlives disposal + mid-fade, a variant swap, `TickerMode`, or a controller that outlives its + beam; boundary tests sweep degenerate boxes, radius and width extremes, and + out-of-range parameters; seeded property tests check the phase resolver's + purity, per-cycle periodicity, and field ranges; and the goldens cover every + variant × theme × palette, with a later freeze for the two traveling + variants. + +### Docs + +- The README is rebuilt around a per-concept structure, with a full field + table (default and applicable variants) for each of the four value objects, + and sections for accessibility, performance, upstream parity, and how the + engine works. +- New guides in [`doc/`](doc): variants, palettes, shape, motion, theming, + performance, accessibility, parity, and architecture. +- `CLAUDE.md` keeps the commands and hard rules and points at + [doc/architecture.md](doc/architecture.md) for the module deep-dive. + ## 0.1.0 -Initial release — a faithful Flutter port of the [border-beam](https://github.com/Jakubantalik/border-beam) React library (v1.3.0) by Jakub Antalik. +Initial release — a faithful Flutter port of the [border-beam](https://github.com/Jakubantalik/Libraries/tree/main/packages/border-beam) React library by Jakub Antalik. ### Features @@ -17,4 +310,4 @@ Initial release — a faithful Flutter port of the [border-beam](https://github. - **Playback control**: optional `BorderBeamController` (`start` / `stop` / `pause` / `resume` / `seek` / `speed`) with exclusive ownership, or declarative `active` + `autoPlay` / `startAfter` / `duration` scheduling. - **Theming**: dark / light presets per variant, `BeamTheme.auto` follows `Theme.of(context)`. - **Tuning hooks** ported from the source's CSS custom properties: `strength`, `brightness`, `saturation`, `hueRange`, `hueBase`, `staticColors`, layer opacity factors, and pulse glow overrides (`glowBoost`, `coreBlur`, `bloomBlur`, `glowBrightness`, `glowSaturation`). -- **Accessibility & performance**: honors `MediaQuery.disableAnimations`, pauses under `TickerMode`, spring-eased fades (`sprung`), single-`Ticker` engine with a ~30fps cap for pulse variants, `RepaintBoundary`-isolated painting (the child never re-rasterizes), and CPU hue folding to keep `saveLayer` counts at ≤3 per frame. +- **Accessibility & performance**: honors `MediaQuery.disableAnimations`, pauses under `TickerMode`, spring-eased fades (`sprung`), single-`Ticker` engine with a ~30fps cap for pulse variants, `RepaintBoundary`-isolated painting (the child never re-rasterizes), and CPU hue folding to keep `saveLayer` counts low. diff --git a/CLAUDE.md b/CLAUDE.md index 2e5239f..7d8881e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,8 @@ -# border_beam — agent guide +# flutter_border_beam — agent guide Flutter package: animated border beam effects. A faithful port of the -[border-beam](https://github.com/Jakubantalik/border-beam) React library -(v1.3.0, MIT, by Jakub Antalik). +[border-beam](https://github.com/Jakubantalik/Libraries/tree/main/packages/border-beam) +React library (v1.4.0, MIT, by Jakub Antalik). ## Commands @@ -14,31 +14,47 @@ Flutter package: animated border beam effects. A faithful port of the - `cd example && flutter run` — demo gallery app (React-demo-styled; tokens in `example/lib/src/demo_theme.dart`). - `tool/record_demo.sh --target lib/showcase.dart --prefix SHOWCASE --contact` — record a demo reel mp4 into `.demos/` (needs a booted iOS simulator + ffmpeg). Reels use `example/lib/demo_harness.dart`'s marker contract (`::START/END`, `:DONE`); new reels are just a map of name → scene widget. - `dart pub publish --dry-run` — pre-publish validation. +- **Cutting a release** — bump `version:` in `pubspec.yaml`, add a `## ` section to `CHANGELOG.md`, merge to `main`, then push the matching tag: `git tag v && git push origin v`. `.github/workflows/release.yaml` verifies the tag against the pubspec and the changelog, re-runs format/analyze/test, publishes to pub.dev over GitHub OIDC (no secrets), and creates the GitHub Release from that changelog section; a version with a `-suffix` is marked prerelease. Goldens are verified only by the macOS `goldens` job in `ci.yaml` — the release job runs on ubuntu and skips them, so tag a commit that is green on `main`. +- **One-time, before the first automated release** — publish manually with `flutter pub publish` (pub.dev only automates publishing for packages that already exist), then enable pub.dev Admin → Automated publishing for `SimplifyJobs/flutter_border_beam` with tag pattern `v{{version}}`. ## Architecture Everything animated is a **pure function of elapsed time** — one `Ticker` per -widget (`BeamClock`), mirroring the React library's single shared rAF loop. -No `AnimationController`s; phases are recomputed from `elapsedSeconds` each -frame. - -- `lib/border_beam.dart` — barrel; ONLY `BorderBeam`, `BorderBeamController`, `BeamVariant`, `BeamColors`, `BeamBlob`/`LineBlob`, `BeamTheme` are public. -- `lib/src/border_beam.dart` — widget, 5 named constructors, scheduling/lifecycle. Controller attached ⇒ it owns playback exclusively (asserts `startAfter`/`duration` are null). -- `lib/src/animation/` — `beam_clock.dart` (ticker, speed, pause, spring fades via `sprung`, optional fps cap), `oscillator.dart` (pingPong + the 17-oscillator pulse bank), `beam_phases.dart` (per-frame value object + keyframe sampling). -- `lib/src/painting/` — `beam_painter.dart` (one `CustomPainter`, repaint driven by the clock, `behind`/`above` passes), `strategies/` (one per variant family), `ring_geometry.dart` (rrect + `RSuperellipse` ring via `Path.combine` difference), `gradient_builders.dart`, `color_matrix.dart` (hue/brightness/saturation matrices), `layer_utils.dart`. -- `lib/src/constants/` — **verbatim transcriptions** of the React source's tables (`palettes.dart`, `theme_presets.dart`, `pulse_tables.dart`, `pulse_params.dart`, `line_keyframes.dart`). -- `lib/src/models/` — public/internal data types; `BeamConfig.resolve` mirrors the React component's computed values (per-variant default durations, the line 13° hue cap, mono forcing static colors). +widget (`BeamClock`), or one per `BeamSync` group, mirroring the React +library's single shared rAF loop. No `AnimationController`s; phases are +recomputed from `elapsedSeconds` each frame. + +Full module deep-dive, the frame pipeline, and the invariants in prose: +**[doc/architecture.md](doc/architecture.md)** — update it when the layout +moves. The map below is the index. + +- `lib/flutter_border_beam.dart` — barrel; ONLY `BorderBeam`, `BorderBeamController`, `BorderBeamTheme`/`BorderBeamThemeData`, `BeamSync`, `BeamVariant`, `BeamColors`/`BeamSeedHarmony`, `BeamBlob`/`LineBlob`, `BeamTheme`, `BeamThemeConfig`, the four value objects (`BeamStyle`, `BeamShape`, `BeamTiming`, `BeamPlayback`), the option types from `beam_options.dart`, and the four widgets (`BeamDecoration`, `BeamFocusRing`, `BeamHover`, `BeamPress`) are public. +- `lib/src/border_beam.dart` — widget: a generic constructor taking a `BeamVariant`, `BorderBeam.overlay`, and 5 named ones; three shorthands (`colors`/`active`/`borderRadius`) over the value objects; the driven inputs (`progress`, `follow`, `strengthListenable`, `speedListenable`); and scheduling/lifecycle. Controller attached ⇒ it owns playback exclusively (the resolved playback asserts `startAfter`/`duration` are null, and the controller's `speed` replaces `timing.speed`). `speed` never reaches `BeamConfig` — it is applied to the clock and deliberately kept out of the config-cache key. +- `lib/src/border_beam_theme.dart` — `BorderBeamTheme` (`InheritedWidget`) + `BorderBeamThemeData`. `of` walks every enclosing scope, depends on each, and merges them outside-in so nested themes compose. +- `lib/src/beam_sync.dart` — `BeamSync` + its internal scope: one clock for a whole subtree. The group owns playback; a member's `BorderBeamController` asserts. +- `lib/src/border_beam_controller.dart` — `BorderBeamController`, a `ChangeNotifier` over one clock (`start`/`stop`/`pause`/`resume`/`seek`/`speed`, plus the one-shot `pulse`/`flash` accents). +- `lib/src/animation/` — `beam_clock.dart` (ticker, speed, pause, fade envelopes, optional fps cap, pulse/flash boosts), `spring_curve.dart` (the in-package fade spring: mass 1, stiffness 180, damping 20), `oscillator.dart` (pingPong + the 17-oscillator pulse bank), `beam_phases.dart` (per-frame value object + keyframe sampling). +- `lib/src/painting/` — `beam_painter.dart` (one `CustomPainter`, repaint driven by the clock, `behind`/`above` passes), `variant_strategy.dart` (the strategy interface, incl. `preferredFps`), `strategies/` (`rotate_strategy.dart`, `line_strategy.dart`, `pulse_inner_strategy.dart`, `pulse_outer_strategy.dart`, `pulse_common.dart`), `ring_geometry.dart` (rrect + `RSuperellipse` ring via `Path.combine` difference, contour and ring-offset handling), `gradient_builders.dart`, `color_matrix.dart` (hue/brightness/saturation matrices), `layer_utils.dart`. +- `lib/src/constants/` — **verbatim transcriptions** of the React source's tables (`palettes.dart`, `theme_presets.dart`, `pulse_tables.dart`, `pulse_params.dart`, `pulse_constants.dart`, `line_keyframes.dart`, `line_geometry.dart`, `rotate_stops.dart`), plus `upstream.dart` (internal; the single place `upstreamLibraryVersion` / `upstreamSpecVersion` / `upstreamRepository` live) and `extra_palettes.dart` — the seven Flutter-only presets, which are **not** transcriptions and are safe to edit. `tool/spec/refresh.sh` re-vendors upstream's `spec/beam-spec.json` into `test/fixtures/`; `test/constants/spec_parity_test.dart` asserts every table against it, and `.github/workflows/upstream_drift.yaml` hashes upstream's `src/styles.ts` so a change that never reached the spec generator still raises the alarm. +- `lib/src/models/` — public/internal data types. The four public value objects (`beam_style.dart`, `beam_shape.dart`, `beam_timing.dart`, `beam_playback.dart`) are all-nullable, `const`, with `copyWith`/`merge`/`==`; a null field means *inherit*. `beam_options.dart` holds the small option types those fields take (`BeamHueMode`, `BeamDirection`, `BeamEdge`, `BeamReducedMotion`, `BeamRepeat`, and the `BeamContour`/`BeamPathContour` pair — a contour is a config-cache key, so every implementer overrides `==`/`hashCode`). `beam_colors.dart` is the sealed `BeamColors` hierarchy (preset/custom/seed/scheme/lerp/scaled/spec) with a value-keyed LRU memo; `beam_palette.dart`/`beam_blob.dart` are the resolved tables; `beam_theme.dart`/`beam_theme_config.dart` the brightness selector and per-variant×brightness preset. `BeamConfig.resolve({variant, palette, brightness, style, shape, timing, textDirection})` flattens them into the painter's config and mirrors the React component's computed values (per-variant default durations and radii, the line 13° hue cap, mono forcing static colors); it carries the timing tracks (`gapSeconds`, hue periods, breathe/spike factors) and a per-corner resolved `BorderRadius`, and is value-equal so `BeamPainter.shouldRepaint` compares configs. +- `lib/src/widgets/` — `beam_decoration.dart` (the engine as a `Decoration`; no context, so brightness and `BorderBeamThemeData` are arguments and reduced motion is inert), `beam_focus_ring.dart`, `beam_hover.dart`, `beam_press.dart`. +- **Tests** — `test/models/` (value objects, config resolution, colors, variants), `test/animation/` (clock, spring, oscillators, phases, travel, periods, the seeded property test), `test/painting/` (ring geometry, color matrices, surface features, the saveLayer budget, boundaries, the paint smoke sweep), `test/widget/` (`BorderBeam` itself: lifecycle, updates, shorthands, theming, sync, speed, progress, motion, cycle gap), `test/widgets/` (the four widgets), `test/golden/` (seven golden families), `test/constants/` + `test/fixtures/` (the transcription check). ## Hard rules -1. **Never tweak values in `lib/src/constants/`** — they are transcribed 1:1 from `src/styles.ts` of the React source (clone: `git clone https://github.com/Jakubantalik/border-beam /tmp/border-beam-react`). Visual parity depends on them. If a value looks wrong, verify against the source first. -2. **saveLayer budget: ≤3 per frame per variant.** Hue/brightness/saturation are folded into gradient colors on the CPU (`BeamColorMatrix.transform`) — do not introduce per-layer `ColorFilter` saveLayers. Blur layers are the only place filter saveLayers are allowed. +1. **Never tweak values in `lib/src/constants/`** — they are transcribed 1:1 from `src/styles.ts` of the React source (clone: `git clone https://github.com/Jakubantalik/Libraries /tmp/border-beam-upstream`). Visual parity depends on them, and `test/constants/` asserts them against fixtures extracted from that source. If a value looks wrong, verify against the source first. `extra_palettes.dart` is the exception: it holds the Flutter-only presets and is not a transcription. +2. **saveLayer budget — the measured per-variant table.** One frame (`paintBehind` + `paintAbove`) issues: **rotate 4, small 3, line 4, pulseInside 4, pulseOutside 3**. `test/painting/save_layer_budget_test.dart` measures every variant × brightness × palette × time sample through a counting canvas and asserts the count *exactly* (plus save/restore balance), so a regression and an improvement both fail until the table is updated in both places. Each entry is a layer the variant genuinely composites — inner, stroke, bloom, the mask sub-layer where two masks intersect (rotate/line/pulseInside), and pulse-outside's two behind-child glows. Hue/brightness/saturation are folded into gradient colors on the CPU (`BeamColorMatrix.transform`) — never add a saveLayer just to carry a `ColorFilter`; a filter rides on a layer that exists anyway, and blur is the only filter allowed to justify a layer of its own. 3. **CSS↔Flutter mapping conventions** (see `gradient_builders.dart`): CSS conic gradients start at 12 o'clock — `SweepGradient` needs the −90° rotation baked in; CSS `radial-gradient(ellipse W H ...)` sizes are RADII; CSS `filter: blur(Npx)` maps to sigma = N; gradient fades to `color.withValues(alpha: 0)` (never `Color(0x00000000)`, which lerps through black). 4. Layer opacity is a multiplied chain (fade × preset × mono × hook × strength), clamped at paint time — presets legitimately exceed 1 (line/dark stroke = 1.14). 5. The line variant does NOT use the mono ×0.5 opacity multiplier; its mono treatment is spike attenuation inside the bloom (see `LineStrategy._paintSpikes`). ## Testing conventions -- Golden scenes freeze the fake test clock at t=1.3s (post fade-in, mid-cycle); regenerating on a different OS will produce diffs — keep goldens macOS-generated. +- Goldens come in seven families, one file each: `beam_golden_test.dart` (variant × theme × palette), `palette_golden_test.dart` (the color factories and transforms), `motion_golden_test.dart` (direction, gap, multi-beam, driven progress), `surface_golden_test.dart` (shape: per-corner, stadium, superellipse, ring offset, contours), `research_golden_test.dart` (reference-implementation options: stock pulse-outside, pulse-inside wash, render scale), `segment_golden_test.dart` (partial-perimeter and corner-wrapped line travel), and `widgets_golden_test.dart` (`BeamDecoration` and the three interaction wrappers). A new scene belongs in the family that names its axis; add a file only for an axis none of them names, never one per feature. +- Golden scenes freeze the fake test clock at t=1.3s (post fade-in, mid-cycle) for every variant × theme × palette; the two traveling variants are captured a second time further along their sweep (`rotate_*_colorful_late` at 2.3s, `line_*_colorful_late` at 2.0s — inside the `beam-edge-fade` plateau, so the beam is at full strength). Regenerating on a different OS or Flutter version will produce diffs — keep goldens macOS-generated on the SDK `ci.yaml` pins. - `test/painting/paint_smoke_test.dart` sweeps every variant×theme×palette×shape for "paints without throwing, produces pixels" — extend it when adding paint paths. +- `test/painting/boundary_test.dart` covers degenerate geometry (zero/1px/sliver boxes, radius past the short side, `borderWidth` 0) and out-of-range parameters, and pins `strength: 0` to painting *no* pixels in either pass. +- `test/animation/phase_property_test.dart` property-tests `BeamPhaseResolver.sample` over a seeded random sweep: purity, per-cycle periodicity of the cycle-driven tracks only, and the range of every phase field. +- `test/widget/border_beam_lifecycle_test.dart` asserts no ticker survives disposal mid-fade (per variant), a variant swap, `TickerMode`, or a controller outliving its beam — `tester.binding.transientCallbackCount` must return to 0. - Ticker tests: the first tick after `Ticker.start()` reports elapsed 0 — pump once before pumping durations. +- A test that documents behavior the code does not yet have is marked `skip: 'defect: '` rather than rewritten to match the bug (`testWidgets` only takes a bool, so its reason goes in a comment). diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..291cb95 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at support@simplify.jobs. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..939912d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,227 @@ +# Contributing to flutter_border_beam + +Thanks for helping out. This package is a faithful Flutter port of the +[border-beam](https://github.com/Jakubantalik/border-beam) React library, and +most of its rules exist to keep that port honest. Read the two hard rules +below ([constants](#the-constants-rule) and the +[saveLayer budget](#the-savelayer-budget)) before changing anything under +`lib/src/constants/` or `lib/src/painting/` — they are what a reviewer will +check first. + +## Setup + +You need **Flutter ≥ 3.35** (the lower bound declared in `pubspec.yaml`; CI +tests that exact version in its `min-sdk` job) and, for goldens, **Flutter +3.44.2 on macOS**. + +```bash +git clone https://github.com/SimplifyJobs/flutter_border_beam.git +cd flutter_border_beam +flutter pub get + +# The demo gallery + interactive playground. It consumes the package by path, +# so it picks up your edits with a hot reload. +cd example && flutter pub get && flutter run +``` + +The gallery is also live at + — useful for comparing +your build against `main`. + +## Commands + +```bash +dart format . # formatting (CI runs --set-exit-if-changed) +flutter analyze --fatal-infos # must be ZERO issues +flutter test # everything, goldens included +flutter test --exclude-tags golden # everything but goldens (non-macOS) +flutter test --tags golden # goldens only (macOS) +dart pub publish --dry-run # archive validation +cd example && flutter analyze && flutter test # the gallery is a CI gate too +``` + +`flutter analyze` must report **zero** issues, infos included. The lint set +turns on `public_member_api_docs`, so every new public member — class, field, +enum value, constructor — needs a doc comment. That is not busywork: it is a +scored pub.dev signal, and CI's `pana` job fails when the score drops. + +### Goldens + +Golden files are **macOS-generated and pinned to Flutter 3.44.2**. Blur and +gradient rasterization shifts between Flutter releases and between platforms, +so a golden regenerated anywhere else will produce a diff that has nothing to +do with your change. CI runs them in a single `macos-latest` job. + +Regenerate **only the scenes you added**, by name: + +```bash +flutter test --update-goldens --tags golden --plain-name 'rotate dark aurora' +``` + +Never run a blanket `flutter test --update-goldens`. Rewriting existing +goldens is a rendering change, and a PR that does it must say in its +description **which** goldens changed and **why the pixels legitimately +moved** — a new blur radius, a corrected gradient stop, a Flutter pin bump. +"The test was failing" is not a justification; that is the test doing its job. +If you must bump the Flutter pin, do it in the same commit as the regenerated +goldens and update `FLUTTER_VERSION` in `.github/workflows/ci.yaml`, +`.github/workflows/release.yaml`, and `.github/workflows/pages.yaml` together. + +Golden failures in CI upload as a `golden-failures` artifact — download it to +see the actual/expected/diff triple. + +## The constants rule + +**Everything in `lib/src/constants/` is a verbatim transcription of the React +source's `src/styles.ts`. Never tweak a value there to make something look +better.** Visual parity with the React library is the whole point of the port; +these tables are the parity. If a number looks wrong, check the source first: + +```bash +git clone https://github.com/Jakubantalik/border-beam /tmp/border-beam-react +``` + +That covers `palettes.dart`, `theme_presets.dart`, `pulse_tables.dart`, +`pulse_params.dart`, and `line_keyframes.dart`. A change to any of them needs +a source citation in the PR description. + +Flutter-only additions are fine, but they live in **clearly headed separate +files** that say up front they are not transcriptions — `extra_palettes.dart` +is the model. Do not mix an original value into a transcription table. + +## The saveLayer budget + +`saveLayer` is the expensive primitive in this package and the best single +proxy for the cost of a frame. One frame (`paintBehind` + `paintAbove`) is +budgeted per variant: + +| variant | layers | what they are | +| --- | --- | --- | +| `rotate` | 4 | inner + inner mask + stroke + blurred bloom | +| `small` | 3 | inner + stroke + blurred bloom (single-mask inner) | +| `line` | 4 | inner + inner mask + stroke + blurred bloom | +| `pulseInside` | 4 | inner + inner mask + stroke + blurred bloom | +| `pulseOutside` | 3 | behind: core glow + bloom halo; above: stroke | + +`test/painting/save_layer_budget_test.dart` measures every variant × brightness +× palette × time sample through a counting canvas and asserts the count +**exactly**, plus save/restore balance. A regression *and* an improvement both +fail until the table is updated in the test and in `CLAUDE.md`. + +So: a new option must fold into a layer that already exists. If it genuinely +cannot, add a budget row and justify it in the PR — say which layer, why it is +unavoidable, and what you tried instead. Two things are never a justification: + +- A `ColorFilter`. Hue, brightness, and saturation are folded into gradient + colors on the CPU (`BeamColorMatrix.transform`). A filter may ride on a + layer that exists anyway; it never earns one. +- Convenience. Blur is the only effect allowed to justify a layer of its own. + +## Where things go + +**Adding a variant** — `lib/src/models/beam_variant.dart` (the enum value, its +doc comment, and its default cycle duration), a strategy in +`lib/src/painting/strategies/` implementing `BeamVariantStrategy`, the +dispatch map at the top of `lib/src/painting/beam_painter.dart`, a named +constructor on `BorderBeam` in `lib/src/border_beam.dart`, any per-variant +defaults in `lib/src/models/beam_config.dart` — plus a budget row, a golden +scene, and a paint-smoke case. + +**Adding an option** — declare it on the value object it belongs to +(`beam_style.dart`, `beam_shape.dart`, `beam_timing.dart`, +`beam_playback.dart`), extending `copyWith`/`merge`/`==`/`hashCode`. A null +field means *inherit*, so the default must be null. Small option types live in +`beam_options.dart`. Resolve it in `BeamConfig.resolve` and consume it in the +strategy. Value equality is load-bearing: `BeamPainter.shouldRepaint` compares +configs, so an option that does not participate in `==` will silently fail to +repaint. + +**Adding a palette** — source colors in +`lib/src/constants/extra_palettes.dart`, exposed as a `static const BeamColors` +on `BeamColors` in `lib/src/models/beam_colors.dart`, plus a palette golden and +a playground entry (`example/lib/src/playground/`). + +Anything public also needs a doc comment, a `CHANGELOG.md` entry, and a README +mention if it changes what a user would reach for first. + +## Tests + +Tests are grouped by what they protect. Put a new test with its family: + +| directory | covers | +| --- | --- | +| `test/models/` | value objects, `BeamConfig.resolve`, palettes, equality | +| `test/widget/` | `BorderBeam` behavior — lifecycle, theming, shorthands, speed, sync | +| `test/widgets/` | the surface widgets (`BeamDecoration`, `BeamFocusRing`, `BeamHover`, `BeamPress`) | +| `test/painting/` | paint smoke, boundary geometry, ring geometry, color matrix, the saveLayer budget | +| `test/animation/` | the clock, oscillators, phase sampling, spring curves | +| `test/golden/*_golden_test.dart` | pixel scenes (macOS only, `@Tags(['golden'])`) | + +Two conventions worth knowing: + +- The first tick after `Ticker.start()` reports elapsed 0 — pump once before + pumping durations. +- A test that documents behavior the code does not have yet is marked + `skip: 'defect: '`, not rewritten to match the bug. (`testWidgets` + only takes a bool, so the reason goes in a comment beside it.) + +Every bug fix ships with a regression test. + +## Writing prose + +Docs and code comments **describe how the code works now**. No change-history +prose — no "no longer", "was removed", "previously", "this used to". Git +carries the history; a comment that narrates a change goes stale the moment +someone reads it out of context. Describe the current behavior and why it is +that way. + +## Commits and PRs + +Conventional Commits, for both commit subjects and PR titles: + +``` +(): +``` + +Lowercase type (`feat`, `fix`, `refactor`, `docs`, `chore`, `ci`, `test`, +`perf`), an optional short scope (`painting`, `motion`, `colors`, `widgets`, +`api`), imperative summary under 72 characters. For example: + +``` +feat(painting): add a comet tail to the rotate strategy +fix(motion): keep the line beam inside its edge-fade plateau +``` + +Before opening a PR: + +- [ ] `dart format .` — clean +- [ ] `flutter analyze --fatal-infos` — zero issues (package **and** `example/`) +- [ ] `flutter test` — green (say if you could not run goldens) +- [ ] Goldens: none regenerated, or the PR names which and why +- [ ] saveLayer budget: unchanged, or the new rows are justified +- [ ] Public API is documented, and the README covers anything user-facing +- [ ] `CHANGELOG.md` has an entry under the unreleased version + +The PR template asks for exactly this. Open the PR as a draft while it is in +progress. + +## Releases + +Maintainers only. The recipe lives in [CLAUDE.md](CLAUDE.md) under "Cutting a +release": bump `version:` in `pubspec.yaml`, add a matching `## ` +section to `CHANGELOG.md`, merge to `main`, then push the tag: + +```bash +git tag v0.2.0 && git push origin v0.2.0 +``` + +`.github/workflows/release.yaml` verifies the tag against the pubspec and the +changelog, re-runs format/analyze/test, publishes to pub.dev over GitHub OIDC +(no secrets), and cuts the GitHub Release from that changelog section. Goldens +are verified by the macOS `goldens` job in `ci.yaml`, not by the release job — +so only tag a commit that is green on `main`. + +## Code of Conduct + +By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). +Security issues go through [SECURITY.md](SECURITY.md), not the issue tracker. diff --git a/LICENSE b/LICENSE index 03f3c9f..200c6e9 100644 --- a/LICENSE +++ b/LICENSE @@ -2,11 +2,6 @@ MIT License Copyright (c) 2026 Rehmat Singh Gill -This package is a Flutter port of the "border-beam" React library: -Copyright (c) 2026 Jakub Antalik (https://github.com/Jakubantalik/border-beam), -released under the MIT License. All visual designs, color palettes, gradient -geometry, and animation timings are derived from that work. - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights @@ -24,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +------------------------------------------------------------------------------- + +Third-party notice + +This package is a Flutter port of the "border-beam" React library: +Copyright (c) 2026 Jakub Antalik (https://github.com/Jakubantalik/border-beam), +released under the MIT License. All visual designs, color palettes, gradient +geometry, and animation timings are derived from that work. diff --git a/README.md b/README.md index d3e62dc..6cf346f 100644 --- a/README.md +++ b/README.md @@ -1,194 +1,749 @@ -# border_beam +
+

flutter_border_beam

+

+ A traveling or breathing glow around any widget — cards, buttons, inputs, search bars. +

+
-Animated border beam effects for Flutter — a traveling or breathing glow around any widget: cards, buttons, inputs, or search bars. +

+ pub package + pub points + Flutter 3.35+ + Dart 3.9+ + CI + codecov + License: MIT +

-A faithful Flutter port of the [border-beam](https://github.com/Jakubantalik/border-beam) React library by [Jakub Antalik](https://x.com/jakubantalik) ([live demo](https://beam.jakubantalik.com)). Every palette, gradient, mask, blur, and timing curve is transcribed from the original source, so the effects are pixel-matched — with Flutter-native additions on top: superellipse (squircle) borders, custom color palettes, a playback controller, and spring-eased fades. +
+ The five beam variants animating in the example gallery +
-## Showcase + +## Overview + +A faithful Flutter port of the [border-beam](https://github.com/Jakubantalik/Libraries/tree/main/packages/border-beam) React library by [Jakub Antalik](https://x.com/jakubantalik) ([live demo](https://beam.jakubantalik.com)). Every palette, gradient, mask, blur, and timing constant is transcribed from the original source, so the effects are pixel-matched — with Flutter-native additions on top. + +**Five variants**, each a named constructor with tuned defaults: + +- **`rotate`** — a beam travels the full border. Cards, surfaces, panels. +- **`small`** — the compact cut, on a 32px radius. Icon buttons, chips. +- **`line`** — a streak rides one edge, with a bloom and spikes. Inputs, search bars. +- **`pulseInside`** — a contained glow breathing inward from the border. +- **`pulseOutside`** — a halo blooming outward, behind the child.
- Rotate beam
+ Rotate beam
Rotate — a beam travels around the border
- Pulse outside halo
+ Pulse outside halo
Pulse outside — a breathing halo blooms behind the child
- Line beam
+ Line beam
Line — the beam rides the bottom edge
- Squircle beam
+ Squircle beam
Superellipse — Apple-style squircle borders, ocean palette
-Run the example app for the full animated gallery (`cd example && flutter run`); demo videos are recorded with `tool/record_demo.sh` (see [Recording demos](#recording-demos)). +And on top of the effects themselves: -## Highlights +- **Eleven palettes** — the four from the original plus seven Flutter-only ones, with `custom`, `fromSeed` harmonies, `fromScheme`, `lerp`, `scaleAlpha`, and per-blob `spec`. +- **Value-object API** — `BeamStyle`, `BeamShape`, `BeamTiming`, `BeamPlayback`; every field nullable, so anything you leave out is inherited from a `BorderBeamTheme` or the variant default. +- **Shapes** — per-corner direction-aware radii, stadium, Apple-style superellipse, ring offset, arbitrary path contours, partial segments, and corner-wrapping lines. +- **Motion** — cycle gap, forward / reverse / bounce, phase offset, several beams on one contour, driven `progress`, pointer `follow`. +- **Synchronized groups** — `BeamSync` puts a whole subtree on one ticker and one timeline, so a row of cards reads as one system. +- **Surfaces & interactions** — `BorderBeam.overlay`, a `BeamDecoration` for existing `Container`s, and `BeamFocusRing` / `BeamHover` / `BeamPress`. +- **Accessible** — four documented reduced-motion behaviors; beams add no semantics and never intercept a pointer. +- **Fast** — one ticker per beam, a ~30fps cap on the pulse variants, `RepaintBoundary` isolation, CPU-folded color matrices, an offscreen pause, and a `saveLayer` budget pinned by a test. -- **Five variants**, each a named constructor with tuned defaults: `rotate`, `small`, `line`, `pulseInside`, `pulseOutside`. -- **Four palettes** from the original (`colorful`, `mono`, `ocean`, `sunset`), plus `BeamColors.custom(...)` for your own colors and `BeamColors.spec(...)` for per-blob control. -- **Superellipse borders** — one flag switches the beam to an Apple-style squircle. -- **Playback control** — declarative `active` toggling with spring-eased fades, `autoPlay`/`startAfter`/`duration` scheduling, or a full `BorderBeamController`. -- **Dark & light themes** per variant, following your app theme automatically. -- **Decorative-only** — beam layers never intercept pointers; your child lays out and hit-tests normally. -- **Performance-minded** — one `Ticker` per beam, ~30fps cap for pulse variants, `RepaintBoundary`-isolated painting (your child never re-rasterizes), CPU-folded color filters (≤3 `saveLayer`s per frame). -- **Accessible** — honors `MediaQuery.disableAnimations` out of the box. +### Live playground -## Requirements +**** — every variant, palette, and tuning hook on live controls, with the matching Dart snippet and a shareable link. Built from [`example/`](example). -- Flutter ≥ 3.35 (stable `RoundedSuperellipseBorder` / `RSuperellipse`) -- Dart ≥ 3.9 -## Install +## Installation -```yaml -dependencies: - border_beam: ^0.1.0 +```console +flutter pub add flutter_border_beam ``` +```dart +import 'package:flutter_border_beam/flutter_border_beam.dart'; +``` + +| Requirement | Version | +| --- | --- | +| Flutter | ≥ 3.35 (stable `RoundedSuperellipseBorder` / `RSuperellipse`) | +| Dart | ≥ 3.9 | +| Dependencies | none beyond the Flutter SDK | + +> Tracks upstream **border-beam 1.4.0**. Where this package deliberately differs from the original, and why, is spelled out in [Parity with the original](#parity-with-the-original). + + ## Usage -Wrap any widget: +### Quick start ```dart -import 'package:border_beam/border_beam.dart'; - BorderBeam.rotate( - child: Card(child: content), + borderRadius: 16, + colors: BeamColors.ocean, + child: Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: const Padding( + padding: EdgeInsets.all(24), + child: Text('Working on it…'), + ), + ), +) +``` + +That is the whole API for the common case. Everything else is a field on one of four value objects — `style`, `shape`, `timing`, `playback` — and every one of their fields is nullable, meaning *inherit*. + +```dart +BorderBeam( + variant: BeamVariant.line, + style: const BeamStyle(colors: BeamColors.sunset, strength: 0.8), + shape: const BeamShape.all(24, superellipse: true), + timing: const BeamTiming(cycleGap: Duration(seconds: 1)), + playback: const BeamPlayback(reducedMotion: BeamReducedMotion.staticFrame), + child: card, ) ``` ### Variants ```dart -BorderBeam.rotate(child: card) // full border beam — cards, surfaces -BorderBeam.small(child: iconButton) // compact — buttons, chips (32px radius) -BorderBeam.line(child: searchBar) // travels the bottom edge — inputs -BorderBeam.pulseInside(child: card) // contained breathing glow -BorderBeam.pulseOutside(child: card) // halo blooming outward behind the child +BorderBeam.rotate(child: card); // full border beam — cards, surfaces +BorderBeam.small(child: iconButton); // compact — buttons, chips (32px radius) +BorderBeam.line(child: searchBar); // travels one edge — inputs +BorderBeam.pulseInside(child: card); // contained breathing glow +BorderBeam.pulseOutside(child: card); // halo blooming outward behind the child + +// Picked at runtime — same parameters, no switch: +BorderBeam(variant: variant, child: card); ``` -> **pulse-outside contract** (same as the original): the child must be opaque -> so only the outward spill shows, should have its own 1px border for a -> defined idle edge, and needs clip-free room around it — padding in the -> parent, no tight `ClipRRect`. +| Variant | Upstream name | Family | Default cycle | Default radius | Default hue track | Paints | +| --- | --- | --- | --- | --- | --- | --- | +| `rotate` | `md` | traveling | 1.96s | 16 | ping-pong, 12s | over the child | +| `small` | `sm` | traveling | 1.96s | 32 | ping-pong, 12s | over the child | +| `line` | `line` | traveling | 3.1s | 16 | ping-pong, 12s (bloom 8s) | over the child | +| `pulseInside` | `pulse-inner` | pulse | 2.3s | 16 | continuous, 16s | over the child | +| `pulseOutside` | `pulse-outside` | pulse | 2.3s | 16 | continuous, 14s | behind and outside the child | -### Colors +The two families differ in more than looks: the traveling variants sweep a window around the contour and respond to `direction`, `cycleGap`, `beamCount`, `progress`, and `follow`; the pulse variants breathe from a bank of oscillators and ignore all of those. + +> ⚠️ **pulse-outside contract** (the same one the original states): the child must be **opaque**, so only the outward spill shows; it should carry its **own 1px border** for a defined idle edge; and it needs **clip-free room** around it — padding in the parent, no tight `ClipRRect`. A halo with nowhere to bloom is a halo you cannot see. + +[Full documentation →](doc/variants.md) + +### Palettes ```dart // Presets -BorderBeam.rotate(colors: BeamColors.ocean, child: card) // also: colorful (default), mono, sunset +BorderBeam.rotate(colors: BeamColors.ocean, child: card); // Your own colors, distributed over the preset blob geometry BorderBeam.rotate( - colors: BeamColors.custom([Colors.pink, Colors.cyan, Colors.amber]), + colors: const BeamColors.custom([Colors.pink, Colors.cyan, Colors.amber]), child: card, -) +); -// Advanced: position every blob yourself +// One brand color, spread into a harmony BorderBeam.rotate( - colors: BeamColors.spec(border: [ - BeamBlob(color: Colors.pink, position: Offset(0.3, 0), size: Size(70, 40)), - // ... - ]), + colors: const BeamColors.fromSeed( + Color(0xFF18A8F0), + harmony: BeamSeedHarmony.triadic, + ), child: card, -) +); + +// The app's Material scheme +BorderBeam.rotate( + colors: BeamColors.fromScheme(Theme.of(context).colorScheme), + child: card, +); + +// Blend two palettes, and dim one +BorderBeam.rotate( + colors: const BeamColors.lerp(BeamColors.ocean, BeamColors.sunset, 0.35), + child: card, +); +BorderBeam.rotate(colors: BeamColors.ember.scaleAlpha(0.6), child: card); ``` -`mono` disables the hue animation and halves layer opacity, exactly like the source. +| Preset | Origin | Colors | Notes | +| --- | --- | --- | --- | +| `colorful` | upstream | rainbow spectrum | the default | +| `mono` | upstream | grayscale | pins the hue and halves layer opacity | +| `ocean` | upstream | blue, purple | | +| `sunset` | upstream | orange, yellow, red | | +| `aurora` | Flutter-only | teal, violet, green, glacier blue | | +| `neon` | Flutter-only | magenta, cyan, lime | the loudest | +| `candy` | Flutter-only | pink, lavender, peach | pastel | +| `ember` | Flutter-only | deep red, orange, gold | | +| `ice` | Flutter-only | pale blue, white, cyan | | +| `gold` | Flutter-only | amber, gold, bronze | pins the hue, keeps full opacity | +| `holographic` | Flutter-only | desaturated pastels | built to be paired with a fast continuous hue drift | + +Every `BeamColors` is a value type and resolution is memoized by value, so rebuilding `BeamColors.custom([...])` inline in `build` does not re-resolve the gradient tables. + +[Full documentation →](doc/palettes.md) ### Shape ```dart +// Uniform radius, const BorderBeam.rotate( - borderRadius: 24, // match your child's decoration radius - useSuperellipse: true, // Apple-style squircle contour + shape: const BeamShape.all(24, superellipse: true), child: card, -) +); + +// Per-corner, direction-aware +BorderBeam.rotate( + shape: const BeamShape( + radius: BorderRadiusDirectional.only(topStart: Radius.circular(28)), + ), + child: card, +); + +// A pill (a circle on a square box) that tracks the box as it resizes +BorderBeam.small(shape: const BeamShape.stadium(), child: chip); + +// The ring 8px outside the child, with the beam on the top edge +BorderBeam.line( + shape: const BeamShape.all(16, ringOffset: 8, edge: BeamEdge.top), + child: card, +); + +// An arbitrary contour +BorderBeam.rotate( + shape: BeamShape( + contour: BeamPathContour((rect) => Path()..addOval(rect), key: 'oval'), + ), + child: card, +); +``` + +Three constructors cover the common cases: `BeamShape.all(r)` is the **const** path to a uniform radius (it stores the number rather than building a `BorderRadius`), `BeamShape.circular(r)` is the same shape when you already think in `BorderRadius` terms (**not** const), and `BeamShape.stadium()` rounds each corner to half the shortest side. `radius` resolves against the ambient `Directionality` and clamps per corner the way `RRect.scaleRadii` does. + +There is **no auto-detection of the child's radius** — pass the same value your child uses. + +[Full documentation →](doc/shape.md) + +### Segments and partial contours + +`BeamShape.segment` masks the unchanged full-ring animation to a clockwise portion of the contour. Constants and blob positions stay in full-ring space, and the mask adds no compositing layer. + +```dart +BorderBeam.rotate( + shape: const BeamShape( + radius: BorderRadius.all(Radius.circular(24)), + segment: BeamSegment.bottomHalf, + ), + child: square, +); +``` + +Positions are arc-length fractions from top-center, clockwise: `0` is top-center, `0.25` approximately right-center on a square, `0.5` bottom-center, `0.75` approximately left-center. Build endpoints with `BeamAnchor.fraction(t)`, `BeamAnchor.edge(edge, t)`, or `BeamAnchor.corner(corner, t)`, or use `topCenter` / `rightCenter` / `bottomCenter` / `leftCenter`. `BeamSegment(start: …, end: …, feather: 32)` runs clockwise from start to end, wrapping through top-center when necessary; `feather` is the fade distance in logical pixels at each end, and `0` makes a hard cut. + +| Preset | Coverage | +| --- | --- | +| `bottomHalf` / `topHalf` | The lower / upper half between the side centers | +| `leftHalf` / `rightHalf` | The left / right half between the top and bottom centers | +| `bottomEdge` / `topEdge` | The named straight run plus both adjacent corner arcs | +| `leftEdge` / `rightEdge` | The named straight run plus both adjacent corner arcs | + +Each family reads a segment differently: **`rotate` / `small`** keep the conic window's full sweep, appearing at `start` and traveling to `end`; **`line`** rides the segment, rounding its corners and fading at both ends (`shape.edge` is ignored while a segment is set); **pulse** hides blobs outside the segment and fades those near either end. + +For two counter-sweeping arcs, stack a second beam with `timing: const BeamTiming(direction: BeamDirection.reverse)`. For a full-contour line, `wrapCorners: true` bends the streak around the two corner arcs adjacent to `edge`. + +### Style + +```dart +BorderBeam.rotate( + style: const BeamStyle( + colors: BeamColors.aurora, + strength: 0.8, + comet: true, + sparkle: 0.4, + tailLength: 1.4, + ), + child: card, +); ``` -`borderRadius` defaults to the variant preset (16, or 32 for `small`). There is no auto-detection of the child's radius — pass the same value your child uses. +Twenty-six fields, all listed in the [`BeamStyle` reference](#beamstyle). Four are worth a sentence each. -### Scheduling (no controller) +**`themeConfig`** replaces the whole variant × brightness preset — layer opacities, inset shadow, and the default brightness/saturation. Start from a preset rather than inventing one: + +```dart +BorderBeam.rotate( + style: BeamStyle( + themeConfig: BeamThemeConfig.presetFor( + BeamVariant.rotate, + Brightness.dark, + ).copyWith(bloomOpacity: 0.4), + ), + child: card, +); +``` + +**`innerSizeScale`** multiplies the size of `pulseInside`'s inner blobs. Below 1 the wash pulls tighter to the border, leaving more of the child clear; above 1 it floods further in. The perimeter ring and the bloom keep their own geometry, so the border itself does not move. + +**`renderScale`** is the fraction of the box the beam is *painted* at before being magnified back to fill it (0.25–1). The palettes are authored against a 350×140 card, so on a screen-width box the blobs read as small and sparse; painting at 0.5 and magnifying restores the proportions the palette was drawn for — glow, blurs, and corner radii all grow together. It costs one canvas transform and no extra layer, but it *is* a magnification, so the ring's own edge softens as the factor drops. + +```dart +BorderBeam.rotate( + style: const BeamStyle(renderScale: 0.35), + child: fullScreenSurface, +); +``` + +**`BeamStyle.pulseOutsideStock`** is a ready-made style carrying the upstream library's **stock** pulse-outside look — a tighter, dimmer halo sitting closer to the child. `BorderBeam.pulseOutside` paints the tuned demo recipe by default, because that is the look the library is known for; the stock style rolls every part of that tuning back. Layer your own fields over it with `copyWith` — anything you set wins. + +```dart +BorderBeam.pulseOutside(style: BeamStyle.pulseOutsideStock, child: card); +``` + +### Motion + +```dart +BorderBeam.rotate( + timing: const BeamTiming( + cycle: Duration(seconds: 3), // one sweep + cycleGap: Duration(seconds: 1), // rest between sweeps + direction: BeamDirection.bounce, // alternate each cycle + beamCount: 3, // three beams, equally spaced + speed: 1.5, // playback rate + ), + child: card, +); +``` + +Changing `cycle` while the beam runs **retimes it in place** — every track keeps its phase, so the beam speeds up or slows down without a jump. `cycleGap` parks the beam at the end of its travel between sweeps, fading out and back in over `min(0.25s, gap / 2)` at each end; the hue, breathe, and spike tracks are textures rather than the sweep, so they keep running through the gap. Every field is in the [`BeamTiming` reference](#beamtiming). + +[Full documentation →](doc/motion.md) + +### Playback ```dart BorderBeam.pulseInside( - active: isLoading, // fades in 0.6s / out 0.5s (spring-eased) - startAfter: Duration(milliseconds: 500), // delay before autoplay - duration: Duration(seconds: 10), // total play time; null = loop forever - onActivate: () => print('visible'), // fires when the fade-in completes - onDeactivate: () => print('hidden'), + active: isLoading, // fades in 0.6s / out 0.5s + playback: const BeamPlayback( + startAfter: Duration(milliseconds: 500), + duration: Duration(seconds: 10), + repeat: BeamRepeat.count(3), + reducedMotion: BeamReducedMotion.staticFrame, + ), + onActivate: () => debugPrint('visible'), // when the fade-in completes + onDeactivate: () => debugPrint('hidden'), child: card, -) +); +``` + +`reducedMotion` has four behaviors, all of them applying to all five variants: + +| `BeamReducedMotion` | Behavior | +| --- | --- | +| `staticFrame` | Paints a single static frame and stops ticking. **The default.** | +| `hide` | Paints nothing; the child is left bare and no ticker runs. | +| `slow` | Keeps animating at a quarter of the configured speed. | +| `animate` | Ignores the request and animates normally. | + +`debugFrozenAt` pins the beam to one instant and never starts its clock — for screenshots and tests rather than product code. Every animated value is a pure function of elapsed time, so a fixed time is a fixed frame: two runs a week apart paint the same pixels. + +`fadeCurve` swaps the fade envelope. The package eases fades with a spring, which carries a little momentum and overshoots slightly; `BeamPlayback.cssEase` is the web's `cubic-bezier(0.25, 0.1, 0.25, 1)`, for a fade that matches the original exactly. + +The full field list is in the [`BeamPlayback` reference](#beamplayback). + + +## Advanced + +### Theming + +```dart +BorderBeamTheme( + data: const BorderBeamThemeData( + style: BeamStyle(colors: BeamColors.ocean, strength: 0.8), + shape: BeamShape.all(20, superellipse: true), + timing: BeamTiming(cycle: Duration(seconds: 3)), + playback: BeamPlayback(reducedMotion: BeamReducedMotion.slow), + ), + child: MaterialApp(home: home), +); ``` +`BorderBeamThemeData` has one slot per value object, and each slot's fields are nullable, so a theme fills in only what it sets. `BorderBeamTheme.of` walks **every** enclosing scope, depends on each, and merges them outside-in — an inner theme overrides just the fields it names, and a change to an outer one rebuilds the beam exactly as an inner one does. + +[Full documentation →](doc/theming.md) + ### Controller -Attach a `BorderBeamController` for programmatic playback. The controller takes **full ownership** — `startAfter`/`duration` must not be set, and the beam starts hidden until you `start()`: +Attach a `BorderBeamController` for programmatic playback. The controller takes **full ownership**: `playback.startAfter` and `playback.duration` must not be set (on the widget or on a theme — it is asserted), `active` and `autoPlay` are ignored, the controller's `speed` replaces `timing.speed`, and the beam starts hidden until you `start()`. ```dart final controller = BorderBeamController(); BorderBeam.rotate(controller: controller, child: card); -controller.start(); // fade in, play -controller.pause(); // freeze the current frame +controller.start(); // fade in, play +controller.pause(); // freeze the current frame controller.resume(); -controller.speed = 2; // playback rate -controller.seek(Duration(seconds: 1)); -controller.stop(); // fade out, halt +controller.speed = 2; // playback rate; must be positive +controller.seek(const Duration(seconds: 1)); +controller.pulse(); // one brightness bump, ~0.6s settle +controller.flash(); // blink to full, hold 120ms, decay +controller.stop(); // fade out, halt +``` + +`isAttached`, `isActive`, and `isRunning` report state, and the controller is a `ChangeNotifier`, so a button can rebuild from it. **One controller drives one beam** — attaching a second asserts. A beam under a `BeamSync` runs on the group's clock and cannot also take a controller; drive the group through `BeamSync` instead. + +`stop()` fades out over 0.5s; `pause()` freezes the current frame with no fade at all, and `resume()` continues from exactly there. `pulse()` and `flash()` are accents on a running beam: they raise brightness and let it settle without touching the timeline, so a beam already sweeping can mark a moment. Both are no-ops while the beam is hidden or frozen. + +### Driving the beam yourself + +Four inputs take the beam off its own schedule without a controller. + +```dart +// A glowing progress ring (or, with .line, a progress bar). +BorderBeam.rotate(progress: downloaded / total, child: card); + +// The sweep gravitates to a point in the box. +BorderBeam.rotate(follow: const Offset(0.5, 0.2), child: card); + +// Live opacity and rate, without rebuilding. +BorderBeam.rotate( + strengthListenable: micLevel, // ValueListenable + speedListenable: tempo, // ValueListenable, positive + child: card, +); +``` + +| Input | Type | Effect | +| --- | --- | --- | +| `progress` | `double?` (0–1) | Places the sweep instead of the clock. The clock still runs, so the fade, hue, and line tracks stay alive — the beam looks lit, not frozen. Repaints without re-resolving the config, so it is cheap to drive from an animation. | +| `follow` | `Offset?` (normalized) | Eases the sweep to the perimeter point nearest a point in the box — critically damped, ~150ms. Setting it back to null hands the sweep back to the clock without a snap. `progress` wins over it. | +| `strengthListenable` | `ValueListenable?` | The live twin of `style.strength`: scales every layer's opacity each frame, no rebuild. | +| `speedListenable` | `ValueListenable?` | The live twin of `timing.speed`, and it wins over both that and a controller's rate. | + +The pulse variants have no travel, so they ignore `progress` and `follow`. Feeding `follow` from a `MouseRegion` is what `BeamHover` does for you. + +### Synchronized groups + +A beam owns its own ticker. Ten beams on a screen means ten tickers, each started at a different instant, so their sweeps drift apart — right for beams that have nothing to do with each other, wrong for a row of cards that should read as one system. `BeamSync` hands the whole subtree **one clock**: one ticker, one timeline, identical phases. + +```dart +BeamSync( + child: Row( + children: [ + for (final (i, card) in cards.indexed) + BorderBeam.rotate( + timing: BeamTiming(phaseOffset: i / cards.length), + child: card, + ), + ], + ), +); +``` + +Beams stay individually configurable — palette, variant, shape, and `phaseOffset` are per beam — but **the group owns playback**: `active`, `autoPlay`, `startAfter`, `duration`, and `repeat` are ignored below a `BeamSync`. Use its own `active` and `speed` instead. Reduced motion is group-owned too, through `BeamSync.reducedMotion`. + +### Overlay and decoration + +`BorderBeam.overlay` is a beam with no child of its own, sized by its parent — it traces content it does not have to wrap: + +```dart +Stack( + children: [ + content, + const Positioned.fill(child: BorderBeam.overlay(borderRadius: 16)), + ], +); +``` + +`BeamDecoration` is the same engine as a `Decoration`, for dropping into a `Container` you already have: + +```dart +Container( + foregroundDecoration: BeamDecoration( + variant: BeamVariant.rotate, + brightness: Theme.of(context).brightness, + theme: BorderBeamTheme.of(context), + colors: BeamColors.ocean, + borderRadius: 16, + ), + decoration: BoxDecoration( + color: surface, + borderRadius: BorderRadius.circular(16), + ), + child: content, +); +``` + +A decoration paints in exactly one slot, so the variant decides which: `rotate`, `small`, `line`, and `pulseInside` go in `foregroundDecoration:`; `pulseOutside` goes in `decoration:`, because its halo blooms behind the child. + +A `BoxPainter` gets a canvas and nothing else, which costs the decoration four things the widget has. Prefer `BorderBeam` when any of them matter: + +- **Ambient theming is passed in, not read.** `brightness` is required and `theme:` takes what `BorderBeamTheme.of(context)` would have returned. +- **The ticker is unmanaged.** It is not created through a `TickerProvider`, so `TickerMode` does not mute it — a beam in a scrollable's cache extent or under an inactive route keeps ticking until the decoration is replaced or its render object is disposed. +- **Reduced motion is not observed.** `MediaQuery.disableAnimationsOf` is unreachable, so `playback.reducedMotion` is inert here. +- **`active` is a starting state, not a toggle**, and the decoration does not interpolate: `lerpFrom`/`lerpTo` return null, so an `AnimatedContainer` snaps between two beam decorations at the halfway point. + +Like the widget, it never absorbs a pointer: `hitTest` returns false. + +### Interaction wrappers + +```dart +BeamFocusRing(borderRadius: 12, child: TextField(decoration: decoration)); +BeamHover(borderRadius: 20, child: pricingCard); +BeamPress(borderRadius: 20, onTap: submit, child: card); +``` + +| Widget | Lights when | Default variant | Notable knobs | +| --- | --- | --- | --- | +| `BeamFocusRing` | `child`'s subtree (or `focusNode`) holds focus | `small`, `ocean` | `alwaysShow` lights it under `FocusHighlightMode.touch` too | +| `BeamHover` | the cursor is over `child`, pulling the sweep toward it | `rotate` | `followPointer`, `holdAfterExit` (300ms) | +| `BeamPress` | a finger is down on `child` | `pulseInside` | `minimumDuration` (600ms), `onTap` | + +Each is a thin wrapper that flips `BorderBeam`'s `active` (and, for `BeamHover`, feeds `follow`), so a transition gets the beam's own fades rather than a cut, and each passes `style`, `shape`, and `timing` straight through. + +`BeamFocusRing` follows `FocusManager.highlightMode`, the same rule `FocusableActionDetector` uses: it shows for keyboard and mouse focus and stays dark for touch. `BeamHover` is a desktop and web wrapper — on a touch device no mouse ever enters and the beam stays dark; `BeamPress` is its touch counterpart, holding the beam lit until `minimumDuration` has passed so a quick tap is a pulse rather than a flicker. `BeamPress` observes raw pointer events through a translucent `Listener` and never enters the gesture arena, so a button inside keeps its own taps and a scrollable above keeps its drags. + + +## Reference + +Every field is nullable and a null means *inherit*, so these tables are the defaults you get by leaving a field out. + +### `BeamStyle` + +| Field | Type | Default | Applies to | +| --- | --- | --- | --- | +| `colors` | `BeamColors?` | `BeamColors.colorful` | all | +| `theme` | `BeamTheme?` | `BeamTheme.auto` | all | +| `strength` | `double?` | `1.0` (clamped 0–1) | all | +| `brightness` | `double?` | preset, else `1.3` | all | +| `saturation` | `double?` | preset | all | +| `hueRange` | `double?` | `30` (`line` caps at 13) | all | +| `hueMode` | `BeamHueMode?` | `pingPong` traveling, `continuous` pulse | all | +| `hueBase` | `double?` | `0` | all | +| `staticColors` | `bool?` | `false` (forced on by `mono` and `gold`) | all | +| `strokeOpacityFactor` | `double?` | `1` | all | +| `innerOpacityFactor` | `double?` | `1` | all | +| `bloomOpacityFactor` | `double?` | `1` | all | +| `glowBoost` | `double?` | `1` | `pulseInside`, `pulseOutside` | +| `coreBlur` | `double?` | preset | `pulseOutside` | +| `bloomBlur` | `double?` | preset | `pulseOutside` | +| `glowBrightness` | `double?` | preset | `pulseOutside` | +| `glowSaturation` | `double?` | preset | `pulseOutside` | +| `tailLength` | `double?` | `1` | `rotate`, `small` | +| `glowSpread` | `double?` | `1` | all | +| `comet` | `bool?` | `false` | `rotate`, `small` | +| `sparkle` | `double?` | `0` (clamped 0–1) | `rotate`, `small` | +| `segments` | `int?` | none (solid ring) | `rotate`, `small`, `line` | +| `innerSizeScale` | `double?` | `1` | `pulseInside` | +| `renderScale` | `double?` | `1` (clamped 0.25–1) | all | +| `pulseOutsideTuning` | `BeamPulseOutsideTuning?` | `demo` | `pulseOutside` | +| `themeConfig` | `BeamThemeConfig?` | per variant × brightness | all | + +`pulseOutsideTuning` switches only the glow geometry: `BeamPulseOutsideTuning.demo` (the default) scales insets and blurs by the element's size, melting the separate blobs into one continuous edge-hugging glow; `.stock` uses the library's fixed insets and per-brightness blur. + +### `BeamShape` + +| Field | Type | Default | Applies to | +| --- | --- | --- | --- | +| `radius` | `BorderRadiusGeometry?` | 16 (32 for `small`) | all — ignored when `contour` is set | +| `borderWidth` | `double?` | `1` | all | +| `superellipse` | `bool?` | `false` | all — ignored when `contour` is set | +| `edge` | `BeamEdge?` | `BeamEdge.bottom` | `line` | +| `ringOffset` | `double?` | `0` | all | +| `contour` | `BeamContour?` | none | all | +| `segment` | `BeamSegment?` | none (full contour) | all | +| `wrapCorners` | `bool?` | `false` | `line`, without a segment | + +### `BeamTiming` + +| Field | Type | Default | Applies to | +| --- | --- | --- | --- | +| `cycle` | `Duration?` | 1.96s rotate/small · 3.1s line · 2.3s pulse | all | +| `cycleGap` | `Duration?` | `Duration.zero` | `rotate`, `small`, `line` | +| `speed` | `double?` | `1` (a controller's `speed` wins) | all | +| `direction` | `BeamDirection?` | `forward` | `rotate`, `small`, `line` | +| `phaseOffset` | `double?` | `0` (fraction of a cycle, 0–1) | all | +| `beamCount` | `int?` | `1` | `rotate`, `small`, `line` | +| `huePeriod` | `Duration?` | 12s traveling · 16s pulseInside · 14s pulseOutside | all | +| `bloomHuePeriod` | `Duration?` | 8s | `line` | +| `breatheFactor` | `double?` | `1.3` × `cycle` | `line` | +| `spikeFactor` | `double?` | `1.33` × `cycle` | `line` | +| `spike2Factor` | `double?` | `1.7` × `cycle` | `line` | + +### `BeamPlayback` + +| Field | Type | Default | Meaning | +| --- | --- | --- | --- | +| `active` | `bool?` | `true` | Play state. Toggling fades in (0.6s) / out (0.5s). | +| `autoPlay` | `bool?` | `true` | Whether the beam starts by itself. | +| `startAfter` | `Duration?` | none | Delay before autoplay. Must be null with a controller. | +| `duration` | `Duration?` | none (plays on) | Total play time before a self fade-out. Must be null with a controller. | +| `repeat` | `BeamRepeat?` | `BeamRepeat.forever()` | `forever()`, `once()`, or `count(n)` cycles, then a fade-out. | +| `reducedMotion` | `BeamReducedMotion?` | `staticFrame` | What to do when the platform asks for reduced motion. | +| `pauseWhenOffscreen` | `bool?` | `true` | Stops the clock while the beam is scrolled out of its nearest enclosing `Scrollable`, with a 256px margin. | +| `fadeCurve` | `Curve?` | the fade spring | The easing both fade envelopes run on. | +| `debugFrozenAt` | `Duration?` | none | Pins the beam to one instant of its timeline and never starts its clock. The frozen frame is still scaled by the live strength. | + +### Palette constructors and transforms + +| Constructor / transform | Signature | What it does | +| --- | --- | --- | +| `BeamColors.custom` | `custom(List colors, {BeamColors base = BeamColors.colorful})` | Distributes your colors over `base`'s blob geometry, cycling when there are fewer colors than slots and preserving each entry's alpha. The list must not be empty. | +| `BeamColors.fromSeed` | `fromSeed(Color seed, {BeamSeedHarmony harmony = BeamSeedHarmony.analogous})` | Spreads one brand color into `analogous`, `complementary`, `triadic`, or `monochrome`. Derived colors are lifted into a readable glow band (lightness 0.55–0.70, saturation ≥ 0.55), so a black or gray seed still reads. | +| `BeamColors.fromScheme` | `fromScheme(ColorScheme scheme)` | Takes `primary`, `secondary`, `tertiary`, dropping roles that duplicate an earlier one. | +| `BeamColors.lerp` | `lerp(BeamColors a, BeamColors b, double t)` | Table-by-table `Color.lerp`. `t` is not clamped, so it extrapolates. | +| `scaleAlpha` | `scaleAlpha(double factor)` | Multiplies every table entry's alpha, preserving the relative depth of the inner / stroke / bloom layers. `factor` must not be negative. | +| `BeamColors.spec` | `spec({required List border, List? smallBorder, List? lineBlobs})` | Full per-blob control. `BeamBlob.size` holds ellipse **radii**, not diameters; omitted tables are derived by cycling the border colors. | + +### Shorthands and precedence + +Three fields are common enough to sit directly on the widget: `colors`, `active`, and `borderRadius` (a uniform `shape.radius`). They are shorthands, not separate settings — each folds into the matching value object, winning over the same field there. + +A field is resolved in one order, and only one: + +```text +flat shorthand > value object on the widget > BorderBeamTheme > variant default ``` -### Fine tuning +Nested `BorderBeamTheme`s merge inner over outer before the widget's own values are applied on top. + + +## Accessibility + +- **Reduced motion is honored by default.** With `MediaQuery.disableAnimations` set, every variant falls back to a single static frame; `hide`, `slow`, and `animate` are one field away. See the [Playback](#playback) table. +- **Beams add no semantics.** They are decoration, and decoration that announced itself would be noise. Screen readers describe your child exactly as they would without the beam. +- **Nothing is conveyed by the beam alone.** A beam marks a state that is already stated somewhere else — the button's label, the field's helper text, a status line. Treat it as emphasis, never as the only signal that something is loading, focused, or selected. +- **Pointers pass through.** No beam layer intercepts a pointer, so hit-testing, focus traversal, and gesture handling behave as if the beam were not there. +- **`BeamFocusRing` complements the platform focus ring**; it does not replace the focused widget's semantics, and it respects `FocusManager.highlightMode` so a tapped field does not sprout a keyboard-focus ring. + +[Full documentation →](doc/accessibility.md) + + +## Performance + +- **One ticker per beam** — or exactly one per `BeamSync`, however many beams are in the group. +- **~30fps cap on the pulse variants**, matching the original's pulse driver. Time still accumulates at full resolution; only notifications are throttled, and a `pulse()`/`flash()` boost lifts the cap while it plays. +- **`RepaintBoundary` isolation.** The child sits in its own boundary inside the beam's, so animating the beam never re-rasterizes your content. +- **Color matrices are folded on the CPU.** Hue, brightness, and saturation are baked into gradient colors rather than carried on a `ColorFilter`, which would need a layer of its own. +- **Offscreen pause.** With `pauseWhenOffscreen` (the default), a beam scrolled out of its nearest enclosing `Scrollable` — with a 256px margin — stops its clock and resumes exactly where it left off. Play state, fades, and callbacks are untouched. + +The `saveLayer` count per frame — `paintBehind` plus `paintAbove` — is a fixed, measured budget, not an estimate: + +| Variant | `saveLayer`s per frame | +| --- | --- | +| `rotate` | 4 | +| `small` | 3 | +| `line` | 4 | +| `pulseInside` | 4 | +| `pulseOutside` | 3 | + +`test/painting/save_layer_budget_test.dart` counts them through a counting canvas for every variant × brightness × palette × time sample and asserts each number **exactly**, so a regression and an improvement both fail the suite until the table is updated. + +[Full documentation →](doc/performance.md) + + +## Parity with the original + +The constants are transcribed verbatim from the upstream `src/styles.ts`, so the rendering matches. Where this package deliberately differs: + +| Area | flutter_border_beam | The original | +| --- | --- | --- | +| Fade envelope | A spring (mass 1, stiffness 180, damping 20) | CSS `ease` — available here as `fadeCurve: BeamPlayback.cssEase` | +| Reduced motion | All five variants, defaulting to a static frame | Pulse variants only (the web build hides them; iOS and React Native do the same) | +| pulse-outside defaults | Bake the tuning the original's web demo applies | The library's stock values — available here as `BeamStyle.pulseOutsideStock` | +| `BeamTheme.auto` | Follows `Theme.of(context)` | Follows the OS color scheme | +| Child radius | Passed in; no auto-detection | Read from the child | + +Flutter-only additions, with no upstream counterpart: `hueBase`, custom palettes (`custom` / `fromSeed` / `fromScheme` / `lerp` / `scaleAlpha` / `spec`) and the seven extra presets, per-corner radii, superellipse corners, stadium shapes, arbitrary contours, `BeamShape.segment`, `BeamShape.wrapCorners`, `segments`, `direction`, `cycleGap`, `beamCount`, `progress`, `follow`, `BeamSync`, `BeamDecoration`, and the `BeamFocusRing` / `BeamHover` / `BeamPress` wrappers. -Every tuning hook of the original is a parameter: +[Full documentation →](doc/parity.md) + + +## How it works + +Everything animated is a **pure function of elapsed time** — there are no `AnimationController`s anywhere. + +1. **Clock.** One `Ticker` per beam (or per `BeamSync`) accumulates `elapsedSeconds`, scaled by the playback rate and gated by the fade envelope. It mirrors the original's single shared `requestAnimationFrame` loop. +2. **Phases.** Each frame, a resolver recomputes every animated track from that one number — sweep position, hue, the line variant's breathe and spike scales, the pulse oscillator bank. Same time in, same frame out, which is what makes goldens and `debugFrozenAt` possible. +3. **Strategies.** One strategy per variant family turns those phases into geometry: the traveling window and its mask, or the breathing blob table. +4. **Layers.** A single `CustomPainter` paints the stroke ring, the inner glow, and the bloom within the budget above, on a `behind` pass (pulse-outside) or an `above` pass (everything else). + +[Full documentation →](doc/architecture.md) + + +## FAQ + +**The beam's corners don't match my card's.** +Pass the same radius your child uses — `borderRadius:` or `shape.radius`. The beam does not read the child's decoration, deliberately: it also wraps widgets that have no decoration to read. + +**My beam is clipped.** +The beam paints outside the child's bounds. `pulseOutside` needs real room around it, and a positive `ringOffset` pushes the ring further out still. Add padding in the parent and remove any tight `ClipRRect` / `ClipPath` above the beam. + +**Nothing shows up at all.** +Check reduced motion. With `reducedMotion: BeamReducedMotion.hide`, a platform request for reduced motion paints nothing — that is the behavior working. `staticFrame` (the default) always leaves a visible frame. + +**The squircle option does nothing on my Flutter version.** +`superellipse` needs Flutter ≥ 3.35, where `RSuperellipse` is stable. That is the package's declared minimum, and CI tests against exactly that version. + +**Why does my segment run the wrong way?** +Segments always run clockwise from `start` to `end`, wrapping through top-center when needed. Swap the two anchors to select the complementary direction around the contour. + +**Does it work on web?** +Yes — the [live playground](https://simplifyjobs.github.io/flutter_border_beam/) is a web build of the example app, and CI builds it on every commit. The blur-heavy variants (`line`, `pulseOutside`) are the most expensive there, so prefer a single hero beam over a grid of them on web. + +**Can I animate between two beam configurations?** +With `BorderBeam`, yes — changing `cycle` retimes in place, and palettes crossfade with `BeamColors.lerp` driven by an `Animation`. With `BeamDecoration`, no: it does not interpolate, so an `AnimatedContainer` snaps. -| Parameter | Default | Purpose | -|---|---|---| -| `strength` | `1.0` | Overall effect opacity (0–1); only affects beam layers | -| `cycleDuration` | 1.96s / 3.1s / 2.3s | One animation cycle (rotate·small / line / pulse) | -| `theme` | `BeamTheme.auto` | `dark`, `light`, or follow `Theme.of(context)` | -| `brightness`, `saturation` | per variant/theme | Glow filter multipliers | -| `hueRange` | `30` (line caps at 13) | Hue animation amplitude in degrees | -| `hueBase` | `0` | Static hue offset for the whole palette | -| `staticColors` | `false` | Disable the hue animation | -| `respectReducedMotion` | `true` | Static frame under `MediaQuery.disableAnimations` | -| `strokeOpacityFactor`, `innerOpacityFactor`, `bloomOpacityFactor` | `1` | Per-layer opacity multipliers | -| `glowBoost` (pulse) | `1` | Halo prominence | -| `coreBlur`, `bloomBlur`, `glowBrightness`, `glowSaturation` (pulse-outside) | preset | Glow tuning overrides | ## Example app -`example/` contains a full gallery recreating the original demo — Rotate/Pulse example tabs with mock chat inputs, task cards, and search bars, plus an interactive playground with live code snippets: +[`example/`](example) is the full gallery — the original demo recreated, with mock chat inputs, task cards, and search bars, plus the interactive playground behind the [live playground](https://simplifyjobs.github.io/flutter_border_beam/) link, live code snippets, and shareable URLs: -```bash +```console cd example && flutter run ``` -## Recording demos +Demo reels live in `example/lib/*.dart` and use the marker-based harness in `example/lib/demo_harness.dart`. Record one on a booted iOS simulator (requires `ffmpeg`): -Demo reels live in `example/lib/*.dart` and use the marker-based harness in `example/lib/demo_harness.dart`. Record on a booted iOS simulator (requires `ffmpeg`): - -```bash +```console tool/record_demo.sh --target lib/showcase.dart --prefix SHOWCASE --contact ``` Outputs land in `.demos/` (gitignored) as 60fps center-cropped mp4s with a contact sheet for review. -## Credits & attribution -This package is a port of **[border-beam](https://github.com/Jakubantalik/border-beam)** — created and designed by **[Jakub Antalik](https://x.com/jakubantalik)** and released under the MIT license. All visual design — the five effects, color palettes, gradient geometry, animation timings, and the demo it ships with — is his work. If you like the effect, go star the original. +## Contributing + +Issues and pull requests are welcome — start with [CONTRIBUTING.md](CONTRIBUTING.md) for the local setup, the golden-test rules, and what a good change looks like here. Security reports go through [SECURITY.md](SECURITY.md). + +One rule worth knowing before you open an editor: **never tweak values in `lib/src/constants/`**. They are transcribed 1:1 from the upstream source and visual parity depends on them — see [doc/parity.md](doc/parity.md). + + +## Credits + +This package is a port of **[border-beam](https://github.com/Jakubantalik/Libraries/tree/main/packages/border-beam)** — created and designed by **[Jakub Antalik](https://x.com/jakubantalik)** and released under the MIT license, with its own home at [Jakubantalik/border-beam](https://github.com/Jakubantalik/border-beam). All visual design — the five effects, color palettes, gradient geometry, animation timings, and the demo it ships with — is his work. If you like the effect, go star the original. The Flutter port adds the widget/controller architecture, superellipse shapes, custom palette API, and the canvas rendering engine. + ## License -[MIT](LICENSE) — includes the original work's copyright notice. +MIT. See [LICENSE](LICENSE) for details — it includes the original work's copyright notice. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d8a9041 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,65 @@ +# Security Policy + +## Supported versions + +Fixes land on the latest published minor of `flutter_border_beam`. Older +minors are not patched — upgrade to the newest release on +[pub.dev](https://pub.dev/packages/flutter_border_beam) before reporting. + +| Version | Supported | +| --- | --- | +| Latest minor | Yes | +| Anything older | No — upgrade first | + +## Reporting a vulnerability + +**Do not open a public issue for a security report.** + +Use GitHub's private vulnerability reporting: go to the +[Security tab](https://github.com/SimplifyJobs/flutter_border_beam/security) +of the repository and press **Report a vulnerability**. That opens a private +advisory visible only to you and the maintainers. + +If you cannot use that form, email **support@simplify.jobs** instead. + +Helpful things to include: the package version, the Flutter version and +platform, a minimal reproduction, and what an attacker gains. + +## What to expect + +- **Acknowledgement within 5 business days.** +- An assessment — whether it is in scope, and the severity we assign it — + once we have reproduced it. +- A fix released on the latest minor, and a GitHub Security Advisory + published when the issue warrants one. We are happy to credit you unless + you would rather stay anonymous. + +Please give us a reasonable window to ship a fix before disclosing publicly. + +## Scope + +This is a **rendering package**. It draws animated borders with Flutter's +canvas API: it opens no sockets, reads and writes no files, stores nothing, +and collects nothing. It has no runtime dependencies beyond the Flutter SDK. +Most of the vulnerability classes a security report usually covers simply +have no surface here. + +In scope: + +- Attacker-controllable input to the public API — colors, radii, durations, + a `BeamContour` path — that causes a crash, an unbounded allocation, a hang, + or an unrecoverable render-loop failure in a host app. +- A compromise of the release path: the publishing workflow, the pub.dev + automated-publishing configuration, or the published archive's contents. +- Anything in the package that reaches the network or the filesystem. There + should be nothing; if you find something, that is the report. + +Out of scope: + +- Visual bugs, jank, and performance that is merely disappointing — those are + ordinary [issues](https://github.com/SimplifyJobs/flutter_border_beam/issues). +- Frame-rate or battery cost of an animation configured to be expensive. +- Vulnerabilities in Flutter, Dart, or a browser engine — report those to + their own projects. +- The example gallery and the hosted playground, which contain no data and + no accounts. diff --git a/assets/showcase/showcase.gif b/assets/showcase/showcase.gif new file mode 100644 index 0000000..44ff36f Binary files /dev/null and b/assets/showcase/showcase.gif differ diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..7c015e3 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,15 @@ +# Coverage is a signal, not a gate, while the suite is being built out. +# Nothing here may turn a pull request red. +coverage: + status: + project: + default: + # Tolerate small dips; a real drop still shows up in the check. + threshold: 1% + informational: true + patch: + default: + informational: true + +# Keep pull requests quiet — the check status carries the number. +comment: false diff --git a/doc/accessibility.md b/doc/accessibility.md new file mode 100644 index 0000000..25141de --- /dev/null +++ b/doc/accessibility.md @@ -0,0 +1,82 @@ +# Accessibility + +A border beam is decoration. It should make a state easier to notice for people who can see it, and cost nothing for everyone else. Three rules follow from that, and the package enforces two of them for you. + +## Reduced motion + +When the platform asks for reduced motion — `MediaQuery.disableAnimationsOf`, which Flutter maps to *Reduce Motion* on iOS and *Remove animations* on Android — the beam obeys by default. + +| `BeamReducedMotion` | Behavior | Reach for it when | +| --- | --- | --- | +| `staticFrame` | Paints one static frame and stops ticking. **The default.** | Almost always. The beam still marks the state; it just stops moving. | +| `hide` | Paints nothing; the child is left bare, and no ticker runs. | The beam is pure decoration and the state is fully carried elsewhere. | +| `slow` | Keeps animating at a quarter speed. | Motion carries meaning that a still frame cannot (a progress ring). | +| `animate` | Ignores the request. | Essentially never in product code. | + +```dart +// App-wide: hide beams for anyone who asked for less motion. +BorderBeamTheme( + data: const BorderBeamThemeData( + playback: BeamPlayback(reducedMotion: BeamReducedMotion.hide), + ), + child: app, +); +``` + +Two details worth knowing: + +- **All five variants honor it.** The original applies reduced motion to the pulse variants only; this package applies it everywhere, which is why `staticFrame` — a frame that still communicates — is the default rather than `hide`. See [parity](parity.md). +- **A frozen beam stops ticking.** `staticFrame` and `hide` pause the clock rather than animating invisibly, so respecting the setting also saves the battery it was meant to save. + +Under a [`BeamSync`](motion.md#beamsync), reduced motion is group-owned because every beam shares one clock. Set `BeamSync.reducedMotion` to `staticFrame` (the default), `hide`, `slow`, or `animate`; per-beam settings are ignored while synchronized. + +`BeamDecoration` **cannot** observe reduced motion — a `BoxPainter` has no `BuildContext` — so `reducedMotion` is inert there. If a surface must honor the setting, use the widget. + +## Semantics + +Beams add **no** semantics. A screen reader describes your child exactly as it would without the beam, and nothing is announced when a beam starts, stops, or changes. + +That is the correct default, and it puts one obligation on the caller: **never let the beam be the only signal.** A beam marks a state that is already stated somewhere a screen reader can reach — the button's label ("Generating…"), the field's helper text, a status line, a `Semantics` liveRegion. Treat the beam as emphasis on information that already exists. + +```dart +// The beam emphasizes; the label states. +BorderBeam.pulseInside( + active: isGenerating, + child: FilledButton( + onPressed: isGenerating ? null : generate, + child: Text(isGenerating ? 'Generating…' : 'Generate'), + ), +); +``` + +If you catch yourself writing "the glowing one is selected", add a non-visual signal. + +## Pointers and focus + +No beam layer intercepts a pointer. `BorderBeam` paints through a `CustomPaint` whose layers are decorative, and `BeamDecoration.hitTest` returns false. Hit-testing, focus traversal, and gesture handling behave exactly as they would without the beam, so a beam can be dropped around an existing interactive widget without auditing its gestures. + +`BeamPress` is the one wrapper that touches input, and it does so through a translucent `Listener` that never enters the gesture arena — a button inside keeps its own taps, and a scrollable above keeps its drags. + +## Focus indication + +`BeamFocusRing` is a *visual* focus indicator, complementing what the focused widget already reports rather than replacing it: + +```dart +BeamFocusRing( + borderRadius: 12, + child: TextField(decoration: decoration), +); +``` + +It follows `FocusManager.highlightMode`, the same rule `FocusableActionDetector` uses: it lights for keyboard and mouse focus and stays dark for touch, where a focus ring around a tapped field is noise. `alwaysShow: true` overrides that. + +Because it only flips `BorderBeam`'s `active`, focus arrives and leaves on the beam's own fades rather than as a cut — but a fade is still a state change, so keep the underlying widget's own focus decoration if your design depends on an instant indicator. The ring does not replace the platform focus ring; it sits alongside it. + +## Contrast and legibility + +The beam paints *around* content, never over text, so it does not affect text contrast directly. Two things still deserve a look: + +- **A bright beam next to small text** raises the local luminance around it and can make a thin label feel lower-contrast than it measures. `style.strength` is the dial; 0.6–0.8 is often better on dense UI than the full 1.0. +- **`pulseOutside` changes the perceived edge of a card.** Its contract already asks for the child's own 1px border, which is what keeps the card's boundary legible when the halo is at its dimmest. + +For anyone sensitive to flicker, the relevant settings are the same ones above: prefer longer cycles, and honor reduced motion with `staticFrame` or `hide`. Nothing in the package flashes faster than roughly one cycle per second at the default timings, but a very short `cycle` combined with `flash()` can, so treat sub-second cycles as a deliberate choice. diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..add6247 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,96 @@ +# Architecture + +The one idea the whole package is built on: **everything animated is a pure function of elapsed time.** One `Ticker` produces a number; every visual value is recomputed from that number each frame. There are no `AnimationController`s anywhere, no per-track state to keep in sync, and no way for two tracks to drift apart — which mirrors the original React library's single shared `requestAnimationFrame` loop, and is what makes goldens, property tests, and `debugFrozenAt` possible. + +## The frame + +```text +Ticker ──► BeamClock ──► BeamPhaseResolver ──► VariantStrategy ──► BeamPainter + elapsed s every animated geometry for stroke / inner / + speed, fade track for this this variant bloom layers + pause, fps frame family +``` + +1. **`BeamClock`** owns the ticker. It scales elapsed time by the playback rate, runs the fade envelope on activate/deactivate, handles pause/resume/seek, applies the optional fps cap, and carries the one-shot `pulse()`/`flash()` boosts. It is a `Listenable`; the painter repaints from it. +2. **`BeamPhaseResolver`** samples every animated track for a given time: sweep position, hue, the line variant's breathe and spike scales, the pulse oscillator bank. Pure — same time in, same phases out. +3. **A `VariantStrategy`** per variant family turns phases into geometry: the traveling window and its masks, or the breathing blob table. +4. **`BeamPainter`** is one `CustomPainter` running that pipeline into a fixed layer budget, on a `behind` pass (pulse-outside) or an `above` pass (everything else). + +## Module layout + +### Public entry points + +| File | Contents | +| --- | --- | +| `lib/flutter_border_beam.dart` | The barrel. Only `BorderBeam`, `BorderBeamController`, `BorderBeamTheme`/`BorderBeamThemeData`, `BeamSync`, `BeamVariant`, `BeamColors`/`BeamSeedHarmony`, `BeamBlob`/`LineBlob`, `BeamTheme`, `BeamThemeConfig`, the four value objects, the option types, and the four widgets are exported. | +| `lib/src/border_beam.dart` | The widget: a generic constructor taking a `BeamVariant`, `BorderBeam.overlay`, five named constructors, three flat shorthands (`colors` / `active` / `borderRadius`), the driven inputs (`progress`, `follow`, `strengthListenable`, `speedListenable`), and all scheduling and lifecycle. A controller attached ⇒ it owns playback exclusively. | +| `lib/src/border_beam_controller.dart` | `BorderBeamController` — a `ChangeNotifier` over one clock. | +| `lib/src/border_beam_theme.dart` | `BorderBeamTheme` (an `InheritedWidget`) + `BorderBeamThemeData`. `of` walks every enclosing scope, depends on each, and merges them outside-in, so nested themes compose. | +| `lib/src/beam_sync.dart` | `BeamSync` and its internal scope — one clock for a whole subtree. | + +### `lib/src/models/` — data types + +The four public **value objects** (`beam_style.dart`, `beam_shape.dart`, `beam_timing.dart`, `beam_playback.dart`) are all-nullable, `const`, with `copyWith` / `merge` / `==`. A null field means *inherit*. + +`beam_options.dart` holds the small option types those fields take: `BeamHueMode`, `BeamDirection`, `BeamEdge`, `BeamReducedMotion`, `BeamRepeat`, `BeamPulseOutsideTuning`, and the `BeamContour` / `BeamPathContour` pair — a contour is a config-cache key, so every implementer overrides `==` / `hashCode`. + +`beam_colors.dart` is the sealed `BeamColors` hierarchy (preset, custom, seed, scheme, lerp, scaled, spec) with the value-keyed LRU that memoizes resolution; `beam_palette.dart` and `beam_blob.dart` are the resolved tables and their entries; `beam_theme.dart` and `beam_theme_config.dart` are the brightness selector and the per-variant × brightness preset. + +`beam_config.dart` is where it all lands. `BeamConfig.resolve({variant, palette, brightness, style, shape, timing, textDirection})` flattens the value objects into the painter's config, mirroring the React component's computed values — per-variant default durations and radii, the line variant's 13° hue cap, mono forcing static colors. It carries the timing tracks (`gapSeconds`, hue periods, breathe/spike factors) and a per-corner resolved `BorderRadius`, and it is value-equal, so `BeamPainter.shouldRepaint` compares configs. + +### `lib/src/animation/` + +| File | Contents | +| --- | --- | +| `beam_clock.dart` | The ticker, playback rate, pause/resume/seek, spring or curve fade envelopes, the optional fps cap, and the `pulse`/`flash` boosts. | +| `spring_curve.dart` | The in-package fade spring (mass 1, stiffness 180, damping 20) — the reason the package depends on nothing but the Flutter SDK. | +| `oscillator.dart` | The ping-pong helper and the 17-oscillator pulse bank. | +| `beam_phases.dart` | `BeamPhaseResolver` — the per-frame value object and keyframe sampling. | + +### `lib/src/painting/` + +| File | Contents | +| --- | --- | +| `beam_painter.dart` | The single `CustomPainter`; repaint driven by the clock, `behind` / `above` passes. | +| `variant_strategy.dart` | The strategy interface, including `preferredFps`. | +| `strategies/` | `rotate_strategy.dart`, `line_strategy.dart`, `pulse_inner_strategy.dart`, `pulse_outer_strategy.dart`, and the `pulse_common.dart` they share. | +| `ring_geometry.dart` | The rounded-rect and `RSuperellipse` ring, built as a `Path.combine` difference; contour and ring-offset handling. | +| `gradient_builders.dart` | Blob tables to `Gradient`s, and the CSS↔Flutter conventions. | +| `color_matrix.dart` | Hue, brightness, and saturation matrices, folded into colors on the CPU. | +| `layer_utils.dart` | The `saveLayer` helpers the budget is counted through. | + +### `lib/src/constants/` + +Verbatim transcriptions of the upstream `src/styles.ts` tables — `palettes.dart`, `theme_presets.dart`, `pulse_tables.dart`, `pulse_params.dart`, `pulse_constants.dart`, `line_keyframes.dart`, `line_geometry.dart`, `rotate_stops.dart` — plus `upstream.dart` (internal provenance: the tracked library and spec versions and the source repository) and `extra_palettes.dart` (the seven Flutter-only presets, which are *not* transcriptions). `tool/spec/refresh.sh` vendors upstream's machine-readable spec into `test/fixtures/`, and `test/constants/spec_parity_test.dart` asserts every table against it. See [parity](parity.md). + +### `lib/src/widgets/` + +`beam_decoration.dart` (the engine as a `Decoration`), `beam_focus_ring.dart`, `beam_hover.dart`, `beam_press.dart`. + +## Invariants worth knowing + +**Never tweak values in `lib/src/constants/`.** They are transcribed 1:1 from the source and pinned by the constant tests. See [parity](parity.md). + +**The saveLayer budget is exact.** rotate 4, small 3, line 4, pulseInside 4, pulseOutside 3 per frame, asserted by `test/painting/save_layer_budget_test.dart` — a regression and an improvement both fail until the table moves. Never add a `saveLayer` just to carry a `ColorFilter`; a filter rides on a layer that exists anyway, and blur is the only filter allowed to justify a layer of its own. See [performance](performance.md). + +**CSS↔Flutter conventions** live in `gradient_builders.dart` and are listed in [parity](parity.md#css--flutter-mapping): conic gradients start at 12 o'clock so `SweepGradient` needs a baked −90°; CSS radial-gradient sizes are radii; `blur(Npx)` maps to sigma = N; fades go to `color.withValues(alpha: 0)`, never `Color(0x00000000)`. + +**Layer opacity is a multiplied chain** — fade × preset × mono × hook × strength — clamped at paint time. Presets legitimately exceed 1 (line/dark's stroke is 1.14). + +**The line variant does not use the mono ×0.5 multiplier.** Its mono treatment is spike attenuation inside the bloom, in `LineStrategy`. + +**Speed never reaches `BeamConfig`.** It is applied to the clock, deliberately kept out of the config cache key, so a rate change rides through without re-resolving the config or rebuilding the phase resolver. + +## Tests + +| Directory | Covers | +| --- | --- | +| `test/models/` | Value objects, `BeamConfig.resolve`, palettes, colors, variants. | +| `test/animation/` | Clock, spring curve, oscillators, phases, travel, periods, and a seeded property test over `BeamPhaseResolver.sample`. | +| `test/painting/` | Ring geometry, color matrices, surface features, the saveLayer budget, degenerate geometry, and a paint smoke sweep. | +| `test/widget/` | `BorderBeam` itself: lifecycle, updates, shorthands, theming, sync, speed, progress, motion, cycle gap. | +| `test/widgets/` | The four widgets in `lib/src/widgets/`. | +| `test/golden/` | Golden families, one per axis — `beam_`, `palette_`, `motion_`, `surface_`, `widgets_`. | +| `test/constants/`, `test/fixtures/` | The transcription check against the extracted upstream fixtures. | + +Golden scenes freeze the fake clock at a known time (t=1.3s — post fade-in, mid-cycle) for every variant × theme × palette, with the traveling variants captured a second time further along their sweep. Goldens are pinned to macOS rendering and to the Flutter version CI pins; regenerating on another OS or SDK produces diffs. diff --git a/doc/motion.md b/doc/motion.md new file mode 100644 index 0000000..b7d72ab --- /dev/null +++ b/doc/motion.md @@ -0,0 +1,176 @@ +# Motion + +Timing, direction, rest, synchronization, and taking the sweep off the clock entirely. The [README's timing table](../README.md#timing) lists the fields and defaults; this guide is about how they interact. + +## The clock + +One `Ticker` accumulates elapsed seconds. Every animated value — sweep position, hue, the line variant's breathe and spike scales, the pulse oscillator bank — is recomputed from that single number each frame. There are no `AnimationController`s and no per-track state, which is what makes the whole engine a pure function of time. + +`speed` scales how fast that number advances. It is applied to the clock rather than to the config, so changing it never re-resolves the beam or rebuilds the phase resolver — a rate change is free. + +Three things can drive the rate, in order: a `speedListenable`, then an attached `BorderBeamController`'s `speed`, then `timing.speed`. Under a `BeamSync` the group's rate owns all of them. + +## Cycle and retiming + +`cycle` is one lap for a traveling variant and one breath for a pulse variant. Changing it on a live beam **retimes in place**: every track keeps the phase it was at, so the beam smoothly speeds up or slows down instead of jumping to the new cycle's start. + +```dart +BorderBeam.rotate( + timing: BeamTiming(cycle: isUrgent + ? const Duration(milliseconds: 900) + : const Duration(milliseconds: 1960)), + child: card, +); +``` + +That is the mechanism to reach for when a beam should react to state. Toggling `active` fades out and back in; changing `cycle` keeps the light on and changes its urgency. + +## Rest between sweeps + +`cycleGap` parks the beam at the end of its travel after each lap: + +```dart +BorderBeam.rotate( + timing: const BeamTiming(cycleGap: Duration(milliseconds: 1200)), + child: card, +); +``` + +The sweep itself still takes `cycle`; the gap is added after it. The beam's fade envelope eases out and back in over `min(0.25s, gap / 2)` at each end, so the rest reads as the beam breathing rather than blinking off. Hue, breathe, and spike are textures rather than the sweep, so they keep running through the gap — the beam is dark, not frozen. + +Changing the gap needs no retime: the current sweep keeps its position and the gap simply appears at the next cycle end. The pulse variants ignore it — their breathing has no cycle boundary to rest at. + +## Direction, offset, and count + +```dart +BorderBeam.rotate( + timing: const BeamTiming( + direction: BeamDirection.bounce, + phaseOffset: 0.25, + beamCount: 3, + ), + child: card, +); +``` + +- **`direction`** — `forward` (clockwise; left-to-right for `line`), `reverse` (the mirror), or `bounce` (alternating each cycle). The pulse variants have no travel to direct. +- **`phaseOffset`** — a fraction of a cycle, 0–1, that the timeline starts at. Two beams on the same cycle with different offsets run out of step. +- **`beamCount`** — how many beams travel the contour at once, spaced equally along the cycle. Every beam shares one clock and one config, so the count costs geometry, not tickers. + +`beamCount` with `bounce` is worth trying on a wide card: the beams meet and part rather than chasing each other around. + +Segments compose with the same travel controls. The segment always names a clockwise span; `direction` controls which way a traveler crosses it, and `cycleGap` adds the normal rest after a traveling sweep. Two beams with opposite directions make a counter-sweep: + +```dart +Stack( + children: [ + BorderBeam.rotate( + shape: const BeamShape(segment: BeamSegment.bottomHalf), + child: card, + ), + BorderBeam.rotate( + shape: const BeamShape(segment: BeamSegment.bottomHalf), + timing: const BeamTiming(direction: BeamDirection.reverse), + child: card, + ), + ], +); +``` + +Add the same `cycleGap` to both timings when the arcs should disappear between passes. + +## The hue tracks + +The hue is a separate track from the sweep, on its own period. + +| | Traveling (`rotate`, `small`, `line`) | Pulse | +| --- | --- | --- | +| Default `hueMode` | `pingPong` — swings ±`hueRange` | `continuous` — a full 360° revolution | +| Default `huePeriod` | 12s | 16s (`pulseInside`) · 14s (`pulseOutside`) | + +`hueMode` is settable on either family, and swapping it is the cheapest way to change a palette's character: `continuous` on a traveling variant turns a subtle shimmer into a full rainbow cycle, and `pingPong` on a pulse variant calms it down. + +`line` has a second hue track for its bloom (`bloomHuePeriod`, 8s), swinging across ±(`hueRange` + 10)°, and its `hueRange` is capped at 13° regardless of what you set — see [variants](variants.md). + +`hueBase` shifts the whole palette by a fixed number of degrees, independent of the animation. It has no upstream counterpart and is the quickest way to nudge a preset toward your brand without leaving the preset. + +## Reduced motion + +Reduced motion is a *playback* concern rather than a timing one — the field lives on `BeamPlayback` and has four behaviors. See [accessibility](accessibility.md). + +## `BeamSync` + +Beams that belong together should move together. `BeamSync` gives its whole subtree one clock — one ticker, one timeline, identical phases: + +```dart +BeamSync( + speed: 0.8, + child: Column( + children: [ + for (final (i, row) in rows.indexed) + BorderBeam.line( + timing: BeamTiming(phaseOffset: i / rows.length), + child: row, + ), + ], + ), +); +``` + +The group owns playback: `active`, `autoPlay`, `startAfter`, `duration`, and `repeat` are ignored below it, and a `BorderBeamController` asserts rather than fighting for the clock. Everything visual stays per beam — palette, variant, shape — as does `phaseOffset`, which is what lets a synced group be evenly spaced rather than perfectly stacked. + +Reduced motion is group-owned because every beam shares one clock. Set `BeamSync.reducedMotion` to `staticFrame` (the default), `hide`, `slow`, or `animate`; per-beam settings are ignored while synchronized. + +## Driving the sweep yourself + +Two inputs take the sweep off the clock without stopping it. + +### `progress` + +```dart +BorderBeam.rotate(progress: received / total, child: card); +``` + +The sweep sits where the value says, turning `rotate` into a glowing progress ring and `line` into a progress bar. The clock keeps running underneath, so the fade envelope, the hue shift, and the line variant's breathe and spike tracks are all still alive — the beam reads as lit rather than frozen. Changing it repaints without re-resolving the configuration, so driving it from an `AnimationController` every frame is cheap. + +### `follow` + +```dart +class _Card extends StatefulWidget { /* … */ } + +// In the State: +Offset? _follow; + +@override +Widget build(BuildContext context) { + return MouseRegion( + onHover: (event) { + final box = context.findRenderObject()! as RenderBox; + final local = box.globalToLocal(event.position); + setState(() { + _follow = Offset( + local.dx / box.size.width, + local.dy / box.size.height, + ); + }); + }, + onExit: (_) => setState(() => _follow = null), + child: BorderBeam.rotate(follow: _follow, child: card), + ); +} +``` + +The beam leaves its schedule and eases to the perimeter point nearest the given normalized point — critically damped, about 150ms, so a jittery pointer stream is smoothed without lag you can feel. Setting it back to null hands the sweep back to the clock from wherever it is, with no snap. + +`progress` wins over `follow`, and the pulse variants ignore both. `BeamHover` is this wired up for you. + +### Live listenables + +`strengthListenable` and `speedListenable` are the per-frame twins of `style.strength` and `timing.speed`: they change the beam without rebuilding the widget, which is what you want when the source is a mic level, a download rate, or a tempo. + +```dart +BorderBeam.rotate( + strengthListenable: level, // ValueListenable + child: card, +); +``` diff --git a/doc/palettes.md b/doc/palettes.md new file mode 100644 index 0000000..acc9dab --- /dev/null +++ b/doc/palettes.md @@ -0,0 +1,111 @@ +# Palettes + +A palette is not a list of colors — it is a set of **blob tables**. Each table entry is an ellipse with a color, a fractional position, and a pair of radii, and the beam's look comes from where those ellipses sit as much as from their hues. The [README's palette tables](../README.md#palettes) list every preset and factory; this guide is about how they work. + +## What a palette actually holds + +`BeamPalette` carries one table per surface the engine paints: + +| Table | Used by | +| --- | --- | +| `border` (9 blobs) | the `rotate` stroke, and as the pulse variants' color source | +| `smallBorder`, `smallInner` | `small` | +| `lineDark`, `lineLight`, `lineInner` | `line`, per brightness | +| `lineBloomDark`, `lineBloomLight`, `spike`, `spikeLt` | the `line` bloom and its spikes | + +Plus three modifiers: `forcesStaticColors` (pin the hue), `opacityMultiplier` (the mono ×0.5), and `monoTreatment`. + +This is why `BeamColors.custom` takes a *list of colors* and a *base*: it keeps the base's positions, radii, and per-entry alpha and substitutes only the hues, so a custom palette keeps the layered depth the original tuned. + +## Presets + +Four come from upstream (`colorful`, `mono`, `ocean`, `sunset`) and are transcribed verbatim. Seven are original to this package (`aurora`, `neon`, `candy`, `ember`, `ice`, `gold`, `holographic`) and are defined as short color lists in `lib/src/constants/extra_palettes.dart`, distributed over `colorful`'s geometry exactly the way `BeamColors.custom` does. Editing one of those lists changes how that preset looks and has no bearing on parity with the source. + +Two presets pin the hue, for different reasons: + +- **`mono`** carries the source's full mono treatment: static colors *and* halved layer opacity, which stops a grayscale beam blowing out. +- **`gold`** pins the hue but keeps full opacity. A hue sweep over a single-hue metal reads as the metal changing material; halving a warm amber only makes it muddy. + +`holographic` is the opposite bet — deliberately low-contrast pastels that come alive when paired with a fast continuous hue drift: + +```dart +BorderBeam.rotate( + colors: BeamColors.holographic, + style: const BeamStyle(hueMode: BeamHueMode.continuous), + timing: const BeamTiming(huePeriod: Duration(seconds: 3)), + child: card, +); +``` + +## Deriving from a brand color + +`BeamColors.fromSeed` spreads one color into a palette using one of four harmonies: + +| `BeamSeedHarmony` | Hue offsets | Feel | +| --- | --- | --- | +| `analogous` (default) | 0°, +25°, −25°, +50° | calmest; still reads as one color | +| `complementary` | 0°, +15°, +180°, +195° | maximum contrast across the beam | +| `triadic` | 0°, +120°, +240° | three-way, evenly spaced | +| `monochrome` | one hue, four lightness steps | single-hue brand look | + +Every derived color is lifted into a band a glow reads well in — lightness 0.55–0.70, saturation at least 0.55 — so a black, white, or gray seed still yields visible, distinguishable blobs rather than a smudge. That clamping is deliberate and is why `fromSeed` cannot reproduce a very dark or very desaturated brand color exactly; when you need the exact color, use `custom`. + +```dart +BorderBeam.rotate( + colors: const BeamColors.fromSeed( + Color(0xFF18A8F0), + harmony: BeamSeedHarmony.complementary, + ), + child: card, +); +``` + +`BeamColors.fromScheme` is the Material shortcut over the same machinery: it takes `primary`, `secondary`, and `tertiary`, dropping any role that is within a small RGB distance of one already kept — so a scheme whose secondary matches its primary yields a two-color palette rather than a doubled one. + +## Transforming a palette + +`lerp` and `scaleAlpha` produce new palettes from existing ones and are themselves values, so they can live in a `const` expression or be rebuilt per frame. + +```dart +// Crossfade two palettes from an animation. +BorderBeam.rotate( + colors: BeamColors.lerp(BeamColors.ocean, BeamColors.ember, animation.value), + child: card, +); + +// Dim without touching layer opacity. +BorderBeam.rotate(colors: BeamColors.neon.scaleAlpha(0.5), child: card); +``` + +`lerp` interpolates table-by-table, keeping `a`'s geometry and cycling `b`'s colors where its table is shorter. `t` is not clamped, so values outside 0–1 extrapolate. The mono modifiers come from whichever end is nearer, and the opacity multiplier is interpolated. + +`scaleAlpha` dims the *palette* rather than the layer opacities, so the relative depth of the inner, stroke, and bloom tables survives. `style.strength` is the other lever: it scales the composited layers instead. Reach for `scaleAlpha` when a palette is too hot, and `strength` when the whole effect is. + +## Per-blob control + +`BeamColors.spec` replaces the border table outright. Sizes are **radii**, not diameters — they map straight onto CSS `radial-gradient(ellipse W H …)`, whose sizes are radii too — and positions are fractions of the painted box, so values outside 0–1 legitimately sit on or beyond the edge. + +```dart +const palette = BeamColors.spec( + border: [ + BeamBlob( + color: Color(0xFFFF4D8D), + position: Offset(0.33, -0.074), + size: Size(70, 40), + ), + BeamBlob( + color: Color(0xFF18A8F0), + position: Offset(0.72, 1.05), + size: Size(90, 50), + ), + ], +); +``` + +Tables you do not supply (`smallBorder`, `lineBlobs`) are derived by cycling your border colors over the default geometry, so a `spec` palette still works on every variant. + +## Values, equality, and the memo + +Every `BeamColors` is a value type: two instances built from equal inputs are `==`. Resolution to concrete tables is memoized by that value through a bounded LRU (32 entries), so building a palette inline in `build` is free after the first frame, and an evicted palette is simply rebuilt and compares equal to the one it replaced. The four upstream presets bypass the LRU entirely — they are canonical const instances, memoized by identity for the life of the isolate. + +The practical consequence: **never** put a palette behind a factory that returns a fresh non-equal object each build (a `Color` list built with `List.generate` is fine — it compares by element; a closure is not). If a beam re-resolves its config every frame, an unequal palette is the first thing to check. diff --git a/doc/parity.md b/doc/parity.md new file mode 100644 index 0000000..a043e13 --- /dev/null +++ b/doc/parity.md @@ -0,0 +1,124 @@ +# Parity with the original + +This package is a port, not an interpretation. The visual constants come from the source, and the deviations are deliberate, few, and listed here. + +Tracked upstream: **[border-beam](https://github.com/Jakubantalik/Libraries/tree/main/packages/border-beam) 1.4.0**, MIT, by [Jakub Antalik](https://x.com/jakubantalik). That npm release is visually identical to 1.3.0, and its machine-readable spec still declares `1.3.0` as the visual baseline — which is the number `upstreamLibraryVersion` records, because it is the one the tables are audited against. + +## Where the constants come from + +Everything in `lib/src/constants/` is a **verbatim transcription** of the tables in the source's `src/styles.ts`: + +| File | Upstream content | +| --- | --- | +| `palettes.dart` | the four preset blob tables (`colorful`, `mono`, `ocean`, `sunset`) | +| `theme_presets.dart` | `sizeThemePresets` — layer opacities, inset shadows, filter multipliers per variant × brightness | +| `pulse_params.dart` | `pulseParams` — breathing parameters per variant, theme, and cycle length | +| `pulse_tables.dart` | the pulse blob tables and their oscillator/quadrant assignments | +| `pulse_constants.dart` | the pulse engine's fixed scalars | +| `line_keyframes.dart` | the line variant's keyframe tracks | +| `line_geometry.dart` | the line variant's blob geometry | +| `rotate_stops.dart` | the rotate/small gradient stops | + +`extra_palettes.dart` is the one file in that directory that is **not** a transcription: it holds the seven Flutter-only presets, defined as short color lists distributed over `colorful`'s geometry. Editing it changes how those presets look and has no bearing on parity. + +`upstream.dart` records the provenance of the whole directory — `upstreamLibraryVersion`, `upstreamSpecVersion`, and `upstreamRepository`. It is internal (the barrel does not export it) and is the single place those strings live, so the parity test reads them rather than restating them. + +**Never tweak a value in `lib/src/constants/`.** Visual parity depends on them, and a value that looks wrong is far more likely to be a transcription that is right for a reason. Verify against the source before changing anything: + +```console +git clone https://github.com/Jakubantalik/Libraries /tmp/border-beam-upstream +``` + +## How the transcription is verified + +Transcription is exactly the kind of work a human does badly and a test does well, so it is not left to review. + +The upstream library ships a machine-readable `spec/beam-spec.json` generated from its `src/styles.ts`. That file is vendored, byte for byte, at `test/fixtures/beam-spec.json`, with its provenance (commit, versions, and a SHA-256 of the `src/styles.ts` it was generated from) alongside it in `test/fixtures/UPSTREAM`. `test/constants/spec_parity_test.dart` reads the fixture and asserts every table in `lib/src/constants/` against it, entry by entry — handling the conventions that differ between the two worlds along the way: CSS `rgb()`/`rgba()` colors, `"33% -7.4%"` positions as fractional offsets, `"70px 40px"` sizes as **radii**, and percent stops against fractional ones. + +Refreshing the fixture is one command: + +```console +tool/spec/refresh.sh # from upstream main +tool/spec/refresh.sh # from a specific ref +``` + +It is idempotent, so re-running against an unchanged upstream leaves the tree untouched. Afterwards, re-read `upstream.dart` — its version strings are hand-maintained — and run `flutter test test/constants/spec_parity_test.dart`. + +`.github/workflows/upstream_drift.yaml` watches the hash: a change to upstream's `src/styles.ts` that never reached the spec generator still raises the alarm. + +A drifted constant therefore fails a unit test rather than a golden, which means it *names* the value instead of showing a diff of blurry pixels. And when the test does fail, the fix is to re-audit against `src/styles.ts` and report the divergence — **never** to change a constant until the test passes. + +The goldens are the second layer. `test/golden/` freezes the fake clock at a known time and captures every variant × theme × palette, with the traveling variants captured a second time further along their sweep. They catch changes in how the values are *used*, where the constant test catches changes in the values themselves. + + +## Deliberate deviations + +### Fade envelope: a spring, not CSS `ease` + +Activation and deactivation fade over 0.6s and 0.5s. The original eases them with CSS `ease`; this package eases them with a spring (mass 1, stiffness 180, damping 20), which settles more naturally under interruption — a beam toggled off mid-fade-in carries its velocity instead of restarting a curve. + +For an exact match, `BeamPlayback.cssEase` is `cubic-bezier(0.25, 0.1, 0.25, 1)`: + +```dart +BorderBeam.rotate( + playback: const BeamPlayback(fadeCurve: BeamPlayback.cssEase), + child: card, +); +``` + +`fadeCurve` accepts any `Curve`, so the envelope is fully yours. + +### Reduced motion: all five variants + +The original applies reduced motion to the pulse variants only, and its web build hides them; the iOS and React Native ports do the same. This package applies it to all five and defaults to a **static frame** rather than hiding — a beam that vanishes takes its meaning with it, while a still frame keeps marking the state. `BeamReducedMotion.hide` restores the upstream web behavior. See [accessibility](accessibility.md). + +### pulse-outside defaults + +The original's demo page applies its own tuning on top of the library's pulse-outside defaults — insets and blurs scaled by the element's size, melting the separate blobs into one continuous edge-hugging glow, plus a prominence boost and a glow multiplier. That tuned look is what people recognize as "the pulse-outside effect", so it is what `BorderBeam.pulseOutside` ships. + +`BeamStyle.pulseOutsideStock` rolls all of it back — the boost and multiplier through the hooks that carry them (`glowBoost`, the three opacity factors, `glowBrightness`/`glowSaturation`) and the geometry through `pulseOutsideTuning` — leaving the library's own defaults: a tighter, dimmer halo sitting closer to the child. + +```dart +BorderBeam.pulseOutside(style: BeamStyle.pulseOutsideStock, child: card); +``` + +Layer your own fields over it with `copyWith` or `merge`; anything you set wins. `BeamPulseOutsideTuning` on its own switches only the glow geometry (`demo`, the default, or `stock`). + +Both sets are the source's numbers; they differ only in which is the default. + +### `BeamTheme.auto` follows the app, not the OS + +Upstream, `auto` follows the OS color scheme. Here it follows `Theme.of(context).brightness`, because in Flutter the app's own theme is what a widget actually sits on: a card in a dark sheet inside a light app should get the dark tuning, and asking the OS would get it wrong. `BeamTheme.dark` and `BeamTheme.light` pin it explicitly. + +### No child-radius auto-detection + +The original reads its child's radius. This package asks you to pass it, because it also wraps widgets with no decoration to read — a `Text`, a `Row`, a third-party card — and a wrong guess is worse than a required argument. See [shape](shape.md#match-the-child-yourself). + +## Flutter-only additions + +None of these exist upstream: + +**Color** — `hueBase`; `BeamColors.custom`, `fromSeed` (four harmonies), `fromScheme`, `lerp`, `scaleAlpha`, `spec`; and the seven extra presets (`aurora`, `neon`, `candy`, `ember`, `ice`, `gold`, `holographic`). + +**Shape** — per-corner direction-aware radii, `BeamShape.stadium`, superellipse corners, `ringOffset`, `edge`, arbitrary `BeamContour` paths, `BeamShape.segment`, and `BeamShape.wrapCorners`. + +**Motion** — `cycleGap`, `direction`, `phaseOffset`, `beamCount`, `segments`, `tailLength`, `glowSpread`, `comet`, `sparkle`, `renderScale`, `innerSizeScale`, driven `progress`, pointer `follow`, `strengthListenable`, `speedListenable`, and `BeamSync`. + +**Playback & API** — the value-object API itself, `BorderBeamTheme`, `BorderBeamController` (including `pulse()` and `flash()`), `BeamRepeat`, `pauseWhenOffscreen`, `debugFrozenAt`, `fadeCurve`, and `pulseOutsideTuning` as a selectable field. + +**Surfaces** — `BorderBeam.overlay`, `BeamDecoration`, `BeamFocusRing`, `BeamHover`, `BeamPress`. + +The React original has no segment API. `BeamSegment` is a Flutter extension that masks the unchanged full-contour animation in perimeter space. The original author's SwiftUI showcase composes the half-phone look with a screen-space gradient mask over a full beam; this package expresses that composition as `shape.segment` and keeps it correct for arbitrary sizes, radii, offsets, and contours. + +## CSS ↔ Flutter mapping + +Four conventions account for most of the translation, and getting one wrong produces a subtly-off effect rather than an obvious break: + +| CSS | Flutter | +| --- | --- | +| `conic-gradient` starts at 12 o'clock | `SweepGradient` starts at 3 o'clock — the −90° rotation is baked in | +| `radial-gradient(ellipse W H …)` sizes are **radii** | passed straight through as `radiusX` / `radiusY` | +| `filter: blur(Npx)` | `MaskFilter.blur` with **sigma = N** | +| a gradient fading out | `color.withValues(alpha: 0)`, never `Color(0x00000000)` — transparent black lerps through black | + +Layer opacity is a multiplied chain — fade × preset × mono × hook × strength — clamped at paint time, which is why presets may legitimately exceed 1 (line/dark's stroke is 1.14 in the source). diff --git a/doc/performance.md b/doc/performance.md new file mode 100644 index 0000000..7e4dfa1 --- /dev/null +++ b/doc/performance.md @@ -0,0 +1,74 @@ +# Performance + +A beam is an animated blur — the most expensive thing a phone GPU does per pixel. The engine is built around keeping that cost fixed and small, and around never paying it for a beam nobody can see. + +## What the engine does for you + +**One ticker per beam.** Not per layer, not per track. Every animated value is recomputed from one elapsed-seconds number each frame, so there is no per-track state to advance and no controller graph to walk. Under a [`BeamSync`](motion.md#beamsync) the whole subtree shares a single ticker. + +**A ~30fps cap on the pulse variants**, matching the original's pulse driver. Time still accumulates at full resolution — only notifications are throttled — so the animation is not slowed, just sampled less often. A `pulse()` or `flash()` boost lifts the cap while it plays, because a brightness accent at 30fps reads as a step. + +**`RepaintBoundary` isolation, twice.** The beam sits in its own boundary, and the child sits in another one inside it. The consequence is the one that matters: **your child never re-rasterizes** when the beam animates, however complex the child is. + +**Color matrices folded on the CPU.** Hue, brightness, and saturation are baked into the gradient colors before painting rather than carried on a `ColorFilter`. A filter needs a layer to ride on; folding it costs a few multiplications per frame instead. + +**An offscreen pause.** With `playback.pauseWhenOffscreen` (the default), a beam watches its nearest enclosing `Scrollable` and stops its clock once it is more than 256px outside the viewport, resuming exactly where it left off — play state, fade, and callbacks untouched. A long list of beamed cards therefore costs the beams you can see, not the beams you have. With no enclosing `Scrollable` it does nothing. + +## The saveLayer budget + +`saveLayer` is the expensive call: it allocates an offscreen surface, paints into it, and composites it back. The per-frame count — `paintBehind` plus `paintAbove` — is fixed per variant and **measured**, not estimated: + +| Variant | `saveLayer`s per frame | +| --- | --- | +| `rotate` | 4 | +| `small` | 3 | +| `line` | 4 | +| `pulseInside` | 4 | +| `pulseOutside` | 3 | + +Each entry is a layer the variant genuinely composites: the inner glow, the stroke, the bloom, the mask sub-layer where two masks intersect (`rotate`, `line`, `pulseInside`), and pulse-outside's two behind-child glows. + +A segment adds two `dstIn` gradient draws per painted layer and zero `saveLayer`s; the budget test includes segment rows to pin that invariant. + +`test/painting/save_layer_budget_test.dart` measures the count through a counting canvas for every variant × brightness × palette × time sample and asserts it **exactly**, along with save/restore balance. A regression and an improvement both fail the suite until the table is updated in both the test and this document — which is the point: the number is a contract, not a high-water mark. + +The rule the engine follows: never add a `saveLayer` to carry a `ColorFilter`. A filter rides on a layer that exists anyway, and blur is the only filter allowed to justify a layer of its own. + +## Measuring your own scene + +**Count the layers a widget paints.** The same technique the budget test uses works on any widget: + +```dart +import 'dart:ui' as ui; + +final recorder = ui.PictureRecorder(); +final canvas = Canvas(recorder); +// Paint your beam's painter into `canvas`, then inspect the recorded picture. +``` + +For a scene rather than a painter, the simplest reliable count is DevTools: open the **Performance** view, enable **Track widget builds**, and use the *Raster* timeline — `saveLayer` calls show up as separate raster work in the frame's layer tree. + +**Check the raster thread, not the UI thread.** A beam does almost nothing on the UI thread; its cost is entirely raster. A frame budget blown by beams shows up as a tall raster bar with a short UI bar. + +**Toggle the two DevTools flags that matter here:** + +- *Highlight repaints* — every beam should show its own boundary changing color while the child inside it does not. If the child flashes too, something above the beam is invalidating it. +- *Render Surface Layers* (`debugRepaintRainbowEnabled` / the raster overlay) — confirms the beam is compositing on its own surface rather than forcing the page to. + +**Profile in profile mode, on a device.** Blur cost in debug mode on a simulator tells you nothing. + +## Getting a scene cheaper + +In rough order of how much they buy: + +1. **Use fewer beams.** A beam is emphasis; a screen where everything is emphasized has none. One hero beam almost always looks better than six. +2. **Put a group on a `BeamSync`.** Ten beams become one ticker and one timeline, and the group reads as deliberate rather than noisy. +3. **Prefer `small` and `pulseOutside`** where the design allows — they are the two three-layer variants. +4. **Drop `sparkle` and `comet`** on a scene that is already tight; both add geometry to the traveling head. +5. **Lower `renderScale`** on very large boxes. It paints the beam smaller and scales up, which cuts blur cost as well as making a card-sized palette read at screen size. +6. **Keep `pauseWhenOffscreen` on.** Turn it off only when a beam must stay phase-locked to something visible while it is itself out of view. +7. **Keep palettes value-equal.** A palette that compares unequal each build re-resolves the config and rebuilds the phase resolver every frame. See [palettes](palettes.md#values-equality-and-the-memo). + +## Web + +The package compiles and runs on web — the [playground](https://simplifyjobs.github.io/flutter_border_beam/) is a web build of the example app, and CI builds it on every commit. Blur is the expensive part on every web renderer, so `line` and `pulseOutside` cost the most there. A single hero beam is a safe bet; a grid of them is not. diff --git a/doc/shape.md b/doc/shape.md new file mode 100644 index 0000000..fc315ca --- /dev/null +++ b/doc/shape.md @@ -0,0 +1,224 @@ +# Shape + +`BeamShape` describes the geometry the beam travels: corner radii, ring thickness, the corner family, how far the ring sits from the child, an optional partial segment or line corner wrap, and — when a rounded rectangle is not enough — an arbitrary path. The [README's shape table](../README.md#shape) lists the fields and defaults; this guide covers the parts with sharp edges. + +## Three constructors, one shape + +```dart +const BeamShape.all(24); // const — stores the number +BeamShape.circular(24); // not const — builds a BorderRadius +const BeamShape.stadium(); // a pill; a circle on a square box +const BeamShape(radius: someGeometry) // per-corner +``` + +`BeamShape.all` and `BeamShape.circular` describe the same shape and **compare equal** for the same number. The split exists because building a `BorderRadius` from a parameter is a runtime construction, so `circular` cannot be `const`; `all` stores the double and grows it into a `BorderRadius` where `radius` is read. Use `all` in a `const` widget or a `const BorderBeamThemeData`, and `circular` when you already have a radius in `BorderRadius` terms. + +`stadium` sets an infinite radius and lets the ring geometry clamp it per corner, which is what makes it track the box as it resizes — no rebuild needed when the chip's label changes length. + +## Per-corner radii and direction + +`radius` is a `BorderRadiusGeometry`, so `BorderRadiusDirectional` works and resolves against the ambient `Directionality`: + +```dart +BorderBeam.rotate( + shape: const BeamShape( + radius: BorderRadiusDirectional.only( + topStart: Radius.circular(28), + bottomStart: Radius.circular(28), + ), + ), + child: card, +); +``` + +In an RTL subtree the same shape mirrors, exactly as the child's own decoration does. + +Radii are clamped the way `RRect.scaleRadii` clamps: when two radii on one side exceed that side's length, **all four** scale down by the smallest offending ratio — so an over-large radius shrinks the whole shape proportionally instead of deforming one corner. + +## Match the child yourself + +The beam does not read the child's decoration. That is deliberate: it wraps widgets that have no decoration to read (a `Text`, a `Row`, a third-party card), and guessing wrong is worse than asking. Pass the same number: + +```dart +const radius = 16.0; + +BorderBeam.rotate( + borderRadius: radius, + child: Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radius)), + child: content, + ), +); +``` + +A mismatch is the single most common visual complaint, and it always looks like the beam is "wrong" rather than the radius being out of sync. + +## Superellipse corners + +`superellipse: true` swaps circular corner arcs for a rounded superellipse — the Apple-style squircle, whose curvature is continuous where it meets the straight edge instead of stepping. It needs Flutter ≥ 3.35, the package's declared minimum, where `RSuperellipse` is stable. + +```dart +BorderBeam.rotate( + shape: const BeamShape.all(28, superellipse: true), + child: card, +); +``` + +It is a Flutter-only addition — the original's CSS `border-radius` produces circular arcs — so use it when your child is a squircle too, and leave it off when you want the source's exact contour. + +## Ring offset + +`ringOffset` moves the beam's ring outward (positive) or inward (negative) from the child's bounds: + +```dart +// The beam orbits 8px clear of the card. +BorderBeam.rotate( + shape: const BeamShape.all(24, ringOffset: 8), + child: card, +); + +// Tucked inside a padded surface. +BorderBeam.rotate( + shape: const BeamShape.all(12, ringOffset: -12), + child: paddedSurface, +); +``` + +A positive offset paints further outside the widget's box, so it needs the same clip-free room `pulseOutside` does — add padding in the parent, and check for a `ClipRRect` above the beam. Corner radii are not adjusted for you; an offset ring around a rounded child usually wants its radius moved by the same amount to stay concentric. + +## Arbitrary contours + +When the shape is not a rounded rectangle at all, hand the beam a path. `BeamContour.build(Rect)` is called with the beam's bounds — already grown or shrunk by `ringOffset` — and returns a closed path in the same coordinate space. `radius` and `superellipse` are ignored while a contour is set. + +`BeamPathContour` is the ready-made implementation: + +```dart +BorderBeam.rotate( + shape: BeamShape( + contour: BeamPathContour((rect) => Path()..addOval(rect), key: 'oval'), + ), + child: card, +); +``` + +The `key` is not optional garnish. **A contour is a config-cache key**, so every implementer must override `==` and `hashCode`; a contour that compares by identity re-resolves the whole beam configuration on every rebuild. Dart closures compare by identity, so an inline builder would be a new value each time — the key is what makes two contours drawing the same path equal. Any value-equal object works: a string, an enum, or a record of the parameters the builder closes over. + +For a shape parameterized at runtime, put the parameters in the key: + +```dart +class NotchedContour extends BeamContour { + const NotchedContour(this.notch); + + final double notch; + + @override + Path build(Rect rect) { + return Path() + ..moveTo(rect.left, rect.top) + ..lineTo(rect.center.dx - notch, rect.top) + ..lineTo(rect.center.dx, rect.top + notch) + ..lineTo(rect.center.dx + notch, rect.top) + ..lineTo(rect.right, rect.top) + ..lineTo(rect.right, rect.bottom) + ..lineTo(rect.left, rect.bottom) + ..close(); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NotchedContour && other.notch == notch; + + @override + int get hashCode => notch.hashCode; +} +``` + +The traveling variants walk the path's perimeter, so a contour with a very uneven perimeter distribution (a long thin spur, say) will pass through it quickly — that is arc-length behavior, not a bug. + +## Segments + +`BeamShape.segment` restricts visibility to part of the contour. It is a mask over the unchanged full-ring animation: the variant's constants, timing, and blob positions stay in full-contour space. The mask adds no compositing layers. + +Public perimeter positions are arc-length fractions measured clockwise from top-center: + +```text + 0 / 1 + ↓ + ┌──────────────┐ + 0.75 → ← 0.25 + │ │ + └──────────────┘ + ↑ + 0.5 +``` + +On a square, `0.25`, `0.5`, and `0.75` are approximately right-center, bottom-center, and left-center. Rounded corners change the exact fractions of edge landmarks because every coordinate is measured by path length, not by angle or box position. + +### Anchors and direction + +Three anchor constructors cover raw and semantic positions: + +- `BeamAnchor.fraction(t)` is an arc-length fraction; values resolve modulo one. +- `BeamAnchor.edge(edge, t)` follows the named straight run in clockwise travel: left-to-right on top, top-to-bottom on right, right-to-left on bottom, and bottom-to-top on left. `t` defaults to `0.5`. +- `BeamAnchor.corner(corner, t)` follows the named corner arc clockwise. `t` defaults to `0.5`; corners are named by `BeamCorner.topLeft`, `topRight`, `bottomRight`, and `bottomLeft`. + +The static anchors `topCenter`, `rightCenter`, `bottomCenter`, and `leftCenter` name the four common landmarks. + +`BeamSegment(start: ..., end: ..., feather: 32)` covers the clockwise span from `start` to `end`. When the end fraction is smaller than the start fraction, the span wraps through the `0` / top-center origin. Swapping the endpoints selects the complementary span. `feather` is the distance in logical pixels along the perimeter over which each end fades; `0` produces a hard cut. + +```dart +BorderBeam.rotate( + shape: const BeamShape( + radius: BorderRadius.all(Radius.circular(24)), + segment: BeamSegment.bottomHalf, + ), + child: square, +); +``` + +### Presets + +| Preset | Clockwise coverage | +| --- | --- | +| `bottomHalf` | right-center → bottom-center → left-center | +| `topHalf` | left-center → top-center → right-center | +| `leftHalf` | bottom-center → left-center → top-center | +| `rightHalf` | top-center → right-center → bottom-center | +| `bottomEdge` | bottom-right arc + bottom straight run + bottom-left arc | +| `topEdge` | top-left arc + top straight run + top-right arc | +| `leftEdge` | bottom-left arc + left straight run + top-left arc | +| `rightEdge` | top-right arc + right straight run + bottom-right arc | + +`bottomHalf` produces the half-phone composition: both bottom corners, the bottom edge, and the lower halves of both sides. + +### Variant behavior + +- **`rotate` and `small`:** the conic window keeps its full sweep. In forward travel the beam appears at the segment start, moves clockwise to its end, and is invisible for the rest of the cycle. +- **`line`:** the streak travels the segment in border-path space. For `bottomHalf` it descends the right edge, rounds the bottom-right corner, crosses the bottom, rounds the bottom-left corner, and climbs the left edge to the endpoint, with the usual fade at both ends of travel. `BeamShape.edge` is ignored when a segment is set. +- **`pulseInside` and `pulseOutside`:** blobs outside the segment are hidden and blobs near an endpoint fade. `bottomHalf` therefore leaves only the lower blobs breathing. + +`BeamTiming.direction` changes travel direction without changing which clockwise span the segment selects. Stack a forward beam and a second beam with `direction: BeamDirection.reverse` for two counter-sweeping arcs. `beamCount` places multiple travelers on the same unchanged full-contour timeline, and the segment reveals each only while it crosses the selected span. Pulse variants ignore both travel settings. `cycleGap` adds the usual invisible rest to traveling variants. + +`ringOffset` moves the path before its perimeter is measured, so the segment and its feather follow the offset ring. A custom `BeamContour` works too: top-center is the point on its path nearest the box's top-center, and fractions advance by measured path length. Semantic edge and corner anchors resolve against the corresponding regions of that measured perimeter. + +## Corner wrap + +`BeamShape.wrapCorners` applies to `line` when no segment is set. The default line geometry runs along the selected `edge`; with `wrapCorners: true`, the streak is placed in border-path space and bends around the two corner arcs adjacent to that edge instead of continuing straight past them. + +```dart +BorderBeam.line( + shape: const BeamShape.all( + 24, + edge: BeamEdge.bottom, + wrapCorners: true, + ), + child: card, +); +``` + +A segment already places the line in border-path space and defines its endpoints, so it takes precedence over both `edge` and `wrapCorners`. + +## Border width + +`borderWidth` is the stroke ring's thickness, 1px by default and in the source. It is independent of your child's border: the beam's ring is a painted glow, not a border you can lay out against. On a hairline-bordered card, keeping both at 1 makes the beam read as the border lighting up rather than as a second ring around it. diff --git a/doc/theming.md b/doc/theming.md new file mode 100644 index 0000000..e351875 --- /dev/null +++ b/doc/theming.md @@ -0,0 +1,115 @@ +# Theming + +Two different things are called "theme" here, and keeping them apart is most of the work: + +- **`BeamTheme`** (`dark` / `light` / `auto`) is how one beam adapts to the background it sits on. It selects a preset. +- **`BorderBeamTheme`** is an `InheritedWidget` supplying default field values to every beam below it. + +## Background adaptation — `BeamTheme` + +Each variant has one tuned config per brightness: layer opacities, an inset shadow color, and default brightness/saturation multipliers. `style.theme` picks which: + +```dart +// Always the dark-background tuning, whatever the app theme says. +BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark), + child: card, +); +``` + +`BeamTheme.auto` — the default — follows `Theme.of(context).brightness`. That is a deliberate deviation from the original, which follows the OS color scheme: in Flutter the app's own theme is the thing a widget sits on, and a card in a dark sheet inside a light app should get the dark tuning. Force `dark` or `light` when a surface's brightness differs from the ambient theme's. + +### Replacing a preset + +`BeamThemeConfig` is public, so the whole preset can be replaced through `style.themeConfig`. Start from a preset and move the one field you want: + +```dart +BorderBeam.rotate( + style: BeamStyle( + themeConfig: BeamThemeConfig.presetFor( + BeamVariant.rotate, + Brightness.dark, + ).copyWith(bloomOpacity: 0.4, innerShadow: const Color(0x00000000)), + ), + child: card, +); +``` + +Its fields are `strokeOpacity`, `innerOpacity`, `bloomOpacity`, `innerShadow`, `saturation`, `brightness`, and `hairlineOpacity` (pulse-outside's static 1px edge, preset to 0 so the child's own border provides it). Opacities may legitimately exceed 1 — the source overdrives line/dark's stroke to 1.14 — and the painted product is clamped. + +`style.brightness` and `style.saturation` still apply **on top of** a replaced config, so a `themeConfig` sets the baseline rather than the last word. + +## Defaults for a subtree — `BorderBeamTheme` + +`BorderBeamThemeData` has one slot per value object, and each slot's fields are nullable, so a theme fills in only what it names: + +```dart +BorderBeamTheme( + data: const BorderBeamThemeData( + style: BeamStyle(colors: BeamColors.ocean, strength: 0.85), + shape: BeamShape.all(20, superellipse: true), + timing: BeamTiming(cycle: Duration(seconds: 3)), + ), + child: MaterialApp(home: home), +); +``` + +Every beam below now defaults to the ocean palette at 85% strength, a 20px squircle, and a 3s cycle — and each can still override any single field. + +### Nesting and merge + +`BorderBeamTheme.of` walks **every** enclosing scope, registers the context as a dependent of each, and merges them outside-in. An inner theme therefore overrides only the fields it sets, and a change to an *outer* theme rebuilds the beam just as an inner one does. + +```dart +BorderBeamTheme( + data: const BorderBeamThemeData( + style: BeamStyle(colors: BeamColors.ocean, strength: 0.85), + ), + child: BorderBeamTheme( + // Keeps strength 0.85; overrides only the palette. + data: const BorderBeamThemeData(style: BeamStyle(colors: BeamColors.ember)), + child: section, + ), +); +``` + +That is the point of the all-nullable value objects: `merge` is field-by-field, so themes compose instead of replacing each other wholesale. + +### Where a theme sits in the order + +```text +flat shorthand > value object on the widget > BorderBeamTheme (inner→outer) > variant default +``` + +`colors`, `active`, and `borderRadius` on the widget are shorthands that fold into `style.colors`, `playback.active`, and `shape.radius` respectively, and win over the same field set in the object next to them. + +### Const-friendly by design + +`BeamShape.all` exists so a `BorderBeamThemeData` can be `const` — `BeamShape.circular` builds a `BorderRadius` at runtime and cannot be. `const BorderBeamThemeData(shape: BeamShape.all(20))` therefore costs nothing per rebuild, and `updateShouldNotify` compares by value, so a rebuilt-but-equal theme notifies nobody. + +### Themeing playback + +The playback slot is a genuine default, not an override: putting `startAfter` or `duration` in a theme applies them to every beam below, including beams that also take a `BorderBeamController`. The controller asserts in that case — it owns playback exclusively — so keep scheduling out of app-wide themes and set `reducedMotion` there instead, which is the field that genuinely benefits from being set once: + +```dart +BorderBeamTheme( + data: const BorderBeamThemeData( + playback: BeamPlayback(reducedMotion: BeamReducedMotion.hide), + ), + child: app, +); +``` + +## `BeamDecoration` and themes + +A `BoxPainter` has no `BuildContext`, so a `BeamDecoration` inherits nothing on its own. Pass the theme in explicitly: + +```dart +BeamDecoration( + variant: BeamVariant.rotate, + brightness: Theme.of(context).brightness, + theme: BorderBeamTheme.of(context), +); +``` + +Without `theme:` the decoration resolves against variant presets only. diff --git a/doc/variants.md b/doc/variants.md new file mode 100644 index 0000000..15878b5 --- /dev/null +++ b/doc/variants.md @@ -0,0 +1,104 @@ +# Variants + +Five effects, two engines. The [README's variant table](../README.md#variants) lists the defaults; this guide is about which one to reach for and how each behaves. + +## The two families + +**Traveling** (`rotate`, `small`, `line`) sweeps a window around the beam's contour once per `cycle`. Everything about travel applies: `direction`, `cycleGap`, `beamCount`, `phaseOffset`, `progress`, `follow`, `segments`, `tailLength`. + +**Pulse** (`pulseInside`, `pulseOutside`) breathes. A bank of independent oscillators scales, drifts, and fades the palette's blobs so that neighbouring blobs never move in lockstep; there is no sweep, so the travel fields above do nothing. Their hue advances continuously rather than swinging, and their clock is capped at ~30fps. + +`BeamVariant.isPulse` answers the question in code, and is what the widget uses to pick an engine. + +## `rotate` — the default + +A beam travels the full border of the box. Tuned for cards, panels, and any surface big enough that a full lap reads as motion rather than a flicker. + +```dart +BorderBeam.rotate(borderRadius: 16, child: card); +``` + +It is the variant most affected by the traveling-only style fields — `comet` trails a soft halo outside the ring behind the head, and `sparkle` scatters twinkles at it: + +```dart +BorderBeam.rotate( + style: const BeamStyle(comet: true, sparkle: 0.5, tailLength: 1.3), + child: card, +); +``` + +## `small` — buttons and chips + +The same traveling engine at a compact scale, with a 32px default radius. Reach for it on anything the size of a button, an icon, or a chip, where `rotate`'s geometry would overwhelm the element. It is the cheapest variant on the [saveLayer budget](performance.md). + +```dart +BorderBeam.small(shape: const BeamShape.stadium(), child: chip); +``` + +## `line` — inputs + +A beam that rides one edge rather than lapping the box, with a bloom that spikes and breathes underneath it. Built for text fields and search bars, where a full border lap fights the caret for attention. + +```dart +BorderBeam.line(child: searchBar); +``` + +Three things are specific to it: + +- **Its hue range is capped at 13°**, however large a `hueRange` you set. That cap is in the original, and a wider swing on a single bright edge reads as a color bug rather than an animation. +- **It has a second hue track** for the bloom, on its own `bloomHuePeriod` (8s). +- **It has breathe and spike tracks**, whose periods are given as multiples of `cycle`: `breatheFactor` (1.3), `spikeFactor` (1.33), `spike2Factor` (1.7). They are deliberately incommensurate with the cycle, so the beam never repeats exactly. + +`BeamShape.edge` moves it to any side of the box: + +```dart +BorderBeam.line(shape: const BeamShape(edge: BeamEdge.top), child: banner); +``` + +The `mono` palette does **not** get the ×0.5 opacity treatment here; the line variant expresses mono as attenuated spikes inside the bloom instead. + +## `pulseInside` — a contained working state + +A breathing glow held inside the child's bounds. It is the safe pulse: nothing paints outside the box, so it drops into a list, a grid, or a clipped surface without any layout accommodation. Good for "this row is working", a pressed state (`BeamPress` defaults to it), or a subscribe button that wants attention without a sweep. + +```dart +BorderBeam.pulseInside(active: isWorking, child: card); +``` + +`innerSizeScale` sets how much of the surface the glow fills — below 1 it tightens toward the border, above 1 it reaches further in — and `glowBoost` sets how prominent it is. + +## `pulseOutside` — a halo + +The same breathing, blooming *behind and outside* the child. It is the most striking variant and the only one with requirements attached: + +> The child must be **opaque**, so only the outward spill shows. It should carry its **own 1px border**, which is the edge you see when the glow is at its dimmest. And it needs **clip-free room** — padding in the parent, no tight `ClipRRect` — because a halo with nowhere to bloom is invisible. + +```dart +Padding( + padding: const EdgeInsets.all(32), + child: BorderBeam.pulseOutside( + child: DecoratedBox( + decoration: BoxDecoration( + color: surface, + border: Border.all(color: hairline), + borderRadius: BorderRadius.circular(16), + ), + child: content, + ), + ), +); +``` + +It is the only variant that paints on the **behind** pass, which is why it is the only one that goes in a `Container`'s `decoration:` rather than `foregroundDecoration:` when used as a [`BeamDecoration`](../README.md#beamdecoration). + +Its four glow overrides — `coreBlur`, `bloomBlur`, `glowBrightness`, `glowSaturation` — port the original's consumer tuning hooks and apply to nothing else. This package's defaults bake the tuning the original's web demo applies; `BeamStyle.pulseOutsideStock` is the library's stock look. See [parity](parity.md). + +## Choosing at runtime + +The generic constructor takes a `BeamVariant`, so a variant that comes from state, a theme, or a settings screen needs no switch: + +```dart +BorderBeam(variant: variant, colors: BeamColors.aurora, child: card); +``` + +Swapping the variant on a live beam is supported: the widget rebuilds its clock and its strategy, and the old ticker is disposed. diff --git a/example/README.md b/example/README.md index c3a47ab..694b843 100644 --- a/example/README.md +++ b/example/README.md @@ -1,18 +1,120 @@ -# border_beam example +# flutter_border_beam example -A gallery recreating the original [border-beam demo](https://beam.jakubantalik.com) in Flutter: Rotate/Pulse example tabs with mock chat inputs, task cards, and search bars, plus an interactive playground (variant, palette, strength, squircle, play/pause) with live code snippets. Dark/light theme toggle included; styling uses the demo's own design tokens, not Material defaults. +The gallery app for the [`flutter_border_beam`](../) package — a Flutter +recreation of the original [border-beam demo](https://beam.jakubantalik.com). ```bash -flutter run +flutter run # phone / desktop +flutter run -d chrome ``` +## What's in it + +- **Rotate** and **Pulse** example tabs, wrapping mock chat inputs, task cards, + and search bars — the same surfaces the original demo uses. +- **Themed** — three variants under a single `BorderBeamTheme`. Each card sets + nothing but its variant, so colors, shape, and cycle all come from the theme. +- **Rest between sweeps** — a `cycleGap` beam that parks at the end of its + travel and fades away before the next sweep. +- **Partial contours** — the half-phone composition on rotate, line, and + pulse-inside, plus a full line bending through adjacent corners. +- **Palettes** — a card per preset: colorful, mono, ocean, sunset, aurora, neon, + candy, ember, ice, gold, holographic. +- **Surfaces** — the beam reached without the wrapper: a `BeamDecoration` in a + `Container`'s `foregroundDecoration`, a `BeamFocusRing` around a focusable + field, and `BeamHover` / `BeamPress` cards. +- **Motion** — one card each for `direction: reverse`, `direction: bounce`, + `beamCount: 3`, `segments: 8`, and the comet tail. +- **Driven progress** — a rotate ring whose sweep is parked by a looping + `AnimationController` value instead of the clock. +- **Sync** — four cards on one `BeamSync` clock, spaced by `phaseOffset`. +- **Playground** — every meaningful field of the API, live (below). +- A dark/light theme toggle. Styling comes from the demo's own design tokens + (`lib/src/demo_theme.dart`), not Material defaults. + +## Playground + +`lib/src/playground/` holds the state bag every control drives +(`playground_state.dart`), the shareable-link codec (`share_codec.dart`), the +snippet generator (`snippet.dart`), the control widgets (`controls.dart`), and +the section itself (`playground_section.dart`). + +An install line sits above it: `flutter pub add flutter_border_beam` with a +Copy chip, beside a chip that copies the repository link. + +Controls are grouped into collapsible sections: + +| Section | Controls | +| --- | --- | +| Variant & colors | variant; palette — the eleven presets plus three assembled modes: **Custom** (2–4 swatches over a base preset, fed to `BeamColors.custom`), **Seed** (one swatch + a `BeamSeedHarmony`, fed to `BeamColors.fromSeed`), and **Lerp** (two presets and a blend slider, fed to `BeamColors.lerp`); an alpha-scale slider over any of them (`BeamColors.scaleAlpha`) | +| Shape | stadium, per-corner, squircle, and star-contour toggles; a corner-radius slider (or four, in per-corner mode); border width; ring offset; segment presets or custom edge anchors with feathering; the travelled edge and corner wrap on a segment-free line | +| Timing | cycle, cycle gap, speed, hue period; direction, phase offset, and beam count on the traveling variants; breathe / spike / spike 2 on the line variant; a static-colors toggle | +| Style | strength, brightness, saturation, hue range, hue mode, hue base, the three layer-opacity factors, glow spread, render scale; tail length and the comet tail on rotate and small; sparkle on the traveling variants; ring segments off the line variant; `glowBoost` and inner size on the pulse variants; the stock recipe, core blur, bloom blur, glow brightness, and glow saturation on pulse-outside | +| Playback | active toggle; controller mode with start / pause / resume / stop / pulse / flash and its own speed; `startAfter` and `duration` outside controller mode; repeat (forever / once / 3 cycles); reduced motion, with a toggle that simulates it; offscreen pause; the fade curve (spring or `BeamPlayback.cssEase`) | +| Drive | `progress:` with a position slider, `follow:` fed by the pointer over the preview, and `strengthListenable:` fed by a sine wave | +| Theme | wraps the preview in a `BorderBeamTheme` carrying ocean colors and a squircle-20 shape, and swaps it for three `BeamSync` beams a third of a cycle apart | + +A few conventions worth knowing: + +- **The preview and the snippet come from the same rule.** Both emit only what + differs from the package default, so a fresh playground prints the one-liner, + and with the theme toggle on, a control left at its default inherits from the + `BorderBeamTheme` while anything you set wins. +- **`auto` means "let the package decide".** Fields whose default depends on the + variant or a theme preset (cycle, hue period, brightness, saturation, and the + pulse-outside glow overrides) carry an `auto` chip that clears the override. +- **Controller mode owns playback.** With a controller attached, `startAfter`, + `duration`, `active`, and `timing.speed` belong to it, so those controls are + disabled and drop out of the snippet. +- **Two previews.** When the window is wide enough the configuration renders on + a dark and a light backdrop at once. `BeamTheme.auto` reads the ambient + brightness, so neither preview sets `theme:` — the snippet stays faithful. +- **Per-variant controls.** A field only appears where it reaches the painter: + the travelled edge on the line variant, tail length and comet on rotate and + small, sparkle on the traveling variants, inner size on pulse-inside. The + snippet drops the same fields on the same variants. +- **Segments own line travel.** Selecting one hides the line edge and corner + wrap controls because `segment` defines the path-space endpoints. Custom + segments expose start/end edge anchors and a 0–120px feather. +- **What the snippet cannot write, it names.** A `BeamPathContour` takes a + builder, so the star contour prints as a commented placeholder; the pointer + and the strength signal print as the variables the surrounding widget would + hold (`follow: pointer`, `strengthListenable: level`). +- **Static frame is the reduced-motion default**, so that chip sets no field. + **Simulate reduced motion** is a preview concern — it wraps the preview in a + `MediaQuery` asking for reduced motion and changes nothing in the snippet. +- The **Copy** button beside the snippet puts it on the clipboard. + +### Share links + +The playground encodes itself into a compact `key=value&key=value` string +holding only the non-default fields, so the default configuration is the empty +string. On the web that string is written to the address bar, and reloading — +or opening someone's link — restores the configuration. **Copy share link** +copies the current URL on the web, and off the web a link into the published +example: + +``` +https://simplifyjobs.github.io/flutter_border_beam/#v=line&str=0.6 +``` + +Unknown keys, malformed pairs, and out-of-range values are ignored on the way +in, so a truncated or hand-edited link still opens a usable playground. + ## Demo reels -Entry points other than `lib/main.dart` are recording reels driven by `lib/demo_harness.dart` (marker contract: `::START/END` … `:DONE`). Record them with the repo's `tool/record_demo.sh`: +Entry points other than `lib/main.dart` are recording reels driven by +`lib/demo_harness.dart`: a reel is a map of scene name → widget, and the +harness mounts each scene centered on the demo backdrop, printing the markers +the recorder keys off (`::START/END`, then `:DONE`). +`lib/showcase.dart` is the README reel; `lib/pulse_outside_demo.dart` is a +single long take of the pulse-outside halo. Record with the repo's +`tool/record_demo.sh`: ```bash # from the package root, with a booted iOS simulator and ffmpeg installed tool/record_demo.sh --target lib/showcase.dart --prefix SHOWCASE --contact ``` -Output mp4s land in `.demos/` (gitignored), center-cropped at 60fps, with an optional contact-sheet PNG for review. +Output mp4s land in `.demos/` (gitignored), center-cropped at 60fps, with an +optional contact-sheet PNG for review. diff --git a/example/lib/main.dart b/example/lib/main.dart index a0948fb..788ebe7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,4 @@ -import 'package:border_beam/border_beam.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; import 'package:flutter/material.dart'; import 'src/demo_theme.dart'; @@ -7,7 +7,7 @@ import 'src/playground.dart'; void main() => runApp(const BorderBeamDemoApp()); -/// The border_beam gallery — a Flutter recreation of the original React +/// The flutter_border_beam gallery — a Flutter recreation of the original React /// demo (https://beam.jakubantalik.com): hero, Rotate/Pulse example tabs, /// and an interactive playground, styled with the demo's own tokens instead /// of Material defaults. @@ -64,11 +64,11 @@ class _DemoPageState extends State<_DemoPage> { ).copyWith(scrollbars: false, overscroll: false), child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CenteredColumn( + maxWidth: 560, children: [ _Header(onToggleTheme: theme.toggle, isDark: theme.isDark), const SizedBox(height: 36), @@ -87,15 +87,88 @@ class _DemoPageState extends State<_DemoPage> { : const _RotateExamples(key: ValueKey('rotate')), ), const SizedBox(height: 56), - const _SectionTitle('Playground'), + const _SectionTitle('Themed'), + const _SectionCaption( + 'One BorderBeamTheme above all three — each card sets ' + 'only its variant and inherits ocean + squircle 20.', + ), const SizedBox(height: 16), - const PlaygroundSection(), - const SizedBox(height: 56), - const _Footer(), - const SizedBox(height: 24), + const _ThemedExamples(), + const SizedBox(height: 40), + const _SectionTitle('Rest between sweeps'), + const _SectionCaption( + 'cycleGap parks the beam at the end of its travel and ' + 'fades it away before the next sweep starts.', + ), + const SizedBox(height: 16), + const _CycleGapExample(), + const SizedBox(height: 40), + const _SectionTitle('Partial contours'), + const _SectionCaption( + 'One segment across rotate, line, and pulse, plus a ' + 'full line that bends around its adjacent corners.', + ), + const SizedBox(height: 16), + const _PartialContourExamples(), + const SizedBox(height: 40), + const _SectionTitle('Palettes'), + const _SectionCaption( + 'The eleven presets, each on the same card. Custom, ' + 'seeded, and blended palettes live in the playground.', + ), + const SizedBox(height: 16), + const _PaletteExamples(), + const SizedBox(height: 40), + const _SectionTitle('Surfaces'), + const _SectionCaption( + 'The beam without the wrapper: a decoration in a ' + 'Container, and the focus, hover, and press wrappers ' + 'that light one on an interaction.', + ), + const SizedBox(height: 16), + const _SurfaceExamples(), + const SizedBox(height: 40), + const _SectionTitle('Motion'), + const _SectionCaption( + 'Direction, beam count, ring segments, and the comet ' + 'tail — one card each.', + ), + const SizedBox(height: 16), + const _MotionExamples(), + const SizedBox(height: 40), + const _SectionTitle('Driven progress'), + const _SectionCaption( + 'progress: parks the sweep where a value says, turning ' + 'the ring into a readout. The clock keeps running, so ' + 'the beam stays lit rather than frozen.', + ), + const SizedBox(height: 16), + const _ProgressExample(), + const SizedBox(height: 40), + const _SectionTitle('Sync'), + const _SectionCaption( + 'One clock for four beams: BeamSync spaces them with ' + 'phaseOffset instead of letting four tickers drift.', + ), + const SizedBox(height: 16), + const _SyncExamples(), ], ), - ), + const SizedBox(height: 56), + const _CenteredColumn( + maxWidth: 1040, + children: [ + _SectionTitle('Playground'), + SizedBox(height: 16), + PlaygroundSection(), + ], + ), + const SizedBox(height: 56), + const _CenteredColumn( + maxWidth: 560, + children: [_Footer(), SizedBox(height: 24)], + ), + ], ), ), ), @@ -149,6 +222,26 @@ class _Header extends StatelessWidget { } } +/// A page section held to [maxWidth] and centered — the gallery reads at +/// 560, the playground needs room for two previews beside its controls. +class _CenteredColumn extends StatelessWidget { + const _CenteredColumn({required this.maxWidth, required this.children}); + + final double maxWidth; + final List children; + + @override + Widget build(BuildContext context) => Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ); +} + class _SectionTitle extends StatelessWidget { const _SectionTitle(this.text); @@ -169,6 +262,29 @@ class _SectionTitle extends StatelessWidget { } } +class _SectionCaption extends StatelessWidget { + const _SectionCaption(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + text, + style: TextStyle( + fontSize: 12, + height: 1.5, + color: t.muted, + decoration: TextDecoration.none, + ), + ), + ); + } +} + /// A frame around each example, matching the demo's `.example-cell`. class ExampleFrame extends StatelessWidget { /// Creates a frame. @@ -215,7 +331,7 @@ class _RotateExamples extends StatelessWidget { children: [ ExampleFrame( child: BorderBeam.rotate( - theme: beamTheme, + style: BeamStyle(theme: beamTheme), borderRadius: 20, child: const MockChatInput(), ), @@ -227,7 +343,7 @@ class _RotateExamples extends StatelessWidget { child: ExampleFrame( height: 140, child: BorderBeam.small( - theme: beamTheme, + style: BeamStyle(theme: beamTheme), borderRadius: 20, child: const MockIconButton(), ), @@ -239,7 +355,7 @@ class _RotateExamples extends StatelessWidget { child: ExampleFrame( height: 140, child: BorderBeam.line( - theme: beamTheme, + style: BeamStyle(theme: beamTheme), borderRadius: 20, child: const MockSearchBar(), ), @@ -265,7 +381,7 @@ class _PulseExamples extends StatelessWidget { height: 300, clip: false, child: BorderBeam.pulseInside( - theme: beamTheme, + style: BeamStyle(theme: beamTheme), borderRadius: 20, child: const MockWorkingCard(), ), @@ -278,7 +394,7 @@ class _PulseExamples extends StatelessWidget { height: 160, clip: false, child: BorderBeam.pulseInside( - theme: beamTheme, + style: BeamStyle(theme: beamTheme), borderRadius: 20, child: const MockSubscribeButton(), ), @@ -291,7 +407,7 @@ class _PulseExamples extends StatelessWidget { height: 220, clip: false, child: BorderBeam.pulseOutside( - theme: beamTheme, + style: BeamStyle(theme: beamTheme), borderRadius: 20, child: const MockChatInput(), ), @@ -304,6 +420,488 @@ class _PulseExamples extends StatelessWidget { } } +// Defaults handed to the Themed section's three cards. Not const: building a +// BorderRadius from a number is a runtime construction, so BeamShape.circular +// cannot be const either. +final BorderBeamThemeData _galleryThemeData = BorderBeamThemeData( + style: const BeamStyle(colors: BeamColors.ocean), + shape: BeamShape.circular(20, superellipse: true), + timing: const BeamTiming(cycle: Duration(milliseconds: 2600)), +); + +/// Three variants under one [BorderBeamTheme]: each beam sets nothing but +/// its own variant, so colors, shape, and cycle all come from the theme. +class _ThemedExamples extends StatelessWidget { + const _ThemedExamples(); + + @override + Widget build(BuildContext context) { + return BorderBeamTheme( + data: _galleryThemeData, + child: Row( + children: [ + for (final entry in const [ + (label: 'rotate', variant: BeamVariant.rotate), + (label: 'small', variant: BeamVariant.small), + (label: 'pulseInside', variant: BeamVariant.pulseInside), + ]) ...[ + if (entry.label != 'rotate') const SizedBox(width: 16), + Expanded( + child: ExampleFrame( + height: 150, + clip: false, + child: SizedBox( + height: 84, + child: BorderBeam( + variant: entry.variant, + child: _ThemedCard(label: entry.label), + ), + ), + ), + ), + ], + ], + ), + ); + } +} + +/// The surface the themed cards wrap — squircle 20, matching the theme's +/// shape, since a beam never reads its child's decoration. +class _ThemedCard extends StatelessWidget { + const _ThemedCard({required this.label, this.radius = 20}); + + final String label; + final double radius; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return Container( + decoration: ShapeDecoration( + color: t.mockBg, + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.circular(radius), + side: BorderSide(color: t.mockBorder), + ), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + fontSize: 12, + color: t.mockText, + fontFamily: 'Menlo', + fontFamilyFallback: const ['Courier New', 'monospace'], + decoration: TextDecoration.none, + ), + ), + ); + } +} + +/// A beam that rests between sweeps instead of running one straight into the +/// next. +class _CycleGapExample extends StatelessWidget { + const _CycleGapExample(); + + @override + Widget build(BuildContext context) { + final theme = DemoTheme.of(context); + return ExampleFrame( + child: BorderBeam.rotate( + style: BeamStyle( + theme: theme.isDark ? BeamTheme.dark : BeamTheme.light, + ), + borderRadius: 20, + timing: const BeamTiming(cycleGap: Duration(milliseconds: 900)), + child: const MockChatInput(), + ), + ); + } +} + +/// A half-phone segment across the three painting families, beside a +/// segment-free line using border-path corner wrapping. +class _PartialContourExamples extends StatelessWidget { + const _PartialContourExamples(); + + static const BeamShape _halfShape = BeamShape.all( + 36, + segment: BeamSegment.bottomHalf, + ); + + @override + Widget build(BuildContext context) => ExampleFrame( + height: 370, + clip: false, + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + const SizedBox( + width: 180, + height: 320, + child: BorderBeam.rotate( + colors: BeamColors.aurora, + shape: _halfShape, + child: _ThemedCard(label: 'Half phone', radius: 36), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + children: [ + Expanded( + child: BorderBeam.line( + colors: BeamColors.ocean, + shape: _halfShape, + child: const _ThemedCard( + label: 'Half phone · line', + radius: 36, + ), + ), + ), + const SizedBox(height: 12), + Expanded( + child: BorderBeam.pulseInside( + colors: BeamColors.sunset, + shape: _halfShape, + child: const _ThemedCard( + label: 'Half phone · pulse', + radius: 36, + ), + ), + ), + const SizedBox(height: 12), + const Expanded( + child: BorderBeam.line( + colors: BeamColors.neon, + shape: BeamShape.all( + 28, + edge: BeamEdge.bottom, + wrapCorners: true, + ), + child: _ThemedCard(label: 'Corner wrap', radius: 28), + ), + ), + ], + ), + ), + ], + ), + ), + ); +} + +/// The eleven palette presets, one small card each. +class _PaletteExamples extends StatelessWidget { + const _PaletteExamples(); + + static const List<(String, BeamColors)> _presets = [ + ('colorful', BeamColors.colorful), + ('mono', BeamColors.mono), + ('ocean', BeamColors.ocean), + ('sunset', BeamColors.sunset), + ('aurora', BeamColors.aurora), + ('neon', BeamColors.neon), + ('candy', BeamColors.candy), + ('ember', BeamColors.ember), + ('ice', BeamColors.ice), + ('gold', BeamColors.gold), + ('holographic', BeamColors.holographic), + ]; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + // Three cards a row on the gallery column, two on a phone. + final columns = constraints.maxWidth >= 420 ? 3 : 2; + final width = (constraints.maxWidth - (columns - 1) * 12) / columns; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final (label, colors) in _presets) + SizedBox( + width: width, + height: 64, + child: BorderBeam.rotate( + colors: colors, + shape: const BeamShape.all(14, superellipse: true), + child: _ThemedCard(label: label, radius: 14), + ), + ), + ], + ); + }, + ); +} + +/// The beam reached through something other than the `BorderBeam` wrapper: +/// a decoration, and the three interaction wrappers. +class _SurfaceExamples extends StatelessWidget { + const _SurfaceExamples(); + + @override + Widget build(BuildContext context) { + final theme = DemoTheme.of(context); + final t = theme.tokens; + final brightness = theme.isDark ? Brightness.dark : Brightness.light; + return Column( + children: [ + Row( + children: [ + Expanded( + child: ExampleFrame( + height: 150, + child: Container( + width: 190, + height: 72, + foregroundDecoration: BeamDecoration( + variant: BeamVariant.rotate, + brightness: brightness, + colors: BeamColors.ocean, + borderRadius: 16, + ), + decoration: ShapeDecoration( + color: t.mockBg, + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: t.mockBorder), + ), + ), + alignment: Alignment.center, + child: _CardLabel('BeamDecoration'), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ExampleFrame( + height: 150, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const BeamFocusRing( + borderRadius: 12, + alwaysShow: true, + child: MockFocusField(), + ), + const SizedBox(height: 10), + _CardLabel('BeamFocusRing'), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ExampleFrame( + height: 150, + child: BeamHover( + borderRadius: 16, + colors: BeamColors.aurora, + child: SizedBox( + width: 190, + height: 72, + child: _ThemedCard(label: 'BeamHover', radius: 16), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ExampleFrame( + height: 150, + child: BeamPress( + borderRadius: 16, + colors: BeamColors.ember, + child: SizedBox( + width: 190, + height: 72, + child: _ThemedCard(label: 'BeamPress', radius: 16), + ), + ), + ), + ), + ], + ), + ], + ); + } +} + +/// Direction, multi-beam, segments, and comet, one card each. +class _MotionExamples extends StatelessWidget { + const _MotionExamples(); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 420 ? 3 : 2; + final width = (constraints.maxWidth - (columns - 1) * 12) / columns; + Widget cell(String label, Widget beam) => + SizedBox(width: width, height: 72, child: beam); + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + cell( + 'reverse', + const BorderBeam.rotate( + colors: BeamColors.ocean, + shape: BeamShape.all(16, superellipse: true), + timing: BeamTiming(direction: BeamDirection.reverse), + child: _ThemedCard(label: 'reverse', radius: 16), + ), + ), + cell( + 'bounce', + const BorderBeam.rotate( + colors: BeamColors.sunset, + shape: BeamShape.all(16, superellipse: true), + timing: BeamTiming(direction: BeamDirection.bounce), + child: _ThemedCard(label: 'bounce', radius: 16), + ), + ), + cell( + 'beams 3', + const BorderBeam.rotate( + colors: BeamColors.neon, + shape: BeamShape.all(16, superellipse: true), + timing: BeamTiming(beamCount: 3), + child: _ThemedCard(label: 'beamCount 3', radius: 16), + ), + ), + cell( + 'segments 8', + const BorderBeam.rotate( + colors: BeamColors.ice, + shape: BeamShape.all(16, superellipse: true), + style: BeamStyle(segments: 8), + child: _ThemedCard(label: 'segments 8', radius: 16), + ), + ), + cell( + 'comet', + const BorderBeam.rotate( + colors: BeamColors.aurora, + shape: BeamShape.all(16, superellipse: true), + style: BeamStyle(comet: true, sparkle: 0.4), + child: _ThemedCard(label: 'comet', radius: 16), + ), + ), + ], + ); + }, + ); +} + +/// A rotate ring parked by a value instead of the clock: the controller +/// loops 0→1, and the beam sits wherever it says. +class _ProgressExample extends StatefulWidget { + const _ProgressExample(); + + @override + State<_ProgressExample> createState() => _ProgressExampleState(); +} + +class _ProgressExampleState extends State<_ProgressExample> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 6), + )..repeat(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ExampleFrame( + height: 170, + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) => SizedBox( + width: 250, + height: 84, + child: BorderBeam.rotate( + colors: BeamColors.aurora, + shape: const BeamShape.all(16, superellipse: true), + progress: _controller.value, + child: _ThemedCard( + label: 'progress ${_controller.value.toStringAsFixed(2)}', + radius: 16, + ), + ), + ), + ), + ); +} + +/// Four cards on one shared clock, a quarter of a cycle apart. +class _SyncExamples extends StatelessWidget { + const _SyncExamples(); + + static const int _count = 4; + + @override + Widget build(BuildContext context) => ExampleFrame( + height: 150, + child: BeamSync( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + for (var i = 0; i < _count; i++) ...[ + if (i > 0) const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 76, + child: BorderBeam.rotate( + colors: BeamColors.ocean, + shape: const BeamShape.all(14, superellipse: true), + timing: BeamTiming(phaseOffset: i / _count), + child: _ThemedCard(label: '${i + 1}', radius: 14), + ), + ), + ), + ], + ], + ), + ), + ), + ); +} + +/// A caption under a surface example. +class _CardLabel extends StatelessWidget { + const _CardLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return Text( + text, + style: TextStyle( + fontSize: 12, + color: t.mockText, + fontFamily: 'Menlo', + fontFamilyFallback: const ['Courier New', 'monospace'], + decoration: TextDecoration.none, + ), + ); + } +} + class _Footer extends StatelessWidget { const _Footer(); @@ -324,7 +922,7 @@ class _Footer extends StatelessWidget { ), const SizedBox(height: 4), Text( - 'Flutter port · border_beam package', + 'Flutter port · flutter_border_beam package', textAlign: TextAlign.center, style: muted.copyWith(color: t.muted.withValues(alpha: 0.5)), ), diff --git a/example/lib/pulse_outside_demo.dart b/example/lib/pulse_outside_demo.dart index f2927fb..49a86af 100644 --- a/example/lib/pulse_outside_demo.dart +++ b/example/lib/pulse_outside_demo.dart @@ -1,4 +1,4 @@ -import 'package:border_beam/border_beam.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; import 'package:flutter/material.dart'; import 'demo_harness.dart'; diff --git a/example/lib/showcase.dart b/example/lib/showcase.dart index 6c14531..5ee5a82 100644 --- a/example/lib/showcase.dart +++ b/example/lib/showcase.dart @@ -1,4 +1,4 @@ -import 'package:border_beam/border_beam.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; import 'package:flutter/material.dart'; import 'demo_harness.dart'; @@ -62,7 +62,7 @@ void main() { 'squircle': () => _card( const BorderBeam.rotate( colors: BeamColors.ocean, - useSuperellipse: true, + shape: BeamShape(superellipse: true), borderRadius: 28, child: _Surface(radius: 28), ), diff --git a/example/lib/src/mocks.dart b/example/lib/src/mocks.dart index 810e446..4dd52aa 100644 --- a/example/lib/src/mocks.dart +++ b/example/lib/src/mocks.dart @@ -230,6 +230,73 @@ class MockSearchBar extends StatelessWidget { } } +/// A focusable field with no Material ancestor: a [Focus] around a painted +/// box, so the gallery can show `BeamFocusRing` lighting on focus without +/// pulling in Material's own input decoration. +class MockFocusField extends StatefulWidget { + /// Creates the field. + const MockFocusField({ + super.key, + this.label = 'Tap to focus', + this.width = 210, + }); + + /// Placeholder text. + final String label; + + /// Field width. + final double width; + + @override + State createState() => _MockFocusFieldState(); +} + +class _MockFocusFieldState extends State { + late final FocusNode _node = FocusNode(debugLabel: 'mock-focus-field'); + + @override + void dispose() { + _node.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return Focus( + focusNode: _node, + child: GestureDetector( + onTap: _node.requestFocus, + child: AnimatedBuilder( + animation: _node, + builder: (context, _) => Container( + width: widget.width, + height: 40, + decoration: _mockBox(t, 12), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Container( + width: 1.5, + height: 16, + color: _node.hasFocus ? t.mockTextStrong : t.mockPlaceholder, + ), + const SizedBox(width: 8), + Text( + widget.label, + style: _mockStyle( + _node.hasFocus ? t.mockText : t.mockPlaceholder, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + class _DashedCircle extends StatelessWidget { const _DashedCircle({required this.color}); diff --git a/example/lib/src/playground.dart b/example/lib/src/playground.dart index bc77fe3..be0d3ac 100644 --- a/example/lib/src/playground.dart +++ b/example/lib/src/playground.dart @@ -1,369 +1,9 @@ -import 'package:border_beam/border_beam.dart'; -import 'package:flutter/material.dart'; - -import 'demo_theme.dart'; - -/// The interactive playground of the gallery: variant / palette / strength / -/// superellipse controls, a live preview, and the generated Flutter snippet -/// (ports the React demo's playground). -class PlaygroundSection extends StatefulWidget { - /// Const constructor. - const PlaygroundSection({super.key}); - - @override - State createState() => _PlaygroundSectionState(); -} - -class _PlaygroundSectionState extends State { - static const _variants = [ - (label: 'Large', variant: BeamVariant.rotate), - (label: 'Small', variant: BeamVariant.small), - (label: 'Line', variant: BeamVariant.line), - (label: 'Pulse Inner', variant: BeamVariant.pulseInside), - (label: 'Pulse Outside', variant: BeamVariant.pulseOutside), - ]; - static const _palettes = [ - (label: 'Colorful', name: 'colorful', colors: BeamColors.colorful), - (label: 'Mono', name: 'mono', colors: BeamColors.mono), - (label: 'Ocean', name: 'ocean', colors: BeamColors.ocean), - (label: 'Sunset', name: 'sunset', colors: BeamColors.sunset), - ]; - - BeamVariant _variant = BeamVariant.rotate; - int _paletteIndex = 0; - double _strength = 0.7; - bool _superellipse = false; - bool _active = true; - - @override - Widget build(BuildContext context) { - final theme = DemoTheme.of(context); - final t = theme.tokens; - final beamTheme = theme.isDark ? BeamTheme.dark : BeamTheme.light; - final palette = _palettes[_paletteIndex]; - - final preview = Container( - width: 350, - height: 140, - alignment: Alignment.center, - decoration: BoxDecoration( - color: t.mockBg, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: t.mockBorder), - ), - child: _variant == BeamVariant.small - ? null - : Text( - 'Build anything...', - style: TextStyle( - fontSize: 13, - color: t.mockPlaceholder, - decoration: TextDecoration.none, - ), - ), - ); - - final beam = switch (_variant) { - BeamVariant.rotate => BorderBeam.rotate( - colors: palette.colors, - theme: beamTheme, - strength: _strength, - active: _active, - useSuperellipse: _superellipse, - child: preview, - ), - BeamVariant.small => BorderBeam.small( - colors: palette.colors, - theme: beamTheme, - strength: _strength, - active: _active, - useSuperellipse: _superellipse, - borderRadius: 16, - child: preview, - ), - BeamVariant.line => BorderBeam.line( - colors: palette.colors, - theme: beamTheme, - strength: _strength, - active: _active, - useSuperellipse: _superellipse, - child: preview, - ), - BeamVariant.pulseInside => BorderBeam.pulseInside( - colors: palette.colors, - theme: beamTheme, - strength: _strength, - active: _active, - useSuperellipse: _superellipse, - child: preview, - ), - BeamVariant.pulseOutside => BorderBeam.pulseOutside( - colors: palette.colors, - theme: beamTheme, - strength: _strength, - active: _active, - useSuperellipse: _superellipse, - child: preview, - ), - }; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _ControlRow( - label: 'Type', - child: Wrap( - spacing: 6, - runSpacing: 6, - children: [ - for (final v in _variants) - _Chip( - label: v.label, - selected: _variant == v.variant, - onTap: () => setState(() => _variant = v.variant), - ), - ], - ), - ), - const SizedBox(height: 12), - _ControlRow( - label: 'Colors', - child: Wrap( - spacing: 6, - children: [ - for (final (i, p) in _palettes.indexed) - _Chip( - label: p.label, - selected: _paletteIndex == i, - onTap: () => setState(() => _paletteIndex = i), - ), - ], - ), - ), - const SizedBox(height: 12), - _ControlRow( - label: 'Strength', - child: Row( - children: [ - Expanded( - child: _MinimalSlider( - value: _strength, - onChanged: (v) => setState(() => _strength = v), - ), - ), - const SizedBox(width: 12), - SizedBox( - width: 40, - child: Text( - '${(_strength * 100).round()}%', - style: TextStyle( - fontSize: 12, - color: t.muted, - decoration: TextDecoration.none, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 12), - _ControlRow( - label: 'Options', - child: Wrap( - spacing: 6, - children: [ - _Chip( - label: 'Squircle', - selected: _superellipse, - onTap: () => setState(() => _superellipse = !_superellipse), - ), - _Chip( - label: _active ? 'Pause' : 'Play', - selected: !_active, - onTap: () => setState(() => _active = !_active), - ), - ], - ), - ), - const SizedBox(height: 20), - Container( - height: 280, - decoration: BoxDecoration( - color: theme.isDark ? t.surface : const Color(0xFFF7F7F7), - borderRadius: BorderRadius.circular(24), - ), - child: Center(child: beam), - ), - const SizedBox(height: 16), - _CodeBlock(code: _snippet(palette.name)), - ], - ); - } - - String _snippet(String paletteName) { - final constructor = switch (_variant) { - BeamVariant.rotate => 'rotate', - BeamVariant.small => 'small', - BeamVariant.line => 'line', - BeamVariant.pulseInside => 'pulseInside', - BeamVariant.pulseOutside => 'pulseOutside', - }; - final args = [ - if (paletteName != 'colorful') 'colors: BeamColors.$paletteName,', - if (_strength < 1) 'strength: ${_strength.toStringAsFixed(2)},', - if (_superellipse) 'useSuperellipse: true,', - if (!_active) 'active: false,', - 'child: child,', - ]; - return 'BorderBeam.$constructor(\n ${args.join('\n ')}\n)'; - } -} - -class _ControlRow extends StatelessWidget { - const _ControlRow({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final t = DemoTheme.of(context).tokens; - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 80, - child: Text( - label, - style: TextStyle( - fontSize: 12, - color: t.muted, - decoration: TextDecoration.none, - ), - ), - ), - Expanded(child: child), - ], - ); - } -} - -class _Chip extends StatelessWidget { - const _Chip({ - required this.label, - required this.selected, - required this.onTap, - }); - - final String label; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final t = DemoTheme.of(context).tokens; - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - height: 28, - padding: const EdgeInsets.symmetric(horizontal: 12), - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected ? t.btnBgActive : t.btnBg, - borderRadius: BorderRadius.circular(14), - ), - child: Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: selected ? t.btnTextActive : t.btnText, - decoration: TextDecoration.none, - ), - ), - ), - ); - } -} - -class _MinimalSlider extends StatelessWidget { - const _MinimalSlider({required this.value, required this.onChanged}); - - final double value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final t = DemoTheme.of(context).tokens; - return LayoutBuilder( - builder: (context, constraints) { - void update(Offset local) { - onChanged((local.dx / constraints.maxWidth).clamp(0.0, 1.0)); - } - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (d) => update(d.localPosition), - onHorizontalDragUpdate: (d) => update(d.localPosition), - child: SizedBox( - height: 28, - child: Center( - child: Stack( - children: [ - Container( - height: 6, - decoration: BoxDecoration( - color: t.trackBg, - borderRadius: BorderRadius.circular(3), - ), - ), - FractionallySizedBox( - widthFactor: value, - child: Container( - height: 6, - decoration: BoxDecoration( - color: t.trackFill, - borderRadius: BorderRadius.circular(3), - ), - ), - ), - ], - ), - ), - ), - ); - }, - ); - } -} - -class _CodeBlock extends StatelessWidget { - const _CodeBlock({required this.code}); - - final String code; - - @override - Widget build(BuildContext context) { - final t = DemoTheme.of(context).tokens; - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: t.panel, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - code, - style: TextStyle( - fontSize: 12, - height: 1.6, - fontFamily: 'Menlo', - fontFamilyFallback: const ['Courier New', 'monospace'], - color: t.codeText, - decoration: TextDecoration.none, - ), - ), - ); - } -} +/// The gallery's interactive playground. +/// +/// The pieces live in `playground/`: the state bag every control drives +/// (`playground_state.dart`), the shareable-link codec (`share_codec.dart`), +/// the snippet generator (`snippet.dart`), the control widgets +/// (`controls.dart`), and the section itself (`playground_section.dart`). +library; + +export 'playground/playground_section.dart' show PlaygroundSection; diff --git a/example/lib/src/playground/controls.dart b/example/lib/src/playground/controls.dart new file mode 100644 index 0000000..04a06b9 --- /dev/null +++ b/example/lib/src/playground/controls.dart @@ -0,0 +1,650 @@ +import 'package:flutter/material.dart'; + +import '../demo_theme.dart'; + +/// A collapsible group of playground controls, headed by a tappable title +/// row in the demo's own typography. +class ControlSection extends StatefulWidget { + /// Creates a section, open when [initiallyExpanded]. + const ControlSection({ + super.key, + required this.title, + required this.children, + this.initiallyExpanded = false, + }); + + /// Header label. + final String title; + + /// Rows revealed while the section is open. + final List children; + + /// Whether the section starts open. + final bool initiallyExpanded; + + @override + State createState() => _ControlSectionState(); +} + +class _ControlSectionState extends State { + late bool _expanded = widget.initiallyExpanded; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: t.panel, + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 14, 14), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: t.heading, + decoration: TextDecoration.none, + ), + ), + ), + AnimatedRotation( + turns: _expanded ? 0.5 : 0, + duration: const Duration(milliseconds: 180), + curve: const Cubic(0.22, 1, 0.36, 1), + child: Icon( + Icons.keyboard_arrow_down, + size: 18, + color: t.muted, + ), + ), + ], + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: const Cubic(0.22, 1, 0.36, 1), + alignment: Alignment.topCenter, + child: _expanded + ? Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: widget.children, + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ); + } +} + +/// A labelled control row: a fixed-width label and the control beside it, +/// stacking the two on narrow layouts. +class ControlRow extends StatelessWidget { + /// Creates a row. + const ControlRow({ + super.key, + required this.label, + required this.child, + this.enabled = true, + }); + + /// Left-hand label. + final String label; + + /// The control. + final Widget child; + + /// Whether the control accepts input; a disabled row dims and ignores + /// pointers. + final bool enabled; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Opacity( + opacity: enabled ? 1 : 0.4, + child: IgnorePointer( + ignoring: !enabled, + child: LayoutBuilder( + builder: (context, constraints) { + final labelWidget = Text( + label, + style: TextStyle( + fontSize: 12, + color: t.muted, + decoration: TextDecoration.none, + ), + ); + if (constraints.maxWidth < 300) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align(alignment: Alignment.centerLeft, child: labelWidget), + const SizedBox(height: 2), + child, + ], + ); + } + return Row( + children: [ + SizedBox(width: 104, child: labelWidget), + Expanded(child: child), + ], + ); + }, + ), + ), + ), + ); + } +} + +/// The demo's pill control button, used for every toggle and choice. +class DemoChip extends StatelessWidget { + /// Creates a chip. + const DemoChip({ + super.key, + required this.label, + required this.selected, + required this.onTap, + this.leading, + this.enabled = true, + }); + + /// Chip text. + final String label; + + /// Whether the chip reads as on. + final bool selected; + + /// Tap callback. + final VoidCallback onTap; + + /// Whether the chip accepts input. + final bool enabled; + + /// Optional widget ahead of the label (a color dot, for instance). + final Widget? leading; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return IgnorePointer( + ignoring: !enabled, + child: Opacity( + opacity: enabled ? 1 : 0.4, + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: const Cubic(0.22, 1, 0.36, 1), + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? t.btnBgActive : t.btnBg, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (leading case final Widget widget) ...[ + widget, + const SizedBox(width: 6), + ], + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: selected ? t.btnTextActive : t.btnText, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// A row of chips laid out as a wrap, one of which is selected. +class ChipGroup extends StatelessWidget { + /// Creates a chip group. + const ChipGroup({super.key, required this.children}); + + /// The chips. + final List children; + + @override + Widget build(BuildContext context) => + Wrap(spacing: 6, runSpacing: 6, children: children); +} + +/// The demo's minimal drag track, mapped over an arbitrary range. +class MinimalSlider extends StatelessWidget { + /// Creates a slider. + const MinimalSlider({ + super.key, + required this.value, + required this.min, + required this.max, + required this.onChanged, + }); + + /// Current value, clamped into [min]–[max] for display. + final double value; + + /// Range start. + final double min; + + /// Range end. + final double max; + + /// Called with the new value on tap or drag. + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + final fraction = ((value - min) / (max - min)).clamp(0.0, 1.0); + return LayoutBuilder( + builder: (context, constraints) { + void update(Offset local) { + final f = (local.dx / constraints.maxWidth).clamp(0.0, 1.0); + // Two decimals is the resolution the share codec round-trips. + onChanged(double.parse((min + f * (max - min)).toStringAsFixed(2))); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => update(d.localPosition), + onHorizontalDragUpdate: (d) => update(d.localPosition), + child: SizedBox( + width: double.infinity, + height: 28, + child: Center( + child: Stack( + children: [ + Container( + height: 6, + decoration: BoxDecoration( + color: t.trackBg, + borderRadius: BorderRadius.circular(3), + ), + ), + FractionallySizedBox( + widthFactor: fraction, + child: Container( + height: 6, + decoration: BoxDecoration( + color: t.trackFill, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +/// A slider row that always carries a value, with the number shown beside +/// the track. +class SliderRow extends StatelessWidget { + /// Creates the row. + const SliderRow({ + super.key, + required this.label, + required this.value, + required this.min, + required this.max, + required this.onChanged, + this.suffix = '', + this.enabled = true, + }); + + /// Row label. + final String label; + + /// Current value. + final double value; + + /// Range start. + final double min; + + /// Range end. + final double max; + + /// Change callback. + final ValueChanged onChanged; + + /// Unit appended to the readout (`s`, `px`, `°`, `×`). + final String suffix; + + /// Whether the row accepts input. + final bool enabled; + + @override + Widget build(BuildContext context) => ControlRow( + label: label, + enabled: enabled, + child: Row( + children: [ + Expanded( + child: MinimalSlider( + value: value, + min: min, + max: max, + onChanged: onChanged, + ), + ), + const SizedBox(width: 10), + ValueReadout(text: '${formatControlValue(value)}$suffix'), + ], + ), + ); +} + +/// A slider row whose value may be null, meaning the package resolves it. +/// +/// The track sits at [fallback] while the value is null, and an `auto` chip +/// clears it back. +class NullableSliderRow extends StatelessWidget { + /// Creates the row. + const NullableSliderRow({ + super.key, + required this.label, + required this.value, + required this.fallback, + required this.min, + required this.max, + required this.onChanged, + this.suffix = '', + this.enabled = true, + }); + + /// Row label. + final String label; + + /// Current override, or null for the package default. + final double? value; + + /// Where the track sits, and what the readout reports, while [value] is + /// null. Null parks the track at [min] — the package default of that field + /// is internal to the variant preset. + final double? fallback; + + /// Range start. + final double min; + + /// Range end. + final double max; + + /// Change callback; null clears the override. + final ValueChanged onChanged; + + /// Unit appended to the readout. + final String suffix; + + /// Whether the row accepts input. + final bool enabled; + + @override + Widget build(BuildContext context) { + final shown = value ?? fallback ?? min; + return ControlRow( + label: label, + enabled: enabled, + child: Row( + children: [ + Expanded( + child: MinimalSlider( + value: shown, + min: min, + max: max, + onChanged: onChanged, + ), + ), + const SizedBox(width: 10), + ValueReadout( + text: value == null + ? 'auto' + : '${formatControlValue(shown)}$suffix', + ), + const SizedBox(width: 8), + DemoChip( + label: 'auto', + selected: value == null, + onTap: () => onChanged(null), + ), + ], + ), + ); + } +} + +/// The fixed-width numeric readout beside a slider. +class ValueReadout extends StatelessWidget { + /// Creates a readout. + const ValueReadout({super.key, required this.text}); + + /// Formatted value. + final String text; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return SizedBox( + width: 52, + child: Text( + text, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 12, + color: t.muted, + fontFeatures: const [FontFeature.tabularFigures()], + decoration: TextDecoration.none, + ), + ), + ); + } +} + +/// Formats a control value for its readout: `2` rather than `2.00`. +String formatControlValue(double value) { + final fixed = value.toStringAsFixed(2); + if (!fixed.contains('.')) return fixed; + return fixed.replaceFirst(RegExp(r'\.?0+$'), ''); +} + +/// The install line above the playground: the pub command with the same copy +/// affordance the code panel uses, and a chip carrying the repository link. +class InstallBlock extends StatelessWidget { + /// Creates the install line. + const InstallBlock({ + super.key, + required this.command, + required this.repoLabel, + required this.onCopyCommand, + required this.onCopyRepo, + required this.copied, + }); + + /// The shell command shown, and copied by the Copy chip. + final String command; + + /// Label of the repository chip. + final String repoLabel; + + /// Copies [command]. + final VoidCallback onCopyCommand; + + /// Copies the repository link. + final VoidCallback onCopyRepo; + + /// Whether either confirmation is showing. + final bool copied; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 12, 12, 12), + decoration: BoxDecoration( + color: t.panel, + borderRadius: BorderRadius.circular(16), + ), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.spaceBetween, + spacing: 12, + runSpacing: 10, + children: [ + Text( + command, + style: TextStyle( + fontSize: 12, + height: 1.6, + fontFamily: 'Menlo', + fontFamilyFallback: const ['Courier New', 'monospace'], + color: t.codeText, + decoration: TextDecoration.none, + ), + ), + // A Wrap rather than a Row: the actions break onto their own line + // on a narrow layout instead of overflowing. + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 8, + children: [ + AnimatedOpacity( + opacity: copied ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: Text( + 'Copied', + style: TextStyle( + fontSize: 12, + color: t.muted, + decoration: TextDecoration.none, + ), + ), + ), + DemoChip(label: 'Copy', selected: false, onTap: onCopyCommand), + DemoChip( + label: repoLabel, + selected: false, + onTap: onCopyRepo, + leading: Icon(Icons.link, size: 14, color: t.btnText), + ), + ], + ), + ], + ), + ); + } +} + +/// The playground's code panel: the generated snippet plus a +/// copy-to-clipboard action that confirms in place. +class CodeBlock extends StatelessWidget { + /// Creates the panel. + const CodeBlock({ + super.key, + required this.code, + required this.onCopy, + required this.copied, + }); + + /// Snippet text. + final String code; + + /// Copy action. + final VoidCallback onCopy; + + /// Whether the confirmation is showing. + final bool copied; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: t.panel, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AnimatedOpacity( + opacity: copied ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: Text( + 'Copied', + style: TextStyle( + fontSize: 12, + color: t.muted, + decoration: TextDecoration.none, + ), + ), + ), + const SizedBox(width: 8), + DemoChip(label: 'Copy', selected: false, onTap: onCopy), + ], + ), + const SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + code, + key: const ValueKey('playground-snippet'), + style: TextStyle( + fontSize: 12, + height: 1.6, + fontFamily: 'Menlo', + fontFamilyFallback: const ['Courier New', 'monospace'], + color: t.codeText, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/src/playground/controls_panel.dart b/example/lib/src/playground/controls_panel.dart new file mode 100644 index 0000000..6556164 --- /dev/null +++ b/example/lib/src/playground/controls_panel.dart @@ -0,0 +1,1080 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; + +import '../demo_theme.dart'; +import 'controls.dart'; +import 'playground_state.dart'; + +/// Every playground control, grouped into collapsible sections. +/// +/// The panel owns no state: it reads [state], hands each edit to [onEdit], +/// and routes the two playback concerns the section owns — swapping a +/// controller in ([onControllerMode]) and driving one ([onControllerAction]) +/// — back up to it. +class ControlsPanel extends StatelessWidget { + /// Creates the panel. + const ControlsPanel({ + super.key, + required this.state, + required this.onEdit, + required this.onControllerMode, + required this.onControllerAction, + }); + + /// The configuration every control reads and writes. + final PlaygroundState state; + + /// Applies a mutation to [state] and rebuilds. + final void Function(VoidCallback mutate) onEdit; + + /// Turns controller mode on or off. + final ValueChanged onControllerMode; + + /// Runs an action against every attached controller. + final void Function(void Function(BorderBeamController)) onControllerAction; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _sections(context), + ); + + List _sections(BuildContext context) { + final s = state; + final isLine = s.variant == BeamVariant.line; + final isPulse = s.variant.isPulse; + final isOutside = s.variant == BeamVariant.pulseOutside; + final travels = s.isTraveling; + final ring = s.isRing; + final brightness = DemoTheme.of(context).isDark + ? Brightness.dark + : Brightness.light; + return [ + ControlSection( + title: 'Variant & colors', + initiallyExpanded: true, + children: [ + ControlRow( + label: 'Variant', + child: ChipGroup( + children: [ + for (final entry in _variantLabels.entries) + DemoChip( + label: entry.value, + selected: s.variant == entry.key, + onTap: () => onEdit(() => s.variant = entry.key), + ), + ], + ), + ), + ControlRow( + label: 'Palette', + child: ChipGroup( + children: [ + for (final preset in PalettePreset.values) + DemoChip( + label: preset.label, + selected: s.palette == preset, + onTap: () => onEdit(() => s.palette = preset), + ), + ], + ), + ), + if (s.palette == PalettePreset.custom) ...[ + ControlRow( + label: 'Custom colors', + child: ChipGroup( + children: [ + for (final (i, swatch) in customSwatches.indexed) + DemoChip( + label: swatch.name, + selected: s.customColors.contains(i), + leading: _Dot(color: swatch.color), + onTap: () => onEdit(() => _toggleSwatch(i)), + ), + ], + ), + ), + ControlRow( + label: 'Base', + child: ChipGroup( + children: [ + for (final preset in PalettePreset.presets) + DemoChip( + label: preset.label, + selected: s.customBase == preset, + onTap: () => onEdit(() => s.customBase = preset), + ), + ], + ), + ), + _Hint( + 'The base supplies the blob geometry and the alpha structure ' + 'your colors are distributed over.', + ), + ], + if (s.palette == PalettePreset.seed) ...[ + ControlRow( + label: 'Seed', + child: ChipGroup( + children: [ + for (final (i, swatch) in customSwatches.indexed) + DemoChip( + label: swatch.name, + selected: s.seedColor == i, + leading: _Dot(color: swatch.color), + onTap: () => onEdit(() => s.seedColor = i), + ), + ], + ), + ), + ControlRow( + label: 'Harmony', + child: ChipGroup( + children: [ + for (final entry in _harmonyLabels.entries) + DemoChip( + label: entry.value, + selected: s.seedHarmony == entry.key, + onTap: () => onEdit(() => s.seedHarmony = entry.key), + ), + ], + ), + ), + ], + if (s.palette == PalettePreset.lerp) ...[ + ControlRow( + label: 'From', + child: ChipGroup( + children: [ + for (final preset in PalettePreset.presets) + DemoChip( + label: preset.label, + selected: s.lerpFrom == preset, + onTap: () => onEdit(() => s.lerpFrom = preset), + ), + ], + ), + ), + ControlRow( + label: 'To', + child: ChipGroup( + children: [ + for (final preset in PalettePreset.presets) + DemoChip( + label: preset.label, + selected: s.lerpTo == preset, + onTap: () => onEdit(() => s.lerpTo = preset), + ), + ], + ), + ), + SliderRow( + label: 'Blend', + value: s.lerpT, + min: 0, + max: 1, + onChanged: (v) => onEdit(() => s.lerpT = v), + ), + ], + SliderRow( + label: 'Alpha scale', + value: s.alphaScale, + min: 0, + max: 2, + suffix: '×', + onChanged: (v) => onEdit(() => s.alphaScale = v), + ), + ], + ), + ControlSection( + title: 'Shape', + initiallyExpanded: true, + children: [ + ControlRow( + label: 'Corners', + child: ChipGroup( + children: [ + DemoChip( + label: 'Stadium', + selected: s.stadium, + onTap: () => onEdit(() => s.stadium = !s.stadium), + ), + DemoChip( + label: 'Per corner', + selected: s.perCorner, + onTap: () => onEdit(() => s.perCorner = !s.perCorner), + ), + DemoChip( + label: 'Squircle', + selected: s.superellipse, + onTap: () => onEdit(() => s.superellipse = !s.superellipse), + ), + DemoChip( + label: 'Star contour', + selected: s.contour, + onTap: () => onEdit(() => s.contour = !s.contour), + ), + ], + ), + ), + if (s.contour) + _Hint( + 'A BeamPathContour replaces the rounded rectangle, so the ' + 'radius and squircle toggles stop applying. The mock surface ' + 'keeps its own corners — the beam never reads the child.', + ), + if (!s.stadium && !s.perCorner) + NullableSliderRow( + label: 'Radius', + value: s.radius, + fallback: s.defaultRadius, + min: 0, + max: 48, + suffix: 'px', + enabled: !s.contour, + onChanged: (v) => onEdit(() => s.radius = v), + ), + if (!s.stadium && s.perCorner) ...[ + SliderRow( + label: 'Top left', + value: s.radiusTopLeft, + min: 0, + max: 48, + suffix: 'px', + onChanged: (v) => onEdit(() => s.radiusTopLeft = v), + ), + SliderRow( + label: 'Top right', + value: s.radiusTopRight, + min: 0, + max: 48, + suffix: 'px', + onChanged: (v) => onEdit(() => s.radiusTopRight = v), + ), + SliderRow( + label: 'Bottom right', + value: s.radiusBottomRight, + min: 0, + max: 48, + suffix: 'px', + onChanged: (v) => onEdit(() => s.radiusBottomRight = v), + ), + SliderRow( + label: 'Bottom left', + value: s.radiusBottomLeft, + min: 0, + max: 48, + suffix: 'px', + onChanged: (v) => onEdit(() => s.radiusBottomLeft = v), + ), + ], + SliderRow( + label: 'Border width', + value: s.borderWidth, + min: 0.5, + max: 4, + suffix: 'px', + onChanged: (v) => onEdit(() => s.borderWidth = v), + ), + SliderRow( + label: 'Ring offset', + value: s.ringOffset, + min: -12, + max: 24, + suffix: 'px', + onChanged: (v) => onEdit(() => s.ringOffset = v), + ), + ControlRow( + label: 'Segment', + child: ChipGroup( + children: [ + for (final preset in SegmentPreset.values) + DemoChip( + label: preset.label, + selected: s.segmentPreset == preset, + onTap: () => onEdit(() => s.segmentPreset = preset), + ), + ], + ), + ), + if (s.segmentPreset == SegmentPreset.custom) ...[ + ControlRow( + label: 'Start edge', + child: ChipGroup( + children: [ + for (final entry in _edgeLabels.entries) + DemoChip( + label: entry.value, + selected: s.segmentStartEdge == entry.key, + onTap: () => onEdit(() => s.segmentStartEdge = entry.key), + ), + ], + ), + ), + SliderRow( + label: 'Start t', + value: s.segmentStartT, + min: 0, + max: 1, + onChanged: (v) => onEdit(() => s.segmentStartT = v), + ), + ControlRow( + label: 'End edge', + child: ChipGroup( + children: [ + for (final entry in _edgeLabels.entries) + DemoChip( + label: entry.value, + selected: s.segmentEndEdge == entry.key, + onTap: () => onEdit(() => s.segmentEndEdge = entry.key), + ), + ], + ), + ), + SliderRow( + label: 'End t', + value: s.segmentEndT, + min: 0, + max: 1, + onChanged: (v) => onEdit(() => s.segmentEndT = v), + ), + SliderRow( + label: 'Feather', + value: s.segmentFeather, + min: 0, + max: 120, + suffix: 'px', + onChanged: (v) => onEdit(() => s.segmentFeather = v), + ), + ], + if (isLine && s.segmentPreset == SegmentPreset.off) + ControlRow( + label: 'Edge', + child: ChipGroup( + children: [ + for (final entry in _edgeLabels.entries) + DemoChip( + label: entry.value, + selected: s.edge == entry.key, + onTap: () => onEdit(() => s.edge = entry.key), + ), + ], + ), + ), + if (isLine && s.segmentPreset == SegmentPreset.off) + ControlRow( + label: 'Corner wrap', + child: DemoChip( + label: 'Wrap corners', + selected: s.wrapCorners, + onTap: () => onEdit(() => s.wrapCorners = !s.wrapCorners), + ), + ), + ], + ), + ControlSection( + title: 'Timing', + children: [ + NullableSliderRow( + label: 'Cycle', + value: s.cycleSeconds, + fallback: s.defaultCycleSeconds, + min: 0.5, + max: 8, + suffix: 's', + onChanged: (v) => onEdit(() => s.cycleSeconds = v), + ), + SliderRow( + label: 'Cycle gap', + value: s.cycleGapSeconds, + min: 0, + max: 4, + suffix: 's', + enabled: !isPulse, + onChanged: (v) => onEdit(() => s.cycleGapSeconds = v), + ), + SliderRow( + label: 'Speed', + value: s.speed, + min: 0.25, + max: 4, + suffix: '×', + // A controller owns a single beam's rate; BeamSync owns the + // group's rate even when controller mode remains selected. + enabled: !s.controllerMode || s.syncDemo, + onChanged: (v) => onEdit(() => s.speed = v), + ), + if (travels) ...[ + ControlRow( + label: 'Direction', + child: ChipGroup( + children: [ + for (final entry in _directionLabels.entries) + DemoChip( + label: entry.value, + selected: s.direction == entry.key, + onTap: () => onEdit(() => s.direction = entry.key), + ), + ], + ), + ), + SliderRow( + label: 'Phase offset', + value: s.phaseOffset, + min: 0, + max: 1, + onChanged: (v) => onEdit(() => s.phaseOffset = v), + ), + ControlRow( + label: 'Beams', + child: ChipGroup( + children: [ + for (var count = 1; count <= 4; count++) + DemoChip( + label: '$count', + selected: s.beamCount == count, + onTap: () => onEdit(() => s.beamCount = count), + ), + ], + ), + ), + ], + NullableSliderRow( + label: 'Hue period', + value: s.huePeriodSeconds, + fallback: s.defaultHuePeriodSeconds, + min: 2, + max: 30, + suffix: 's', + onChanged: (v) => onEdit(() => s.huePeriodSeconds = v), + ), + if (isLine) ...[ + SliderRow( + label: 'Breathe', + value: s.breatheFactor, + min: 0.5, + max: 3, + suffix: '×', + onChanged: (v) => onEdit(() => s.breatheFactor = v), + ), + SliderRow( + label: 'Spike', + value: s.spikeFactor, + min: 0.5, + max: 3, + suffix: '×', + onChanged: (v) => onEdit(() => s.spikeFactor = v), + ), + SliderRow( + label: 'Spike 2', + value: s.spike2Factor, + min: 0.5, + max: 3, + suffix: '×', + onChanged: (v) => onEdit(() => s.spike2Factor = v), + ), + ], + ControlRow( + label: 'Hue', + child: ChipGroup( + children: [ + DemoChip( + label: 'Static colors', + selected: s.staticColors, + onTap: () => onEdit(() => s.staticColors = !s.staticColors), + ), + ], + ), + ), + ], + ), + ControlSection( + title: 'Style', + children: [ + SliderRow( + label: 'Strength', + value: s.strength, + min: 0, + max: 1, + onChanged: (v) => onEdit(() => s.strength = v), + ), + NullableSliderRow( + label: 'Brightness', + value: s.brightness, + fallback: s.defaultBrightness(brightness), + min: 0.5, + max: 3, + suffix: '×', + onChanged: (v) => onEdit(() => s.brightness = v), + ), + NullableSliderRow( + label: 'Saturation', + value: s.saturation, + fallback: s.defaultSaturation(brightness), + min: 0.5, + max: 3, + suffix: '×', + onChanged: (v) => onEdit(() => s.saturation = v), + ), + SliderRow( + label: 'Hue range', + value: s.hueRange, + min: 0, + max: 60, + suffix: '°', + // The line variant caps its own hue range at 13°. + enabled: !isPulse, + onChanged: (v) => onEdit(() => s.hueRange = v), + ), + ControlRow( + label: 'Hue mode', + child: ChipGroup( + children: [ + DemoChip( + label: 'Auto', + selected: s.hueMode == null, + onTap: () => onEdit(() => s.hueMode = null), + ), + for (final entry in _hueModeLabels.entries) + DemoChip( + label: entry.value, + selected: s.hueMode == entry.key, + onTap: () => onEdit(() => s.hueMode = entry.key), + ), + ], + ), + ), + SliderRow( + label: 'Hue base', + value: s.hueBase, + min: -180, + max: 180, + suffix: '°', + onChanged: (v) => onEdit(() => s.hueBase = v), + ), + SliderRow( + label: 'Stroke opacity', + value: s.strokeOpacityFactor, + min: 0, + max: 2, + suffix: '×', + onChanged: (v) => onEdit(() => s.strokeOpacityFactor = v), + ), + SliderRow( + label: 'Inner opacity', + value: s.innerOpacityFactor, + min: 0, + max: 2, + suffix: '×', + onChanged: (v) => onEdit(() => s.innerOpacityFactor = v), + ), + SliderRow( + label: 'Bloom opacity', + value: s.bloomOpacityFactor, + min: 0, + max: 2, + suffix: '×', + onChanged: (v) => onEdit(() => s.bloomOpacityFactor = v), + ), + if (ring) + SliderRow( + label: 'Tail length', + value: s.tailLength, + min: 0.5, + max: 2, + suffix: '×', + onChanged: (v) => onEdit(() => s.tailLength = v), + ), + SliderRow( + label: 'Glow spread', + value: s.glowSpread, + min: 0.5, + max: 3, + suffix: '×', + onChanged: (v) => onEdit(() => s.glowSpread = v), + ), + if (travels) + SliderRow( + label: 'Sparkle', + value: s.sparkle, + min: 0, + max: 1, + onChanged: (v) => onEdit(() => s.sparkle = v), + ), + if (ring) + ControlRow( + label: 'Comet', + child: ChipGroup( + children: [ + DemoChip( + label: 'Comet tail', + selected: s.comet, + onTap: () => onEdit(() => s.comet = !s.comet), + ), + ], + ), + ), + if (!isLine) + ControlRow( + label: 'Segments', + child: ChipGroup( + children: [ + DemoChip( + label: 'Off', + selected: s.segments == null, + onTap: () => onEdit(() => s.segments = null), + ), + for (final count in segmentChoices) + DemoChip( + label: '$count', + selected: s.segments == count, + onTap: () => onEdit(() => s.segments = count), + ), + ], + ), + ), + SliderRow( + label: 'Render scale', + value: s.renderScale, + min: 0.25, + max: 1, + suffix: '×', + onChanged: (v) => onEdit(() => s.renderScale = v), + ), + if (s.variant == BeamVariant.pulseInside) + SliderRow( + label: 'Inner size', + value: s.innerSizeScale, + min: 0.5, + max: 2, + suffix: '×', + onChanged: (v) => onEdit(() => s.innerSizeScale = v), + ), + if (isOutside) + ControlRow( + label: 'Recipe', + child: ChipGroup( + children: [ + DemoChip( + label: 'Stock pulse-outside', + selected: s.stockPulseOutside, + onTap: () => onEdit( + () => s.stockPulseOutside = !s.stockPulseOutside, + ), + ), + ], + ), + ), + if (isPulse) + SliderRow( + label: 'Glow boost', + value: s.glowBoost, + min: 0, + max: 3, + suffix: '×', + onChanged: (v) => onEdit(() => s.glowBoost = v), + ), + if (isOutside && s.stockPulseOutside) + _Hint( + 'BeamStyle.pulseOutsideStock is the React library\'s own ' + 'look, before the demo page\'s tuning: a tighter, dimmer halo. ' + 'Anything set here layers over it.', + ), + if (isOutside) ...[ + NullableSliderRow( + label: 'Core blur', + value: s.coreBlur, + fallback: null, + min: 0, + max: 60, + suffix: 'px', + onChanged: (v) => onEdit(() => s.coreBlur = v), + ), + NullableSliderRow( + label: 'Bloom blur', + value: s.bloomBlur, + fallback: null, + min: 0, + max: 120, + suffix: 'px', + onChanged: (v) => onEdit(() => s.bloomBlur = v), + ), + NullableSliderRow( + label: 'Glow brightness', + value: s.glowBrightness, + fallback: null, + min: 0.5, + max: 3, + suffix: '×', + onChanged: (v) => onEdit(() => s.glowBrightness = v), + ), + NullableSliderRow( + label: 'Glow saturation', + value: s.glowSaturation, + fallback: null, + min: 0.5, + max: 3, + suffix: '×', + onChanged: (v) => onEdit(() => s.glowSaturation = v), + ), + ], + ], + ), + ControlSection( + title: 'Playback', + initiallyExpanded: true, + children: [ + ControlRow( + label: 'Mode', + child: ChipGroup( + children: [ + DemoChip( + label: s.active ? 'Active' : 'Inactive', + selected: s.active, + enabled: !s.controllerMode || s.syncDemo, + onTap: () => onEdit(() => s.active = !s.active), + ), + DemoChip( + label: 'Controller', + selected: s.controllerMode, + onTap: () => onControllerMode(!s.controllerMode), + ), + ], + ), + ), + if (s.controllerMode) ...[ + ControlRow( + // A BeamSync group owns its own clock, so the controller has + // no beam to drive while the sync demo is on. + enabled: !s.syncDemo, + label: 'Transport', + child: ChipGroup( + children: [ + for (final entry + in { + 'Start': (c) => c.start(), + 'Pause': (c) => c.pause(), + 'Resume': (c) => c.resume(), + 'Stop': (c) => c.stop(), + 'Pulse': (c) => c.pulse(), + 'Flash': (c) => c.flash(), + }.entries) + DemoChip( + label: entry.key, + selected: false, + onTap: () => onControllerAction(entry.value), + ), + ], + ), + ), + SliderRow( + label: 'Controller speed', + value: s.controllerSpeed, + min: 0.25, + max: 4, + suffix: '×', + enabled: !s.syncDemo, + onChanged: (v) => onEdit(() { + s.controllerSpeed = v; + onControllerAction((c) => c.speed = v); + }), + ), + ] else ...[ + SliderRow( + label: 'Start after', + value: s.startAfterSeconds, + min: 0, + max: 3, + suffix: 's', + enabled: !s.syncDemo, + onChanged: (v) => onEdit(() => s.startAfterSeconds = v), + ), + SliderRow( + label: 'Duration', + value: s.durationSeconds, + min: 0, + max: 15, + suffix: 's', + enabled: !s.syncDemo, + onChanged: (v) => onEdit(() => s.durationSeconds = v), + ), + ], + ControlRow( + enabled: !s.syncDemo, + label: 'Repeat', + child: ChipGroup( + children: [ + for (final entry in _repeatLabels.entries) + DemoChip( + label: entry.value, + selected: s.repeatCycles == entry.key, + onTap: () => onEdit(() => s.repeatCycles = entry.key), + ), + ], + ), + ), + ControlRow( + label: 'Reduced motion', + child: ChipGroup( + children: [ + DemoChip( + label: 'Static frame', + selected: s.reducedMotion == null, + onTap: () => onEdit(() => s.reducedMotion = null), + ), + for (final entry in _reducedMotionLabels.entries) + DemoChip( + label: entry.value, + selected: s.reducedMotion == entry.key, + onTap: () => onEdit(() => s.reducedMotion = entry.key), + ), + ], + ), + ), + ControlRow( + enabled: !s.syncDemo, + label: 'Offscreen', + child: ChipGroup( + children: [ + DemoChip( + label: 'Default', + selected: s.pauseWhenOffscreen == null, + onTap: () => onEdit(() => s.pauseWhenOffscreen = null), + ), + DemoChip( + label: 'Pause offscreen', + selected: s.pauseWhenOffscreen == true, + onTap: () => onEdit(() => s.pauseWhenOffscreen = true), + ), + DemoChip( + label: 'Keep running', + selected: s.pauseWhenOffscreen == false, + onTap: () => onEdit(() => s.pauseWhenOffscreen = false), + ), + ], + ), + ), + ControlRow( + enabled: !s.syncDemo, + label: 'Fade curve', + child: ChipGroup( + children: [ + DemoChip( + label: 'Spring', + selected: !s.cssFadeCurve, + onTap: () => onEdit(() => s.cssFadeCurve = false), + ), + DemoChip( + label: 'CSS ease', + selected: s.cssFadeCurve, + onTap: () => onEdit(() => s.cssFadeCurve = true), + ), + ], + ), + ), + ControlRow( + label: 'Simulate', + child: ChipGroup( + children: [ + DemoChip( + label: 'Simulate reduced motion', + selected: s.simulateReducedMotion, + onTap: () => onEdit( + () => s.simulateReducedMotion = !s.simulateReducedMotion, + ), + ), + ], + ), + ), + _Hint( + 'Static frame is the package default, so it sets no field. ' + 'Simulate wraps the preview in a MediaQuery asking for reduced ' + 'motion — the same signal the platform sends.', + ), + ], + ), + ControlSection( + title: 'Drive', + children: [ + ControlRow( + label: 'Sources', + child: ChipGroup( + children: [ + DemoChip( + label: 'Progress', + selected: s.driveProgress, + onTap: () => onEdit(() => s.driveProgress = !s.driveProgress), + ), + DemoChip( + label: 'Follow pointer', + selected: s.followPointer, + onTap: () => onEdit(() => s.followPointer = !s.followPointer), + ), + DemoChip( + label: 'Strength from signal', + selected: s.strengthSignal, + onTap: () => + onEdit(() => s.strengthSignal = !s.strengthSignal), + ), + ], + ), + ), + if (s.driveProgress) + SliderRow( + // Named for what it sets, so it does not read as a second + // copy of the toggle above it. + label: 'Position', + value: s.progress, + min: 0, + max: 1, + enabled: travels, + onChanged: (v) => onEdit(() => s.progress = v), + ), + _Hint( + travels + ? 'Progress parks the sweep where you put it and follow eases ' + 'it to the pointer, so progress wins where both are on. ' + 'Strength rides a sine wave through a ' + 'strengthListenable, without a rebuild.' + : 'The pulse variants have no travel, so progress and follow ' + 'do nothing here. Strength still rides a sine wave ' + 'through a strengthListenable.', + ), + ], + ), + ControlSection( + title: 'Theme', + children: [ + ControlRow( + label: 'Inheritance', + child: ChipGroup( + children: [ + DemoChip( + label: 'BorderBeamTheme', + selected: s.themeDemo, + onTap: () => onEdit(() => s.themeDemo = !s.themeDemo), + ), + ], + ), + ), + ControlRow( + label: 'Group', + child: ChipGroup( + children: [ + DemoChip( + label: 'BeamSync', + selected: s.syncDemo, + onTap: () => onEdit(() => s.syncDemo = !s.syncDemo), + ), + ], + ), + ), + _Hint( + 'BorderBeamTheme wraps the preview in ocean colors and a ' + 'squircle-20 shape: controls left at their default inherit from ' + 'it, anything you set wins. BeamSync swaps the preview for three ' + 'beams on one clock, a third of a cycle apart.', + ), + ], + ), + ]; + } + + void _toggleSwatch(int index) { + final selected = state.customColors.toList(); + if (selected.contains(index)) { + if (selected.length <= minCustomColors) return; + selected.remove(index); + } else { + if (selected.length >= maxCustomColors) return; + selected.add(index); + } + state.customColors = selected; + } +} + +class _Dot extends StatelessWidget { + const _Dot({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) => Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); +} + +class _Hint extends StatelessWidget { + const _Hint(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + final t = DemoTheme.of(context).tokens; + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + text, + style: TextStyle( + fontSize: 11, + height: 1.5, + color: t.muted, + decoration: TextDecoration.none, + ), + ), + ); + } +} + +const Map _variantLabels = { + BeamVariant.rotate: 'Large', + BeamVariant.small: 'Small', + BeamVariant.line: 'Line', + BeamVariant.pulseInside: 'Pulse Inner', + BeamVariant.pulseOutside: 'Pulse Outside', +}; + +const Map _harmonyLabels = { + BeamSeedHarmony.analogous: 'Analogous', + BeamSeedHarmony.complementary: 'Complementary', + BeamSeedHarmony.triadic: 'Triadic', + BeamSeedHarmony.monochrome: 'Monochrome', +}; + +const Map _edgeLabels = { + BeamEdge.top: 'Top', + BeamEdge.right: 'Right', + BeamEdge.bottom: 'Bottom', + BeamEdge.left: 'Left', +}; + +const Map _directionLabels = { + BeamDirection.forward: 'Forward', + BeamDirection.reverse: 'Reverse', + BeamDirection.bounce: 'Bounce', +}; + +const Map _hueModeLabels = { + BeamHueMode.pingPong: 'Ping-pong', + BeamHueMode.continuous: 'Continuous', +}; + +// Null is the forever default; the two counts are the shapes BeamRepeat +// takes. +const Map _repeatLabels = { + null: 'Forever', + 1: 'Once', + 3: '3 cycles', +}; + +// Static frame is the package default and lives on its own chip, which +// clears the field rather than setting it. +const Map _reducedMotionLabels = { + BeamReducedMotion.hide: 'Hide', + BeamReducedMotion.slow: 'Slow', + BeamReducedMotion.animate: 'Animate', +}; diff --git a/example/lib/src/playground/playground_section.dart b/example/lib/src/playground/playground_section.dart new file mode 100644 index 0000000..458fc77 --- /dev/null +++ b/example/lib/src/playground/playground_section.dart @@ -0,0 +1,239 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; + +import '../demo_theme.dart'; +import 'controls.dart'; +import 'controls_panel.dart'; +import 'playground_state.dart'; +import 'preview.dart'; +import 'share_codec.dart'; +import 'snippet.dart'; + +/// The interactive playground: every meaningful knob of the package's API +/// wired to a live preview and a generated snippet. +/// +/// The preview and the snippet are built from the same "only what differs +/// from the package default" rule, so what you read is what you see — with +/// the theme demo on, a control left at its default inherits from the +/// enclosing [BorderBeamTheme] instead. +/// +/// State round-trips through the URL: the current configuration is written +/// to the address bar on the web, and the share button copies a link that +/// reproduces it. +class PlaygroundSection extends StatefulWidget { + /// Const constructor. + const PlaygroundSection({super.key}); + + @override + State createState() => _PlaygroundSectionState(); +} + +// The control column of the two-column layout: wide enough for a slider to +// keep its label, readout, and `auto` chip on one line. +const double _controlsWidth = 420; + +// The install line above the playground. +const String _installCommand = 'flutter pub add flutter_border_beam'; +const String _repoUrl = 'https://github.com/SimplifyJobs/flutter_border_beam'; +const String _repoLabel = 'SimplifyJobs/flutter_border_beam'; + +class _PlaygroundSectionState extends State { + late PlaygroundState _state = _initialState(); + + // One controller per preview: a BorderBeamController may drive only one + // beam at a time, and the wide layout shows two. + final List _controllers = [ + BorderBeamController(), + BorderBeamController(), + ]; + + String? _copied; + Timer? _copiedTimer; + + static PlaygroundState _initialState() { + final encoded = playgroundStateStringFrom(Uri.base); + return encoded == null ? PlaygroundState() : decodePlaygroundState(encoded); + } + + @override + void dispose() { + _copiedTimer?.cancel(); + for (final controller in _controllers) { + controller.dispose(); + } + super.dispose(); + } + + void _edit(VoidCallback mutate) { + setState(mutate); + _publishState(); + } + + // On the web the address bar carries the configuration, so a reload — or a + // copied URL — reproduces it. + void _publishState() { + if (!kIsWeb) return; + final encoded = encodePlaygroundState(_state); + SystemNavigator.routeInformationUpdated( + uri: Uri(path: '/', query: encoded.isEmpty ? null : encoded), + replace: true, + ); + } + + void _controllerDo(void Function(BorderBeamController) action) { + for (final controller in _controllers) { + action(controller); + } + } + + void _setControllerMode(bool enabled) { + _edit(() => _state.controllerMode = enabled); + } + + void _copy(String label, String text) { + unawaited(Clipboard.setData(ClipboardData(text: text))); + setState(() => _copied = label); + _copiedTimer?.cancel(); + _copiedTimer = Timer(const Duration(milliseconds: 1600), () { + if (mounted) setState(() => _copied = null); + }); + } + + String _shareLink() { + final encoded = encodePlaygroundState(_state); + if (!kIsWeb) return playgroundShareUrl(encoded); + final base = Uri.base; + final page = Uri( + scheme: base.scheme, + host: base.host, + port: base.hasPort ? base.port : null, + path: base.path, + ).toString(); + return encoded.isEmpty ? page : '$page#$encoded'; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // Wide enough for the controls to sit beside the preview instead of + // above it. + final wide = constraints.maxWidth >= 900; + final previewWidth = wide + ? constraints.maxWidth - _controlsWidth - 20 + : constraints.maxWidth; + final previews = PlaygroundPreviews( + state: _state, + controllers: _controllers, + // Both brightnesses fit beside each other once each half still + // holds a readable surface. + bothThemes: previewWidth >= 520, + ); + final controls = ControlsPanel( + state: _state, + onEdit: _edit, + onControllerMode: _setControllerMode, + onControllerAction: _controllerDo, + ); + final snippet = CodeBlock( + code: buildSnippet(_state), + copied: _copied == 'code', + onCopy: () => _copy('code', buildSnippet(_state)), + ); + + final install = InstallBlock( + command: _installCommand, + repoLabel: _repoLabel, + copied: _copied == 'install' || _copied == 'repo', + onCopyCommand: () => _copy('install', _installCommand), + onCopyRepo: () => _copy('repo', _repoUrl), + ); + + if (!wide) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + install, + const SizedBox(height: 12), + _toolbar(), + const SizedBox(height: 12), + previews, + const SizedBox(height: 16), + controls, + const SizedBox(height: 8), + snippet, + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + install, + const SizedBox(height: 12), + _toolbar(), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: _controlsWidth, child: controls), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [previews, const SizedBox(height: 16), snippet], + ), + ), + ], + ), + ], + ); + }, + ); + } + + Widget _toolbar() { + final t = DemoTheme.of(context).tokens; + return Row( + children: [ + Expanded( + child: Text( + 'Every field the beam takes, live.', + style: TextStyle( + fontSize: 12, + color: t.muted, + decoration: TextDecoration.none, + ), + ), + ), + AnimatedOpacity( + opacity: _copied == 'link' ? 1 : 0, + duration: const Duration(milliseconds: 150), + child: Text( + 'Copied', + style: TextStyle( + fontSize: 12, + color: t.muted, + decoration: TextDecoration.none, + ), + ), + ), + const SizedBox(width: 8), + DemoChip( + label: 'Copy share link', + selected: false, + onTap: () => _copy('link', _shareLink()), + ), + const SizedBox(width: 6), + DemoChip( + label: 'Reset', + selected: false, + onTap: () => _edit(() => _state = PlaygroundState()), + ), + ], + ); + } +} diff --git a/example/lib/src/playground/playground_state.dart b/example/lib/src/playground/playground_state.dart new file mode 100644 index 0000000..161ecf8 --- /dev/null +++ b/example/lib/src/playground/playground_state.dart @@ -0,0 +1,720 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; + +/// The palette choices the playground offers: the eleven presets, a +/// user-assembled [BeamColors.custom] list, a [BeamColors.fromSeed] palette +/// derived from one swatch, and a [BeamColors.lerp] blend of two presets. +enum PalettePreset { + /// [BeamColors.colorful] — the package default. + colorful('Colorful', 'colorful', BeamColors.colorful), + + /// [BeamColors.mono]. + mono('Mono', 'mono', BeamColors.mono), + + /// [BeamColors.ocean]. + ocean('Ocean', 'ocean', BeamColors.ocean), + + /// [BeamColors.sunset]. + sunset('Sunset', 'sunset', BeamColors.sunset), + + /// [BeamColors.aurora]. + aurora('Aurora', 'aurora', BeamColors.aurora), + + /// [BeamColors.neon]. + neon('Neon', 'neon', BeamColors.neon), + + /// [BeamColors.candy]. + candy('Candy', 'candy', BeamColors.candy), + + /// [BeamColors.ember]. + ember('Ember', 'ember', BeamColors.ember), + + /// [BeamColors.ice]. + ice('Ice', 'ice', BeamColors.ice), + + /// [BeamColors.gold]. + gold('Gold', 'gold', BeamColors.gold), + + /// [BeamColors.holographic]. + holographic('Holographic', 'holographic', BeamColors.holographic), + + /// Colors picked from [customSwatches]; [colors] is null because the list + /// is built from the playground state. + custom('Custom', 'custom', null), + + /// A palette derived from one swatch through [BeamColors.fromSeed]. + seed('Seed', 'seed', null), + + /// Two presets blended through [BeamColors.lerp]. + lerp('Lerp', 'lerp', null); + + const PalettePreset(this.label, this.id, this.colors); + + /// Chip label. + final String label; + + /// Stable id used by the share codec and the generated snippet. + final String id; + + /// The preset this choice maps to, or null for the three assembled modes + /// ([custom], [seed], [lerp]). + final BeamColors? colors; + + /// The choices that name a package preset — what the lerp endpoints and + /// the custom base are picked from. + static List get presets => + values.where((preset) => preset.colors != null).toList(); +} + +/// One entry of the custom-color picker: a name for the control and the +/// color the snippet emits. +typedef Swatch = ({String name, Color color}); + +/// The fixed palette the custom-colors and seed controls pick from — a small +/// preset list rather than a full color picker, which keeps the state +/// shareable as a handful of indices. +const List customSwatches = [ + (name: 'Pink', color: Color(0xFFFF0080)), + (name: 'Cyan', color: Color(0xFF00E5FF)), + (name: 'Amber', color: Color(0xFFFFC400)), + (name: 'Violet', color: Color(0xFF7C4DFF)), + (name: 'Lime', color: Color(0xFFA6FF00)), + (name: 'Red', color: Color(0xFFFF3D00)), + (name: 'Teal', color: Color(0xFF1DE9B6)), + (name: 'Blue', color: Color(0xFF2979FF)), +]; + +/// Smallest and largest number of colors [BeamColors.custom] may be given +/// from the picker. +const int minCustomColors = 2; + +/// Largest number of colors the custom picker accepts. +const int maxCustomColors = 4; + +/// The dash counts the ring-segments control offers, alongside `off`. +const List segmentChoices = [4, 6, 8, 12, 16]; + +/// Partial-contour choices exposed by the Shape controls. +enum SegmentPreset { + /// The full contour. + off('Off', 'off'), + + /// [BeamSegment.bottomHalf]. + bottomHalf('Bottom half', 'bottomHalf'), + + /// [BeamSegment.topHalf]. + topHalf('Top half', 'topHalf'), + + /// [BeamSegment.leftHalf]. + leftHalf('Left half', 'leftHalf'), + + /// [BeamSegment.rightHalf]. + rightHalf('Right half', 'rightHalf'), + + /// [BeamSegment.bottomEdge]. + bottomEdge('Bottom edge', 'bottomEdge'), + + /// [BeamSegment.topEdge]. + topEdge('Top edge', 'topEdge'), + + /// A segment assembled from the two edge-anchor controls. + custom('Custom', 'custom'); + + const SegmentPreset(this.label, this.id); + + /// Chip label. + final String label; + + /// Stable share-link identifier. + final String id; +} + +/// The five-pointed star the shape section's contour toggle installs, so the +/// playground can show a beam travelling a path that is not a rounded +/// rectangle. +/// +/// The key is what [BeamPathContour] compares on: two contours drawing the +/// same star are one value, which keeps the beam's resolved config out of +/// the rebuild path. +const BeamPathContour starContour = BeamPathContour(_starPath, key: 'star'); + +/// Traces a five-pointed star inscribed in [rect]. +Path _starPath(Rect rect) { + const points = 5; + final center = rect.center; + final outer = rect.shortestSide / 2; + final inner = outer * 0.44; + final path = Path(); + for (var i = 0; i < points * 2; i++) { + final radius = i.isEven ? outer : inner; + // Start at 12 o'clock so the star stands upright. + final angle = -math.pi / 2 + i * math.pi / points; + final point = Offset( + center.dx + radius * math.cos(angle), + center.dy + radius * math.sin(angle), + ); + if (i == 0) { + path.moveTo(point.dx, point.dy); + } else { + path.lineTo(point.dx, point.dy); + } + } + return path..close(); +} + +/// Every knob the playground drives, in one mutable bag. +/// +/// A freshly constructed instance is the "all defaults" configuration: every +/// field holds the value the package would resolve on its own, so the +/// snippet generator and the live preview can both emit *only* what differs +/// from it. Fields whose package default depends on the variant or on a +/// theme preset are nullable, and null means "let the package decide". +class PlaygroundState { + /// Creates the all-defaults state. + PlaygroundState(); + + // ─── Variant & colors ─────────────────────────────────────────────────── + + /// Which effect the preview paints. + BeamVariant variant = BeamVariant.rotate; + + /// Selected palette. + PalettePreset palette = PalettePreset.colorful; + + /// Indices into [customSwatches] used when [palette] is + /// [PalettePreset.custom]. + List customColors = [0, 1]; + + /// The preset whose blob geometry and alpha structure the custom colors + /// are distributed over. + PalettePreset customBase = PalettePreset.colorful; + + /// Index into [customSwatches] of the [BeamColors.fromSeed] brand color. + int seedColor = 7; + + /// How [BeamColors.fromSeed] spreads the seed hue. + BeamSeedHarmony seedHarmony = BeamSeedHarmony.analogous; + + /// First endpoint of a [BeamColors.lerp] palette. + PalettePreset lerpFrom = PalettePreset.ocean; + + /// Second endpoint of a [BeamColors.lerp] palette. + PalettePreset lerpTo = PalettePreset.sunset; + + /// Where the [BeamColors.lerp] blend sits between its endpoints. + double lerpT = 0.5; + + /// Multiplier applied to every palette entry's alpha through + /// [BeamColors.scaleAlpha]. + double alphaScale = 1; + + // ─── Shape ────────────────────────────────────────────────────────────── + + /// Whether the contour is a pill ([BeamShape.stadium]). + bool stadium = false; + + /// Whether the four corners are set individually. + bool perCorner = false; + + /// Uniform corner radius override. Null keeps the variant preset. + double? radius; + + /// Top-left radius in per-corner mode. + double radiusTopLeft = 16; + + /// Top-right radius in per-corner mode. + double radiusTopRight = 16; + + /// Bottom-right radius in per-corner mode. + double radiusBottomRight = 16; + + /// Bottom-left radius in per-corner mode. + double radiusBottomLeft = 16; + + /// Whether the contour uses superellipse corners. + bool superellipse = false; + + /// Stroke ring thickness in logical px. + double borderWidth = 1; + + /// Which edge the line variant's beam rides. + BeamEdge edge = BeamEdge.bottom; + + /// How far the ring sits outside (positive) or inside (negative) the + /// child's bounds, in logical px. + double ringOffset = 0; + + /// Whether the beam travels [starContour] instead of a rounded rectangle. + bool contour = false; + + /// Which part of the contour is visible. + SegmentPreset segmentPreset = SegmentPreset.off; + + /// Edge carrying the custom segment's start anchor. + BeamEdge segmentStartEdge = BeamEdge.right; + + /// Position along [segmentStartEdge]'s clockwise straight run. + double segmentStartT = 0.5; + + /// Edge carrying the custom segment's end anchor. + BeamEdge segmentEndEdge = BeamEdge.left; + + /// Position along [segmentEndEdge]'s clockwise straight run. + double segmentEndT = 0.5; + + /// Fade length at each end of a custom segment, in logical px. + double segmentFeather = 48; + + /// Whether a segment-free line bends around its adjacent corners. + bool wrapCorners = false; + + // ─── Timing ───────────────────────────────────────────────────────────── + + /// Cycle length in seconds; null keeps the variant preset. + double? cycleSeconds; + + /// Rest between sweeps in seconds. + double cycleGapSeconds = 0; + + /// Declarative playback rate. Ignored in [controllerMode]. + double speed = 1; + + /// Which way the beam travels its contour. + BeamDirection direction = BeamDirection.forward; + + /// Fraction of a cycle, 0–1, the timeline starts at. + double phaseOffset = 0; + + /// How many beams travel the contour at once. + int beamCount = 1; + + /// Hue track period in seconds; null keeps the variant preset. + double? huePeriodSeconds; + + /// The line beam's breathe period, as a multiple of the cycle. + double breatheFactor = 1.3; + + /// The line beam's first spike period, as a multiple of the cycle. + double spikeFactor = 1.33; + + /// The line beam's second spike period, as a multiple of the cycle. + double spike2Factor = 1.7; + + /// Whether the hue animation is disabled. + bool staticColors = false; + + // ─── Style ────────────────────────────────────────────────────────────── + + /// Effect opacity, 0–1. + double strength = 1; + + /// Glow brightness multiplier; null keeps the theme preset. + double? brightness; + + /// Glow saturation multiplier; null keeps the theme preset. + double? saturation; + + /// Hue animation amplitude in degrees. + double hueRange = 30; + + /// Whether the hue swings or revolves; null keeps the variant default. + BeamHueMode? hueMode; + + /// Static hue offset in degrees. + double hueBase = 0; + + /// Stroke ring opacity multiplier. + double strokeOpacityFactor = 1; + + /// Inner glow opacity multiplier. + double innerOpacityFactor = 1; + + /// Bloom opacity multiplier. + double bloomOpacityFactor = 1; + + /// Angular width multiplier of the traveling window. + double tailLength = 1; + + /// How far the bloom and halo layers reach past the stroke ring. + double glowSpread = 1; + + /// Whether a soft halo trails the traveling head outside the ring. + bool comet = false; + + /// Density of the twinkles scattered at the traveling head, 0–1. + double sparkle = 0; + + /// Number of dashes the ring is broken into; null keeps it solid. + int? segments; + + /// Multiplier on the size of the pulse-inside inner wash. + double innerSizeScale = 1; + + /// The fraction of the box the beam is painted at before being magnified + /// back up to fill it. + double renderScale = 1; + + /// Whether pulse-outside paints [BeamStyle.pulseOutsideStock] — the React + /// library's own look, before the demo page's tuning — under the fields + /// set here. + bool stockPulseOutside = false; + + /// Pulse glow prominence multiplier. + double glowBoost = 1; + + /// pulse-outside core blur override in px; null keeps the preset. + double? coreBlur; + + /// pulse-outside halo blur override in px; null keeps the preset. + double? bloomBlur; + + /// pulse-outside glow brightness override; null keeps the preset. + double? glowBrightness; + + /// pulse-outside glow saturation override; null keeps the preset. + double? glowSaturation; + + // ─── Playback ─────────────────────────────────────────────────────────── + + /// Declarative play state. Ignored in [controllerMode]. + bool active = true; + + /// How many cycles the beam runs before it fades out; null loops forever. + int? repeatCycles; + + /// What the beam does under reduced motion; null keeps the package + /// default, [BeamReducedMotion.staticFrame]. + BeamReducedMotion? reducedMotion; + + /// Whether the preview is wrapped in a `MediaQuery` asking for reduced + /// motion, so [reducedMotion] can be watched without OS settings. A + /// preview concern only: it reaches neither the beam's fields nor the + /// snippet. + bool simulateReducedMotion = false; + + /// Whether the beam pauses while scrolled out of view. Null keeps the + /// package default (true), while false explicitly opts out. + bool? pauseWhenOffscreen; + + /// Whether the fades run on [BeamPlayback.cssEase] instead of the spring. + bool cssFadeCurve = false; + + /// Whether a [BorderBeamController] drives playback instead of the + /// declarative fields. + bool controllerMode = false; + + /// The controller's playback rate, used in [controllerMode]. + double controllerSpeed = 1; + + /// Autoplay delay in seconds; 0 means none. + double startAfterSeconds = 0; + + /// Total play time in seconds; 0 means forever. + double durationSeconds = 0; + + // ─── Drive ────────────────────────────────────────────────────────────── + + /// Whether `BorderBeam.progress` parks the sweep instead of the clock. + bool driveProgress = false; + + /// Where the driven sweep sits, 0–1. + double progress = 0.35; + + /// Whether the preview feeds pointer position to `BorderBeam.follow`. + bool followPointer = false; + + /// Whether a sine-wave signal drives `BorderBeam.strengthListenable`. + bool strengthSignal = false; + + // ─── Group demos ──────────────────────────────────────────────────────── + + /// Whether the preview sits inside a [BorderBeamTheme] supplying ocean + + /// squircle-20 defaults, so fields left at their default inherit from it. + bool themeDemo = false; + + /// Whether the preview shows three beams under one [BeamSync], each a + /// third of a cycle apart. + bool syncDemo = false; + + // ─── Derived values ───────────────────────────────────────────────────── + + /// The colors this state selects. + BeamColors get beamColors { + final base = switch (palette) { + PalettePreset.custom => BeamColors.custom([ + for (final i in customColors) customSwatches[i].color, + ], base: customBase.colors ?? BeamColors.colorful), + PalettePreset.seed => BeamColors.fromSeed( + customSwatches[seedColor].color, + harmony: seedHarmony, + ), + PalettePreset.lerp => BeamColors.lerp( + lerpFrom.colors ?? BeamColors.colorful, + lerpTo.colors ?? BeamColors.colorful, + lerpT, + ), + _ => palette.colors ?? BeamColors.colorful, + }; + return alphaScale == 1 ? base : base.scaleAlpha(alphaScale); + } + + /// Whether the palette differs from the package default. + bool get hasColors => palette != PalettePreset.colorful || alphaScale != 1; + + /// Whether the variant's beam travels its contour — the family that has a + /// direction, a phase, a head, and a progress to drive. + bool get isTraveling => !variant.isPulse; + + /// Whether the variant sweeps the whole ring, which is what a tail length + /// and a comet halo shape: rotate and small, but not the line beam. + bool get isRing => + variant == BeamVariant.rotate || variant == BeamVariant.small; + + /// The uniform radius the package would resolve on its own. + double get defaultRadius => variant.defaultBorderRadius; + + /// The cycle length in seconds the package would resolve on its own. + double get defaultCycleSeconds => + variant.defaultCycleDuration.inMilliseconds / 1000; + + /// The hue period in seconds the package would resolve on its own — 12s + /// for the traveling variants, and the pulse presets' own periods. + double get defaultHuePeriodSeconds => switch (variant) { + BeamVariant.pulseInside => 16, + BeamVariant.pulseOutside => 14, + _ => 12, + }; + + /// The brightness multiplier the preset carries for [variant] at + /// [themeBrightness]. + double defaultBrightness(Brightness themeBrightness) => + BeamThemeConfig.presetFor(variant, themeBrightness).brightness ?? 1.3; + + /// The saturation multiplier the preset carries for [variant] at + /// [themeBrightness]. + double defaultSaturation(Brightness themeBrightness) => + BeamThemeConfig.presetFor(variant, themeBrightness).saturation; + + /// The corner radii the beam contour uses, per-corner mode included. + /// Stadium corners are infinite and clamped per corner by the ring + /// geometry, which is what makes them track the box. + BorderRadius get borderRadius => stadium + ? const BorderRadius.all(Radius.circular(double.infinity)) + : perCorner + ? BorderRadius.only( + topLeft: Radius.circular(radiusTopLeft), + topRight: Radius.circular(radiusTopRight), + bottomRight: Radius.circular(radiusBottomRight), + bottomLeft: Radius.circular(radiusBottomLeft), + ) + : BorderRadius.circular(radius ?? defaultRadius); + + /// The same contour, sized for a concrete [size] — the preview surface's + /// own decoration needs finite stadium radii. + BorderRadius resolvedBorderRadius(Size size) => stadium + ? BorderRadius.circular( + (size.shortestSide / 2).clamp(0.0, double.maxFinite), + ) + : borderRadius; + + /// Whether the shape differs from what the package would resolve. + bool get hasShape => + stadium || + perCorner || + superellipse || + contour || + radius != null || + borderWidth != 1 || + ringOffset != 0 || + segmentPreset != SegmentPreset.off || + (variant == BeamVariant.line && + segmentPreset == SegmentPreset.off && + (edge != BeamEdge.bottom || wrapCorners)); + + /// The selected partial contour, or null for the full contour. + BeamSegment? get segment => switch (segmentPreset) { + SegmentPreset.off => null, + SegmentPreset.bottomHalf => BeamSegment.bottomHalf, + SegmentPreset.topHalf => BeamSegment.topHalf, + SegmentPreset.leftHalf => BeamSegment.leftHalf, + SegmentPreset.rightHalf => BeamSegment.rightHalf, + SegmentPreset.bottomEdge => BeamSegment.bottomEdge, + SegmentPreset.topEdge => BeamSegment.topEdge, + SegmentPreset.custom => BeamSegment( + start: BeamAnchor.edge(segmentStartEdge, segmentStartT), + end: BeamAnchor.edge(segmentEndEdge, segmentEndT), + feather: segmentFeather, + ), + }; + + // ─── Value objects ────────────────────────────────────────────────────── + + /// The [BeamShape] to hand the widget, or null when nothing differs from + /// the package default. + BeamShape? buildShape() { + if (!hasShape) return null; + final width = borderWidth == 1 ? null : borderWidth; + final se = superellipse ? true : null; + final beamEdge = + variant == BeamVariant.line && + segmentPreset == SegmentPreset.off && + edge != BeamEdge.bottom + ? edge + : null; + final offset = ringOffset == 0 ? null : ringOffset; + final path = contour ? starContour : null; + if (stadium) { + return BeamShape.stadium( + borderWidth: width, + superellipse: se, + edge: beamEdge, + ringOffset: offset, + contour: path, + segment: segment, + wrapCorners: + variant == BeamVariant.line && + segmentPreset == SegmentPreset.off && + wrapCorners + ? true + : null, + ); + } + return BeamShape( + radius: borderRadius, + borderWidth: width, + superellipse: se, + edge: beamEdge, + ringOffset: offset, + contour: path, + segment: segment, + wrapCorners: + variant == BeamVariant.line && + segmentPreset == SegmentPreset.off && + wrapCorners + ? true + : null, + ); + } + + /// The [BeamStyle] to hand the widget, or null when nothing differs. + BeamStyle? buildStyle() { + final style = BeamStyle( + colors: hasColors ? beamColors : null, + strength: strength == 1 ? null : strength, + brightness: brightness, + saturation: saturation, + hueRange: hueRange == 30 ? null : hueRange, + hueMode: hueMode, + hueBase: hueBase == 0 ? null : hueBase, + staticColors: staticColors ? true : null, + strokeOpacityFactor: strokeOpacityFactor == 1 + ? null + : strokeOpacityFactor, + innerOpacityFactor: innerOpacityFactor == 1 ? null : innerOpacityFactor, + bloomOpacityFactor: bloomOpacityFactor == 1 ? null : bloomOpacityFactor, + tailLength: isRing && tailLength != 1 ? tailLength : null, + glowSpread: glowSpread == 1 ? null : glowSpread, + comet: isRing && comet ? true : null, + sparkle: isTraveling && sparkle != 0 ? sparkle : null, + segments: variant == BeamVariant.line ? null : segments, + innerSizeScale: variant == BeamVariant.pulseInside && innerSizeScale != 1 + ? innerSizeScale + : null, + renderScale: renderScale == 1 ? null : renderScale, + glowBoost: variant.isPulse && glowBoost != 1 ? glowBoost : null, + coreBlur: variant == BeamVariant.pulseOutside ? coreBlur : null, + bloomBlur: variant == BeamVariant.pulseOutside ? bloomBlur : null, + glowBrightness: variant == BeamVariant.pulseOutside + ? glowBrightness + : null, + glowSaturation: variant == BeamVariant.pulseOutside + ? glowSaturation + : null, + ); + // The stock look is a whole style, not a field: it goes underneath, so + // anything set here still wins. + if (usesStockPulseOutside) { + return BeamStyle.pulseOutsideStock.merge(style); + } + return style == const BeamStyle() ? null : style; + } + + /// Whether the stock pulse-outside style applies — it is that variant's + /// own recipe, and no other variant reads it. + bool get usesStockPulseOutside => + stockPulseOutside && variant == BeamVariant.pulseOutside; + + /// The [BeamTiming] to hand the widget, or null when nothing differs. + /// + /// The breathe/spike factors belong to the line variant, the travel fields + /// to the traveling ones, and a controller owns the playback rate, so each + /// is dropped where it does not apply. + BeamTiming? buildTiming() { + final isLine = variant == BeamVariant.line; + final timing = BeamTiming( + cycle: cycleSeconds == null ? null : _durationOf(cycleSeconds!), + cycleGap: cycleGapSeconds == 0 ? null : _durationOf(cycleGapSeconds), + speed: speed == 1 || controllerMode ? null : speed, + direction: isTraveling && direction != BeamDirection.forward + ? direction + : null, + phaseOffset: isTraveling && phaseOffset != 0 ? phaseOffset : null, + beamCount: isTraveling && beamCount != 1 ? beamCount : null, + huePeriod: huePeriodSeconds == null + ? null + : _durationOf(huePeriodSeconds!), + breatheFactor: isLine && breatheFactor != 1.3 ? breatheFactor : null, + spikeFactor: isLine && spikeFactor != 1.33 ? spikeFactor : null, + spike2Factor: isLine && spike2Factor != 1.7 ? spike2Factor : null, + ); + return timing == const BeamTiming() ? null : timing; + } + + /// The [BeamPlayback] to hand the widget, or null when nothing differs. + /// + /// A controller owns scheduling exclusively, so `startAfter` and + /// `duration` are dropped alongside one; `repeat` and `reducedMotion` are + /// the beam's own and survive either way. + BeamPlayback? buildPlayback() { + final playback = BeamPlayback( + startAfter: startAfterSeconds == 0 || controllerMode + ? null + : _durationOf(startAfterSeconds), + duration: durationSeconds == 0 || controllerMode + ? null + : _durationOf(durationSeconds), + repeat: buildRepeat(), + reducedMotion: reducedMotion, + pauseWhenOffscreen: pauseWhenOffscreen, + fadeCurve: cssFadeCurve ? BeamPlayback.cssEase : null, + ); + return playback == const BeamPlayback() ? null : playback; + } + + /// The [BeamRepeat] the cycle-count control selects, or null while the + /// beam loops forever. + BeamRepeat? buildRepeat() => switch (repeatCycles) { + null => null, + 1 => const BeamRepeat.once(), + final int n => BeamRepeat.count(n), + }; + + /// The `active:` shorthand, or null when the controller owns playback or + /// the beam is simply on. + bool? buildActive() => controllerMode || active ? null : false; + + /// The `progress:` value, or null while the clock owns the travel — which + /// the pulse variants always do. + double? buildProgress() => driveProgress && isTraveling ? progress : null; + + /// Whether the preview feeds `follow:` a pointer position. + /// + /// `progress` parks the sweep where it says, so a beam driven by one has no + /// travel left for the pointer to pull; the pulse variants never travel. + bool get followsPointer => followPointer && isTraveling && !driveProgress; +} + +Duration _durationOf(double seconds) => + Duration(milliseconds: (seconds * 1000).round()); diff --git a/example/lib/src/playground/preview.dart b/example/lib/src/playground/preview.dart new file mode 100644 index 0000000..53def27 --- /dev/null +++ b/example/lib/src/playground/preview.dart @@ -0,0 +1,363 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; + +import '../demo_theme.dart'; +import 'playground_state.dart'; + +// The theme the "Theme demo" toggle installs above the preview. +final BorderBeamThemeData _demoThemeData = BorderBeamThemeData( + style: const BeamStyle(colors: BeamColors.ocean), + shape: BeamShape.circular(20, superellipse: true), +); + +// The preview surface, and the frame that gives pulse-outside room to bloom. +const double _surfaceHeight = 128; +const double _maxSurfaceWidth = 320; +const double _frameHeight = 240; + +// The sync demo's three stacked cards. +const int _syncBeams = 3; +const double _syncSurfaceHeight = 52; +const double _syncGap = 14; + +// One full swing of the strength signal. +const Duration _signalPeriod = Duration(milliseconds: 2400); + +class PlaygroundPreviews extends StatelessWidget { + /// Creates the preview area. + const PlaygroundPreviews({ + super.key, + required this.state, + required this.controllers, + required this.bothThemes, + }); + + /// The configuration to render. + final PlaygroundState state; + + /// One controller per preview — a BorderBeamController drives a single + /// beam at a time, and the wide layout shows two. + final List controllers; + + /// Whether to render the dark and light backdrops side by side. + final bool bothThemes; + + @override + Widget build(BuildContext context) { + final isDark = DemoTheme.of(context).isDark; + if (!bothThemes) { + return _Preview( + key: GlobalObjectKey(controllers.first), + state: state, + brightness: isDark ? Brightness.dark : Brightness.light, + controller: controllers.first, + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _Preview( + key: GlobalObjectKey(controllers[0]), + state: state, + brightness: Brightness.dark, + controller: controllers[0], + ), + ), + const SizedBox(width: 16), + Expanded( + child: _Preview( + key: GlobalObjectKey(controllers[1]), + state: state, + brightness: Brightness.light, + controller: controllers[1], + ), + ), + ], + ); + } +} + +class _Preview extends StatefulWidget { + const _Preview({ + super.key, + required this.state, + required this.brightness, + required this.controller, + }); + + final PlaygroundState state; + final Brightness brightness; + final BorderBeamController controller; + + @override + State<_Preview> createState() => _PreviewState(); +} + +class _PreviewState extends State<_Preview> + with SingleTickerProviderStateMixin { + // Where the pointer is, in normalized box coordinates, while the Drive + // section's follow toggle is on. + Offset? _follow; + + // The signal behind `strengthListenable`: a sine wave, so the beam breathes + // with something that is neither the clock nor a rebuild. + final ValueNotifier _level = ValueNotifier(1); + late final Ticker _ticker = createTicker(_tick); + late bool _controllerMode = widget.state.controllerMode; + late bool _syncDemo = widget.state.syncDemo; + + @override + void initState() { + super.initState(); + _syncSignal(); + _startAttachedController(); + } + + @override + void didUpdateWidget(_Preview oldWidget) { + super.didUpdateWidget(oldWidget); + _syncSignal(); + if (!widget.state.followsPointer) _follow = null; + if (_controllerMode && !widget.state.controllerMode) { + // Restore a visible clock before BorderBeam detaches the controller; + // otherwise disabling controller mode after Stop leaves a blank beam. + widget.controller.start(); + } + final needsController = + widget.state.controllerMode && + !widget.state.syncDemo && + (!_controllerMode || _syncDemo); + _controllerMode = widget.state.controllerMode; + _syncDemo = widget.state.syncDemo; + if (needsController) _startAttachedController(); + } + + @override + void dispose() { + _ticker.dispose(); + _level.dispose(); + super.dispose(); + } + + void _syncSignal() { + final wanted = widget.state.strengthSignal; + if (wanted == _ticker.isActive) return; + if (wanted) { + _ticker.start(); + } else { + _ticker.stop(); + _level.value = 1; + } + } + + void _tick(Duration elapsed) { + final turns = elapsed.inMicroseconds / _signalPeriod.inMicroseconds; + // 0.15–1, so the bottom of the swing dims the beam rather than killing it. + _level.value = 0.575 + 0.425 * math.sin(turns * 2 * math.pi); + } + + void _startAttachedController() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + !widget.state.controllerMode || + widget.state.syncDemo || + !widget.controller.isAttached) { + return; + } + widget.controller + ..speed = widget.state.controllerSpeed + ..start(); + }); + } + + void _track(Offset local, Size size) { + if (!widget.state.followsPointer) return; + setState(() { + _follow = Offset( + (local.dx / size.width).clamp(0.0, 1.0), + (local.dy / size.height).clamp(0.0, 1.0), + ); + }); + } + + void _release() { + if (_follow == null) return; + setState(() => _follow = null); + } + + @override + Widget build(BuildContext context) { + final state = widget.state; + final dark = widget.brightness == Brightness.dark; + final tokens = dark ? DemoTokens.dark : DemoTokens.light; + + Widget frame(Widget child) => Container( + height: _frameHeight, + decoration: BoxDecoration( + color: dark ? const Color(0xFF101010) : const Color(0xFFF7F7F7), + borderRadius: BorderRadius.circular(24), + ), + alignment: Alignment.center, + child: child, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final width = math.min( + _maxSurfaceWidth, + math.max(120.0, constraints.maxWidth - 56), + ); + Widget content = state.syncDemo + ? _syncGroup(state, tokens, width) + : _single(state, tokens, width); + if (state.themeDemo) { + content = BorderBeamTheme(data: _demoThemeData, child: content); + } + if (state.simulateReducedMotion) { + // The same signal the platform sends, so the reducedMotion chips + // can be watched without touching OS settings. + content = MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: content, + ); + } + // BeamTheme.auto reads the ambient brightness, so each preview only + // needs the right Theme above it — no explicit `theme:` field, which + // keeps the generated snippet faithful to what is on screen. + return Theme( + data: ThemeData(brightness: widget.brightness), + child: frame(content), + ); + }, + ); + } + + // The single configured beam, wrapped in a pointer listener while the + // follow toggle is on. + Widget _single(PlaygroundState state, DemoTokens tokens, double width) { + final segmentSide = math.min(width, 200.0); + final size = state.segmentPreset == SegmentPreset.off + ? Size(width, _surfaceHeight) + : Size.square(segmentSide); + final Widget beam = SizedBox( + width: size.width, + height: size.height, + child: BorderBeam( + variant: state.variant, + active: state.buildActive(), + style: state.buildStyle(), + shape: state.buildShape(), + timing: state.buildTiming(), + playback: state.buildPlayback(), + progress: state.buildProgress(), + follow: state.followsPointer ? _follow : null, + strengthListenable: state.strengthSignal ? _level : null, + controller: state.controllerMode ? widget.controller : null, + child: _MockSurface(state: state, tokens: tokens, size: size), + ), + ); + if (!state.followsPointer) return beam; + // The Listener carries touch drags; the MouseRegion adds plain hover, + // which sends no pointer events of its own. + return MouseRegion( + onHover: (event) => _track(event.localPosition, size), + onExit: (_) => _release(), + child: Listener( + onPointerDown: (event) => _track(event.localPosition, size), + onPointerMove: (event) => _track(event.localPosition, size), + onPointerUp: (_) => _release(), + onPointerCancel: (_) => _release(), + child: beam, + ), + ); + } + + // Three beams on one BeamSync clock, evenly spaced around the cycle. + // + // The group owns playback, and a BorderBeamController owns a clock of its + // own, so no controller is attached here. + Widget _syncGroup(PlaygroundState state, DemoTokens tokens, double width) { + final size = Size(width, _syncSurfaceHeight); + final base = state.buildTiming() ?? const BeamTiming(); + return BeamSync( + active: state.active, + speed: state.speed, + reducedMotion: state.reducedMotion ?? BeamReducedMotion.staticFrame, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < _syncBeams; i++) ...[ + if (i > 0) const SizedBox(height: _syncGap), + SizedBox( + width: width, + height: _syncSurfaceHeight, + child: BorderBeam( + variant: state.variant, + style: state.buildStyle(), + shape: state.buildShape(), + timing: base.copyWith(phaseOffset: i / _syncBeams), + progress: state.buildProgress(), + strengthListenable: state.strengthSignal ? _level : null, + child: _MockSurface( + state: state, + tokens: tokens, + size: size, + label: 'phaseOffset ${(i / _syncBeams).toStringAsFixed(2)}', + ), + ), + ), + ], + ], + ), + ); + } +} + +/// The surface the beam hugs. Its own corners must match the beam contour, +/// theme inheritance included — the beam does not read the child's shape. +class _MockSurface extends StatelessWidget { + const _MockSurface({ + required this.state, + required this.tokens, + required this.size, + this.label = 'Build anything...', + }); + + final PlaygroundState state; + final DemoTokens tokens; + final Size size; + final String label; + + @override + Widget build(BuildContext context) { + // With no shape of its own, the beam inherits the demo theme's squircle + // 20 — so the surface follows it there too. + final inherits = state.themeDemo && !state.hasShape; + final radius = inherits + ? BorderRadius.circular(20) + : state.resolvedBorderRadius(size); + final superellipse = inherits || state.superellipse; + final side = BorderSide(color: tokens.mockBorder); + return Container( + decoration: ShapeDecoration( + color: tokens.mockBg, + shape: superellipse + ? RoundedSuperellipseBorder(borderRadius: radius, side: side) + : RoundedRectangleBorder(borderRadius: radius, side: side), + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + fontSize: 13, + color: tokens.mockPlaceholder, + decoration: TextDecoration.none, + ), + ), + ); + } +} diff --git a/example/lib/src/playground/share_codec.dart b/example/lib/src/playground/share_codec.dart new file mode 100644 index 0000000..1f4d43c --- /dev/null +++ b/example/lib/src/playground/share_codec.dart @@ -0,0 +1,365 @@ +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter_border_beam/flutter_border_beam.dart'; + +import 'playground_state.dart'; + +/// Where the example is published, used for the share link built off the web. +const String playgroundSiteUrl = + 'https://simplifyjobs.github.io/flutter_border_beam/'; + +/// Encodes [state] as a compact `key=value&key=value` string. +/// +/// Only fields that differ from a fresh [PlaygroundState] are written, so the +/// default configuration encodes to the empty string. Every value is plain +/// ASCII (`[A-Za-z0-9.,-]`), so the result needs no percent-escaping in a +/// URL query or fragment. +String encodePlaygroundState(PlaygroundState state) { + final d = PlaygroundState(); + final parts = []; + void put(String key, Object value) => parts.add('$key=$value'); + void number(String key, double value, double fallback) { + if (value != fallback) put(key, _formatNumber(value)); + } + + void optional(String key, double? value) { + if (value != null) put(key, _formatNumber(value)); + } + + void flag(String key, bool value) { + if (value) put(key, 1); + } + + void choice(String key, Enum value, Enum fallback) { + if (value != fallback) put(key, value.name); + } + + void integer(String key, int? value, int? fallback) { + if (value != fallback && value != null) put(key, value); + } + + if (state.variant != d.variant) put('v', _variantIds[state.variant]!); + if (state.palette != d.palette) put('p', state.palette.id); + if (!listEquals(state.customColors, d.customColors)) { + put('cc', state.customColors.join(',')); + } + if (state.customBase != d.customBase) put('pb', state.customBase.id); + integer('sdc', state.seedColor, d.seedColor); + choice('sdh', state.seedHarmony, d.seedHarmony); + if (state.lerpFrom != d.lerpFrom) put('lfa', state.lerpFrom.id); + if (state.lerpTo != d.lerpTo) put('lto', state.lerpTo.id); + number('lt', state.lerpT, d.lerpT); + number('al', state.alphaScale, d.alphaScale); + + flag('st', state.stadium); + flag('pc', state.perCorner); + optional('r', state.radius); + number('rtl', state.radiusTopLeft, d.radiusTopLeft); + number('rtr', state.radiusTopRight, d.radiusTopRight); + number('rbr', state.radiusBottomRight, d.radiusBottomRight); + number('rbl', state.radiusBottomLeft, d.radiusBottomLeft); + flag('se', state.superellipse); + number('bw', state.borderWidth, d.borderWidth); + choice('ed', state.edge, d.edge); + number('ro', state.ringOffset, d.ringOffset); + flag('ct', state.contour); + if (state.segmentPreset != SegmentPreset.off) { + put('sgp', state.segmentPreset.id); + if (state.segmentPreset == SegmentPreset.custom) { + if (state.segmentStartEdge != d.segmentStartEdge || + state.segmentStartT != d.segmentStartT) { + put( + 'sga', + '${state.segmentStartEdge.name},${_formatNumber(state.segmentStartT)}', + ); + } + if (state.segmentEndEdge != d.segmentEndEdge || + state.segmentEndT != d.segmentEndT) { + put( + 'sgb', + '${state.segmentEndEdge.name},${_formatNumber(state.segmentEndT)}', + ); + } + if (state.segmentFeather != d.segmentFeather) { + put('sgf', _formatNumber(state.segmentFeather)); + } + } + } + flag('wc', state.wrapCorners); + + optional('cy', state.cycleSeconds); + number('cg', state.cycleGapSeconds, d.cycleGapSeconds); + number('sp', state.speed, d.speed); + choice('dir', state.direction, d.direction); + number('po', state.phaseOffset, d.phaseOffset); + integer('bc', state.beamCount, d.beamCount); + optional('hp', state.huePeriodSeconds); + number('bf', state.breatheFactor, d.breatheFactor); + number('sf', state.spikeFactor, d.spikeFactor); + number('s2', state.spike2Factor, d.spike2Factor); + flag('sc', state.staticColors); + + number('str', state.strength, d.strength); + optional('br', state.brightness); + optional('sa', state.saturation); + number('hr', state.hueRange, d.hueRange); + if (state.hueMode case final BeamHueMode mode) put('hm', mode.name); + number('hb', state.hueBase, d.hueBase); + number('so', state.strokeOpacityFactor, d.strokeOpacityFactor); + number('io', state.innerOpacityFactor, d.innerOpacityFactor); + number('bo', state.bloomOpacityFactor, d.bloomOpacityFactor); + number('tl', state.tailLength, d.tailLength); + number('gs', state.glowSpread, d.glowSpread); + flag('co', state.comet); + number('sk', state.sparkle, d.sparkle); + integer('sg', state.segments, d.segments); + number('iss', state.innerSizeScale, d.innerSizeScale); + number('rs', state.renderScale, d.renderScale); + flag('pos', state.stockPulseOutside); + number('gb', state.glowBoost, d.glowBoost); + optional('cb', state.coreBlur); + optional('bb', state.bloomBlur); + optional('gbr', state.glowBrightness); + optional('gsa', state.glowSaturation); + + if (!state.active) put('a', 0); + integer('rp', state.repeatCycles, d.repeatCycles); + if (state.reducedMotion case final BeamReducedMotion mode) { + put('rm', mode.name); + } + flag('srm', state.simulateReducedMotion); + if (state.pauseWhenOffscreen case final bool value) { + put('pwo', value ? 1 : 0); + } + flag('fc', state.cssFadeCurve); + flag('cm', state.controllerMode); + number('cs', state.controllerSpeed, d.controllerSpeed); + number('sd', state.startAfterSeconds, d.startAfterSeconds); + number('du', state.durationSeconds, d.durationSeconds); + + flag('dp', state.driveProgress); + number('pr', state.progress, d.progress); + flag('fp', state.followPointer); + flag('ss', state.strengthSignal); + + flag('th', state.themeDemo); + flag('sy', state.syncDemo); + + return parts.join('&'); +} + +/// Decodes an [encodePlaygroundState] string back into a state. +/// +/// Unknown keys, malformed pairs, and out-of-range values are ignored: the +/// corresponding field keeps its default, so a truncated or hand-edited link +/// still opens a usable playground. +PlaygroundState decodePlaygroundState(String encoded) { + final state = PlaygroundState(); + final values = {}; + for (final pair in encoded.split('&')) { + if (pair.isEmpty) continue; + final split = pair.indexOf('='); + if (split <= 0) continue; + values[pair.substring(0, split)] = pair.substring(split + 1); + } + + double? read(String key, double min, double max) { + final raw = values[key]; + if (raw == null) return null; + final parsed = double.tryParse(raw); + if (parsed == null || parsed.isNaN || parsed < min || parsed > max) { + return null; + } + return parsed; + } + + bool? readFlag(String key) => switch (values[key]) { + '1' => true, + '0' => false, + _ => null, + }; + + int? readInt(String key, int min, int max) { + final parsed = int.tryParse(values[key] ?? ''); + if (parsed == null || parsed < min || parsed > max) return null; + return parsed; + } + + T? readEnum(String key, List options) { + final raw = values[key]; + for (final option in options) { + if (option.name == raw) return option; + } + return null; + } + + PalettePreset? readPreset(String key) { + for (final preset in PalettePreset.presets) { + if (preset.id == values[key]) return preset; + } + return null; + } + + ({BeamEdge edge, double t})? readAnchor(String key) { + final parts = values[key]?.split(','); + if (parts == null || parts.length != 2) return null; + BeamEdge? edge; + for (final option in BeamEdge.values) { + if (option.name == parts.first) edge = option; + } + final t = double.tryParse(parts.last); + if (edge == null || t == null || t.isNaN || t < 0 || t > 1) return null; + return (edge: edge, t: t); + } + + for (final entry in _variantIds.entries) { + if (entry.value == values['v']) state.variant = entry.key; + } + for (final preset in PalettePreset.values) { + if (preset.id == values['p']) state.palette = preset; + } + final colors = values['cc']; + if (colors != null) { + final parsed = [ + for (final part in colors.split(',')) + if (int.tryParse(part) case final int i) + if (i >= 0 && i < customSwatches.length) i, + ]; + if (parsed.length >= minCustomColors) { + state.customColors = parsed.take(maxCustomColors).toList(); + } + } + + state.customBase = readPreset('pb') ?? state.customBase; + state.seedColor = + readInt('sdc', 0, customSwatches.length - 1) ?? state.seedColor; + state.seedHarmony = + readEnum('sdh', BeamSeedHarmony.values) ?? state.seedHarmony; + state.lerpFrom = readPreset('lfa') ?? state.lerpFrom; + state.lerpTo = readPreset('lto') ?? state.lerpTo; + state.lerpT = read('lt', 0, 1) ?? state.lerpT; + state.alphaScale = read('al', 0, 2) ?? state.alphaScale; + + state.stadium = readFlag('st') ?? state.stadium; + state.perCorner = readFlag('pc') ?? state.perCorner; + state.radius = read('r', 0, 48) ?? state.radius; + state.radiusTopLeft = read('rtl', 0, 48) ?? state.radiusTopLeft; + state.radiusTopRight = read('rtr', 0, 48) ?? state.radiusTopRight; + state.radiusBottomRight = read('rbr', 0, 48) ?? state.radiusBottomRight; + state.radiusBottomLeft = read('rbl', 0, 48) ?? state.radiusBottomLeft; + state.superellipse = readFlag('se') ?? state.superellipse; + state.borderWidth = read('bw', 0.5, 4) ?? state.borderWidth; + state.edge = readEnum('ed', BeamEdge.values) ?? state.edge; + state.ringOffset = read('ro', -12, 24) ?? state.ringOffset; + state.contour = readFlag('ct') ?? state.contour; + for (final preset in SegmentPreset.values) { + if (preset.id == values['sgp']) state.segmentPreset = preset; + } + if (readAnchor('sga') case final anchor?) { + state.segmentStartEdge = anchor.edge; + state.segmentStartT = anchor.t; + } + if (readAnchor('sgb') case final anchor?) { + state.segmentEndEdge = anchor.edge; + state.segmentEndT = anchor.t; + } + state.segmentFeather = read('sgf', 0, 120) ?? state.segmentFeather; + state.wrapCorners = readFlag('wc') ?? state.wrapCorners; + + state.cycleSeconds = read('cy', 0.5, 8); + state.cycleGapSeconds = read('cg', 0, 4) ?? state.cycleGapSeconds; + state.speed = read('sp', 0.25, 4) ?? state.speed; + state.direction = readEnum('dir', BeamDirection.values) ?? state.direction; + state.phaseOffset = read('po', 0, 1) ?? state.phaseOffset; + state.beamCount = readInt('bc', 1, 4) ?? state.beamCount; + state.huePeriodSeconds = read('hp', 2, 30); + state.breatheFactor = read('bf', 0.5, 3) ?? state.breatheFactor; + state.spikeFactor = read('sf', 0.5, 3) ?? state.spikeFactor; + state.spike2Factor = read('s2', 0.5, 3) ?? state.spike2Factor; + state.staticColors = readFlag('sc') ?? state.staticColors; + + state.strength = read('str', 0, 1) ?? state.strength; + state.brightness = read('br', 0.5, 3); + state.saturation = read('sa', 0.5, 3); + state.hueRange = read('hr', 0, 60) ?? state.hueRange; + state.hueMode = readEnum('hm', BeamHueMode.values); + state.hueBase = read('hb', -180, 180) ?? state.hueBase; + state.strokeOpacityFactor = read('so', 0, 2) ?? state.strokeOpacityFactor; + state.innerOpacityFactor = read('io', 0, 2) ?? state.innerOpacityFactor; + state.bloomOpacityFactor = read('bo', 0, 2) ?? state.bloomOpacityFactor; + state.tailLength = read('tl', 0.5, 2) ?? state.tailLength; + state.glowSpread = read('gs', 0.5, 3) ?? state.glowSpread; + state.comet = readFlag('co') ?? state.comet; + state.sparkle = read('sk', 0, 1) ?? state.sparkle; + final segments = readInt('sg', 2, 64); + state.segments = segmentChoices.contains(segments) ? segments : null; + state.innerSizeScale = read('iss', 0.5, 2) ?? state.innerSizeScale; + state.renderScale = read('rs', 0.25, 1) ?? state.renderScale; + state.stockPulseOutside = readFlag('pos') ?? state.stockPulseOutside; + state.glowBoost = read('gb', 0, 3) ?? state.glowBoost; + state.coreBlur = read('cb', 0, 60); + state.bloomBlur = read('bb', 0, 120); + state.glowBrightness = read('gbr', 0.5, 3); + state.glowSaturation = read('gsa', 0.5, 3); + + state.active = readFlag('a') ?? state.active; + state.repeatCycles = readInt('rp', 1, 99); + state.reducedMotion = readEnum('rm', BeamReducedMotion.values); + state.simulateReducedMotion = readFlag('srm') ?? state.simulateReducedMotion; + state.pauseWhenOffscreen = readFlag('pwo') ?? state.pauseWhenOffscreen; + state.cssFadeCurve = readFlag('fc') ?? state.cssFadeCurve; + state.controllerMode = readFlag('cm') ?? state.controllerMode; + state.controllerSpeed = read('cs', 0.25, 4) ?? state.controllerSpeed; + state.startAfterSeconds = read('sd', 0, 3) ?? state.startAfterSeconds; + state.durationSeconds = read('du', 0, 15) ?? state.durationSeconds; + + state.driveProgress = readFlag('dp') ?? state.driveProgress; + state.progress = read('pr', 0, 1) ?? state.progress; + state.followPointer = readFlag('fp') ?? state.followPointer; + state.strengthSignal = readFlag('ss') ?? state.strengthSignal; + + state.themeDemo = readFlag('th') ?? state.themeDemo; + state.syncDemo = readFlag('sy') ?? state.syncDemo; + + return state; +} + +/// Pulls a playground state string out of [uri]. +/// +/// Three shapes reproduce a configuration, and all three are accepted: the +/// query (`?v=line`), a bare fragment (`#v=line` — the form the share button +/// hands out off the web), and a fragment carrying a route and query +/// (`#/?v=line` — the form Flutter web's hash routing writes back). Returns +/// null when the URL carries no state. +String? playgroundStateStringFrom(Uri uri) { + String? fromFragment(String fragment) { + if (fragment.isEmpty) return null; + final query = fragment.indexOf('?'); + if (query >= 0) return fragment.substring(query + 1); + // A fragment that is only a route path carries no state. + return fragment.startsWith('/') ? null : fragment; + } + + final fragment = fromFragment(uri.fragment); + if (fragment != null && fragment.isNotEmpty) return fragment; + return uri.query.isEmpty ? null : uri.query; +} + +/// The link that reproduces [encoded] on the published example site. +String playgroundShareUrl(String encoded) => + encoded.isEmpty ? playgroundSiteUrl : '$playgroundSiteUrl#$encoded'; + +const Map _variantIds = { + BeamVariant.rotate: 'rotate', + BeamVariant.small: 'small', + BeamVariant.line: 'line', + BeamVariant.pulseInside: 'pulse-inside', + BeamVariant.pulseOutside: 'pulse-outside', +}; + +/// Formats a double as compactly as the two-decimal control resolution +/// allows: `1.5` stays `1.5`, `2.0` becomes `2`. +String _formatNumber(double value) { + final fixed = value.toStringAsFixed(2); + if (!fixed.contains('.')) return fixed; + return fixed.replaceFirst(RegExp(r'\.?0+$'), ''); +} diff --git a/example/lib/src/playground/snippet.dart b/example/lib/src/playground/snippet.dart new file mode 100644 index 0000000..94e506f --- /dev/null +++ b/example/lib/src/playground/snippet.dart @@ -0,0 +1,363 @@ +import 'package:flutter_border_beam/flutter_border_beam.dart'; + +import 'playground_state.dart'; + +/// Builds the Dart snippet that reproduces [state]. +/// +/// Only fields that differ from what the package resolves on its own are +/// emitted, so the default configuration prints the one-liner. The result is +/// paste-able as written: `const` sits on every value object that can carry +/// it, and the two constructions that cannot be const (a runtime-built +/// [BeamShape.circular], and a [BorderBeamThemeData] holding one) are +/// emitted without it. +/// +/// Two things a snippet cannot carry are named rather than pretended: a +/// [BeamPathContour] takes a builder function, so it prints as a commented +/// placeholder, and the values a pointer or a live signal supply print as +/// the variables the surrounding widget would hold. +String buildSnippet(PlaygroundState state) { + final buffer = StringBuffer(); + for (final line in _preamble(state)) { + buffer.writeln(line); + } + if (buffer.isNotEmpty) buffer.writeln(); + + var body = _beam(state); + if (state.syncDemo) { + final beams = [ + for (var i = 0; i < 3; i++) + '${_indent(_beam(state, synced: true, phaseOffset: i / 3), ' ')},', + ].join('\n'); + body = _wrap('BeamSync', [ + if (!state.active) 'active: false,', + if (state.speed != 1) 'speed: ${_num(state.speed)},', + if (state.reducedMotion case final BeamReducedMotion mode) + 'reducedMotion: BeamReducedMotion.${mode.name},', + '// One clock for the group; each beam has its own phase offset.', + ], 'Column(\n children: [\n$beams\n ],\n)'); + } + if (state.themeDemo) { + body = _wrap('BorderBeamTheme', [ + 'data: BorderBeamThemeData(', + ' style: const BeamStyle(colors: BeamColors.ocean),', + ' shape: BeamShape.circular(20, superellipse: true),', + '),', + ], body); + } + return (buffer..write(body)).toString(); +} + +// The declarations the beam's arguments refer to. +List _preamble(PlaygroundState state) => [ + if (state.controllerMode && !state.syncDemo) ...[ + 'final controller = BorderBeamController();', + '// controller.start() / .pause() / .resume() / .stop()', + '// controller.pulse() / .flash()', + '// controller.speed = ${_num(state.controllerSpeed)};', + ], + if (state.strengthSignal) + 'final level = ValueNotifier(1); // drive from your own signal', + if (state.followsPointer && !state.syncDemo) + '// pointer: the normalized pointer position (0–1 on each axis), from a ' + 'MouseRegion or a Listener.', +]; + +// Wraps [child] in [name], with [header] lines above the `child:` argument. +String _wrap(String name, List header, String child) { + final buffer = StringBuffer('$name(\n'); + for (final line in header) { + buffer.writeln(' $line'); + } + return (buffer + ..writeln(' child: ${_indent(child, ' ').trimLeft()},') + ..write(')')) + .toString(); +} + +// The BorderBeam call itself. +String _beam( + PlaygroundState state, { + bool synced = false, + double? phaseOffset, +}) { + final args = []; + + if (state.hasColors) args.add('colors: ${_colors(state)}'); + if (!synced && state.buildActive() == false) args.add('active: false'); + + final shape = _shape(state); + if (shape != null) args.add(shape); + + final style = _style(state); + if (style != null) { + args.add( + state.usesStockPulseOutside + ? 'style: $style' + : 'style: const BeamStyle(\n$style\n )', + ); + } + + final timing = _timing(state, synced: synced, phaseOffset: phaseOffset); + if (timing != null) args.add('timing: const BeamTiming(\n$timing\n )'); + + final playback = _playback(state, synced: synced); + if (playback != null) { + args.add('playback: const BeamPlayback(\n$playback\n )'); + } + + if (state.buildProgress() case final double progress) { + args.add('progress: ${_num(progress)}'); + } + if (state.followsPointer && !synced) args.add('follow: pointer'); + if (state.strengthSignal) args.add('strengthListenable: level'); + + if (state.controllerMode && !synced) args.add('controller: controller'); + args.add('child: child'); + + final name = _constructorNames[state.variant]!; + return 'BorderBeam.$name(\n ${args.join(',\n ')},\n)'; +} + +String _colors(PlaygroundState state) { + final base = switch (state.palette) { + PalettePreset.custom => _customColors(state), + PalettePreset.seed => _seedColors(state), + PalettePreset.lerp => + 'const BeamColors.lerp(${_preset(state.lerpFrom)}, ' + '${_preset(state.lerpTo)}, ${_num(state.lerpT)})', + _ => _preset(state.palette), + }; + return state.alphaScale == 1 + ? base + : '$base.scaleAlpha(${_num(state.alphaScale)})'; +} + +String _preset(PalettePreset preset) => + 'BeamColors.' + '${preset.colors == null ? PalettePreset.colorful.id : preset.id}'; + +String _customColors(PlaygroundState state) { + final colors = [ + for (final i in state.customColors) + _color(customSwatches[i].color.toARGB32()), + ]; + final base = state.customBase == PalettePreset.colorful + ? '' + : ', base: ${_preset(state.customBase)}'; + return 'const BeamColors.custom([${colors.join(', ')}]$base)'; +} + +String _seedColors(PlaygroundState state) { + final harmony = state.seedHarmony == BeamSeedHarmony.analogous + ? '' + : ', harmony: BeamSeedHarmony.${state.seedHarmony.name}'; + final seed = _color(customSwatches[state.seedColor].color.toARGB32()); + return 'const BeamColors.fromSeed($seed$harmony)'; +} + +String _color(int argb) => + 'Color(0x${argb.toRadixString(16).toUpperCase().padLeft(8, '0')})'; + +// The `borderRadius:` shorthand when a plain radius is all that changed, +// otherwise a full BeamShape. `BeamShape.all` stores the radius as a number +// instead of building a BorderRadius, which is what keeps it const. +String? _shape(PlaygroundState state) { + if (!state.hasShape) return null; + final isLine = state.variant == BeamVariant.line; + final extras = [ + if (state.borderWidth != 1) 'borderWidth: ${_num(state.borderWidth)}', + if (state.superellipse) 'superellipse: true', + if (isLine && + state.segmentPreset == SegmentPreset.off && + state.edge != BeamEdge.bottom) + 'edge: BeamEdge.${state.edge.name}', + if (state.ringOffset != 0) 'ringOffset: ${_num(state.ringOffset)}', + if (state.segmentPreset != SegmentPreset.off) 'segment: ${_segment(state)}', + if (isLine && state.segmentPreset == SegmentPreset.off && state.wrapCorners) + 'wrapCorners: true', + ]; + final contour = state.contour ? _contourPlaceholder : null; + + if (state.perCorner && !state.stadium) { + final radii = [ + 'topLeft: Radius.circular(${_num(state.radiusTopLeft)})', + 'topRight: Radius.circular(${_num(state.radiusTopRight)})', + 'bottomRight: Radius.circular(${_num(state.radiusBottomRight)})', + 'bottomLeft: Radius.circular(${_num(state.radiusBottomLeft)})', + ]; + return _shapeBlock('const BeamShape', [ + 'radius: BorderRadius.only(\n ${radii.join(',\n ')},\n ),', + for (final field in extras) '$field,', + ?contour, + ]); + } + + final head = state.stadium + ? 'const BeamShape.stadium' + : 'const BeamShape.all'; + final radius = state.radius ?? state.defaultRadius; + final positional = state.stadium ? [] : [_num(radius)]; + if (contour != null) { + return _shapeBlock(head, [ + for (final field in [...positional, ...extras]) '$field,', + contour, + ]); + } + if (!state.stadium && extras.isEmpty) { + return 'borderRadius: ${_num(radius)}'; + } + return 'shape: $head(${[...positional, ...extras].join(', ')})'; +} + +String _segment(PlaygroundState state) => switch (state.segmentPreset) { + SegmentPreset.off => throw StateError('Off has no BeamSegment'), + SegmentPreset.custom => + 'BeamSegment(start: ${_edgeAnchor(state.segmentStartEdge, state.segmentStartT)}, ' + 'end: ${_edgeAnchor(state.segmentEndEdge, state.segmentEndT)}, ' + 'feather: ${_num(state.segmentFeather)})', + final SegmentPreset preset => 'BeamSegment.${preset.id}', +}; + +String _edgeAnchor(BeamEdge edge, double t) => t == 0.5 + ? 'BeamAnchor.edge(BeamEdge.${edge.name})' + : 'BeamAnchor.edge(BeamEdge.${edge.name}, ${_num(t)})'; + +// A BeamPathContour takes a builder, which a generated snippet cannot write +// for you — the placeholder names the field and the key it compares on. +const String _contourPlaceholder = + "// contour: BeamPathContour(builder: yourPath, key: 'star'),"; + +String _shapeBlock(String head, List lines) => + 'shape: $head(\n ${lines.join('\n ')}\n )'; + +String? _style(PlaygroundState state) { + final isPulse = state.variant.isPulse; + final isOutside = state.variant == BeamVariant.pulseOutside; + // The four glow overrides only reach the painter on pulse-outside. + final coreBlur = isOutside ? state.coreBlur : null; + final bloomBlur = isOutside ? state.bloomBlur : null; + final glowBrightness = isOutside ? state.glowBrightness : null; + final glowSaturation = isOutside ? state.glowSaturation : null; + final segments = state.variant == BeamVariant.line ? null : state.segments; + final fields = [ + if (state.strength != 1) 'strength: ${_num(state.strength)}', + if (state.brightness case final double v) 'brightness: ${_num(v)}', + if (state.saturation case final double v) 'saturation: ${_num(v)}', + if (state.hueRange != 30) 'hueRange: ${_num(state.hueRange)}', + if (state.hueMode case final BeamHueMode v) + 'hueMode: BeamHueMode.${v.name}', + if (state.hueBase != 0) 'hueBase: ${_num(state.hueBase)}', + if (state.staticColors) 'staticColors: true', + if (state.strokeOpacityFactor != 1) + 'strokeOpacityFactor: ${_num(state.strokeOpacityFactor)}', + if (state.innerOpacityFactor != 1) + 'innerOpacityFactor: ${_num(state.innerOpacityFactor)}', + if (state.bloomOpacityFactor != 1) + 'bloomOpacityFactor: ${_num(state.bloomOpacityFactor)}', + if (state.isRing && state.tailLength != 1) + 'tailLength: ${_num(state.tailLength)}', + if (state.glowSpread != 1) 'glowSpread: ${_num(state.glowSpread)}', + if (state.isRing && state.comet) 'comet: true', + if (state.isTraveling && state.sparkle != 0) + 'sparkle: ${_num(state.sparkle)}', + if (segments case final int v) 'segments: $v', + if (state.variant == BeamVariant.pulseInside && state.innerSizeScale != 1) + 'innerSizeScale: ${_num(state.innerSizeScale)}', + if (state.renderScale != 1) 'renderScale: ${_num(state.renderScale)}', + if (isPulse && state.glowBoost != 1) 'glowBoost: ${_num(state.glowBoost)}', + if (coreBlur case final double v) 'coreBlur: ${_num(v)}', + if (bloomBlur case final double v) 'bloomBlur: ${_num(v)}', + if (glowBrightness case final double v) 'glowBrightness: ${_num(v)}', + if (glowSaturation case final double v) 'glowSaturation: ${_num(v)}', + ]; + if (state.usesStockPulseOutside) { + // The stock look is a whole style; merge layers the fields set here + // over it, which is what the preview does too. + return fields.isEmpty + ? _stockStyle + : '$_stockStyle.merge(\n const BeamStyle(\n ' + '${fields.join(',\n ')},\n ),\n )'; + } + return fields.isEmpty ? null : ' ${fields.join(',\n ')},'; +} + +const String _stockStyle = 'BeamStyle.pulseOutsideStock'; + +String? _timing( + PlaygroundState state, { + bool synced = false, + double? phaseOffset, +}) { + final isLine = state.variant == BeamVariant.line; + final travels = state.isTraveling; + final fields = [ + if (state.cycleSeconds case final double v) 'cycle: ${_duration(v)}', + if (state.cycleGapSeconds != 0) + 'cycleGap: ${_duration(state.cycleGapSeconds)}', + if (state.speed != 1 && !state.controllerMode && !synced) + 'speed: ${_num(state.speed)}', + if (travels && state.direction != BeamDirection.forward) + 'direction: BeamDirection.${state.direction.name}', + if (travels && (phaseOffset ?? state.phaseOffset) != 0) + 'phaseOffset: ${_num(phaseOffset ?? state.phaseOffset)}', + if (travels && state.beamCount != 1) 'beamCount: ${state.beamCount}', + if (state.huePeriodSeconds case final double v) + 'huePeriod: ${_duration(v)}', + if (isLine && state.breatheFactor != 1.3) + 'breatheFactor: ${_num(state.breatheFactor)}', + if (isLine && state.spikeFactor != 1.33) + 'spikeFactor: ${_num(state.spikeFactor)}', + if (isLine && state.spike2Factor != 1.7) + 'spike2Factor: ${_num(state.spike2Factor)}', + ]; + return fields.isEmpty ? null : ' ${fields.join(',\n ')},'; +} + +String? _playback(PlaygroundState state, {bool synced = false}) { + if (synced) return null; + // A controller owns scheduling exclusively; repeat and reduced motion stay + // the beam's own either way. + final scheduled = !state.controllerMode; + final fields = [ + if (scheduled && state.startAfterSeconds != 0) + 'startAfter: ${_duration(state.startAfterSeconds)}', + if (scheduled && state.durationSeconds != 0) + 'duration: ${_duration(state.durationSeconds)}', + if (state.repeatCycles case final int cycles) 'repeat: ${_repeat(cycles)}', + if (state.reducedMotion case final BeamReducedMotion v) + 'reducedMotion: BeamReducedMotion.${v.name}', + if (state.pauseWhenOffscreen case final bool value) + 'pauseWhenOffscreen: $value', + if (state.cssFadeCurve) 'fadeCurve: BeamPlayback.cssEase', + ]; + return fields.isEmpty ? null : ' ${fields.join(',\n ')},'; +} + +String _repeat(int cycles) => + cycles == 1 ? 'BeamRepeat.once()' : 'BeamRepeat.count($cycles)'; + +String _duration(double seconds) { + final ms = (seconds * 1000).round(); + return ms % 1000 == 0 + ? 'Duration(seconds: ${ms ~/ 1000})' + : 'Duration(milliseconds: $ms)'; +} + +/// Formats a double the way a hand-written argument would read: `2` rather +/// than `2.0`, `1.33` kept at the control's resolution. +String _num(double value) { + final fixed = value.toStringAsFixed(2); + if (!fixed.contains('.')) return fixed; + return fixed.replaceFirst(RegExp(r'\.?0+$'), ''); +} + +String _indent(String source, String prefix) => + source.split('\n').map((line) => '$prefix$line').join('\n'); + +const Map _constructorNames = { + BeamVariant.rotate: 'rotate', + BeamVariant.small: 'small', + BeamVariant.line: 'line', + BeamVariant.pulseInside: 'pulseInside', + BeamVariant.pulseOutside: 'pulseOutside', +}; diff --git a/example/pubspec.lock b/example/pubspec.lock index 856c008..8636d13 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -17,13 +17,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - border_beam: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "0.1.0" characters: dependency: transitive description: @@ -48,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" - source: hosted - version: "1.0.9" fake_async: dependency: transitive description: @@ -69,6 +54,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_border_beam: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.1.0" flutter_lints: dependency: "direct dev" description: @@ -159,14 +151,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" - sprung: - dependency: transitive - description: - name: sprung - sha256: "54322638f5e393d2b808175f7eadbaa4836a4425456e98d93c3d676dc56ebdf1" - url: "https://pub.dev" - source: hosted - version: "3.0.1" stack_trace: dependency: transitive description: @@ -224,5 +208,5 @@ packages: source: hosted version: "15.2.0" sdks: - dart: ">=3.12.2 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.35.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index bab8497..795c783 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,91 +1,24 @@ -name: border_beam_example -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +name: flutter_border_beam_example +description: >- + Gallery and interactive playground for the flutter_border_beam package, + plus the recording reels used to capture its demo videos. +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. version: 1.0.0+1 environment: - sdk: ^3.12.2 + sdk: ^3.9.0 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - border_beam: + flutter_border_beam: path: ../ - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 - dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/example/test/gallery_smoke_test.dart b/example/test/gallery_smoke_test.dart index 3862aba..b269dce 100644 --- a/example/test/gallery_smoke_test.dart +++ b/example/test/gallery_smoke_test.dart @@ -1,10 +1,60 @@ -import 'package:border_beam_example/main.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam_example/main.dart'; +import 'package:flutter_border_beam_example/src/mocks.dart'; +import 'package:flutter_border_beam_example/src/playground/controls_panel.dart'; import 'package:flutter_test/flutter_test.dart'; +/// Reads the playground's generated snippet. +String snippetText(WidgetTester tester) => + tester.widget(find.byKey(const ValueKey('playground-snippet'))).data!; + +/// Advances past the demo's implicit animations. `pumpAndSettle` never +/// returns here: a running beam keeps its ticker scheduled forever. +Future settle(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); +} + +/// Scrolls [label] into view before tapping it — the demo is one long +/// scroll view, and a tap on an off-screen widget silently hits nothing. +Future tapLabel(WidgetTester tester, String label) async { + await tapFinder(tester, find.text(label)); +} + +/// The same, for a label that also appears elsewhere on the page: the +/// playground's own controls are scoped to the [ControlsPanel]. +Future tapControl(WidgetTester tester, String label) async { + await tapFinder( + tester, + find.descendant(of: find.byType(ControlsPanel), matching: find.text(label)), + ); +} + +/// Scrolls [finder] into view and taps it. +Future tapFinder(WidgetTester tester, Finder finder) async { + await settle(tester); + await tester.ensureVisible(finder); + await tester.pump(); + await tester.tap(finder); + await settle(tester); +} + +/// Mounts the demo on a tall viewport — the gallery is one long page. +Future pumpGallery(WidgetTester tester) async { + tester.view.physicalSize = const Size(1200, 3600); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + await tester.pumpWidget(const BorderBeamDemoApp()); + await tester.pump(const Duration(milliseconds: 300)); +} + void main() { - testWidgets('gallery renders both tabs and the playground', (tester) async { - tester.view.physicalSize = const Size(1200, 2600); + testWidgets('gallery renders both tabs, the themed section, and the ' + 'playground', (tester) async { + tester.view.physicalSize = const Size(1200, 3600); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); @@ -15,17 +65,395 @@ void main() { expect(find.text('Build anything...'), findsWidgets); expect(find.text('Playground'), findsOneWidget); + // The themed gallery: three variants under one BorderBeamTheme. + expect(find.text('Themed'), findsOneWidget); + expect(find.text('rotate'), findsOneWidget); + expect(find.text('small'), findsOneWidget); + expect(find.text('pulseInside'), findsOneWidget); + expect(find.text('Rest between sweeps'), findsOneWidget); + expect(find.text('Partial contours'), findsOneWidget); + expect(find.text('Half phone'), findsOneWidget); + expect(find.text('Half phone · line'), findsOneWidget); + expect(find.text('Half phone · pulse'), findsOneWidget); + expect(find.text('Corner wrap'), findsOneWidget); + // Switch to the Pulse tab. - await tester.tap(find.text('Pulse')); - await tester.pump(const Duration(milliseconds: 300)); + await tapLabel(tester, 'Pulse'); expect(find.text('Working...'), findsOneWidget); expect(find.text('Subscribe'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: a default configuration prints the one-liner', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 3600); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const BorderBeamDemoApp()); + await tester.pump(const Duration(milliseconds: 300)); + + expect(snippetText(tester), 'BorderBeam.rotate(\n child: child,\n)'); + }); + + testWidgets('playground: variant, shape, and palette changes reach the ' + 'snippet', (tester) async { + tester.view.physicalSize = const Size(1200, 3600); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const BorderBeamDemoApp()); + await tester.pump(const Duration(milliseconds: 300)); + + final before = snippetText(tester); + + await tapLabel(tester, 'Line'); + expect(snippetText(tester), startsWith('BorderBeam.line(')); + expect(snippetText(tester), isNot(before)); + + await tapLabel(tester, 'Ocean'); + expect(snippetText(tester), contains('colors: BeamColors.ocean')); + + await tapLabel(tester, 'Stadium'); + expect(snippetText(tester), contains('BeamShape.stadium(')); + + await tapLabel(tester, 'Squircle'); + expect(snippetText(tester), contains('superellipse: true')); + + await tapLabel(tester, 'Pulse Outside'); + expect(snippetText(tester), startsWith('BorderBeam.pulseOutside(')); + + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: segment chips and corner wrap reach the snippet', ( + tester, + ) async { + await pumpGallery(tester); + + await tapControl(tester, 'Bottom half'); + expect(snippetText(tester), contains('BeamSegment.bottomHalf')); + + await tapControl(tester, 'Top edge'); + expect(snippetText(tester), contains('BeamSegment.topEdge')); + + await tapFinder( + tester, + find + .descendant( + of: find.byType(ControlsPanel), + matching: find.text('Custom'), + ) + .last, + ); + expect( + snippetText(tester), + contains( + 'BeamSegment(start: BeamAnchor.edge(BeamEdge.right), ' + 'end: BeamAnchor.edge(BeamEdge.left), feather: 48)', + ), + ); + expect(find.text('Start edge'), findsOneWidget); + expect(find.text('End edge'), findsOneWidget); + expect(find.text('Feather'), findsOneWidget); + + await tapControl(tester, 'Off'); + await tapControl(tester, 'Line'); + await tapControl(tester, 'Wrap corners'); + expect(snippetText(tester), contains('wrapCorners: true')); + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: controller mode swaps in a controller and its ' + 'transport', (tester) async { + tester.view.physicalSize = const Size(1200, 3600); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const BorderBeamDemoApp()); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Start'), findsNothing); + + await tapLabel(tester, 'Controller'); + expect(snippetText(tester), contains('final controller =')); + expect(snippetText(tester), contains('controller: controller')); + // The declarative scheduling fields are the controller's now. + expect(snippetText(tester), isNot(contains('playback:'))); + expect(find.text('Start'), findsOneWidget); + + for (final action in ['Pause', 'Resume', 'Stop', 'Start']) { + await tapLabel(tester, action); + } + + await tapLabel(tester, 'Controller'); + expect(snippetText(tester), isNot(contains('controller: controller'))); + expect(find.text('Start'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: the theme toggle wraps the snippet in a ' + 'BorderBeamTheme', (tester) async { + tester.view.physicalSize = const Size(1200, 3600); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const BorderBeamDemoApp()); + await tester.pump(const Duration(milliseconds: 300)); + + await tapLabel(tester, 'Theme'); + await tapLabel(tester, 'BorderBeamTheme'); + expect(snippetText(tester), startsWith('BorderBeamTheme(')); + expect(snippetText(tester), contains('BeamColors.ocean')); + expect(tester.takeException(), isNull); + }); - // Playground: switch variant and palette without errors. - await tester.tap(find.text('Pulse Outside'), warnIfMissed: false); + testWidgets('playground: Reset returns every control to its default', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 3600); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const BorderBeamDemoApp()); await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('Ocean'), warnIfMissed: false); + + await tapLabel(tester, 'Line'); + await tapLabel(tester, 'Stadium'); + expect( + snippetText(tester), + isNot('BorderBeam.rotate(\n child: child,\n)'), + ); + + await tapLabel(tester, 'Reset'); + expect(snippetText(tester), 'BorderBeam.rotate(\n child: child,\n)'); + expect(tester.takeException(), isNull); + }); + + testWidgets('gallery: the palette, surface, motion, progress, and sync ' + 'sections all render', (tester) async { + await pumpGallery(tester); + + for (final title in [ + 'Palettes', + 'Surfaces', + 'Motion', + 'Driven progress', + 'Sync', + 'Partial contours', + ]) { + await tester.ensureVisible(find.text(title)); + await settle(tester); + expect(find.text(title), findsOneWidget, reason: '$title section'); + } + + for (final label in ['Half phone', 'Half phone · line', 'Corner wrap']) { + await tester.ensureVisible(find.text(label)); + await settle(tester); + expect(find.text(label), findsOneWidget); + } + + // One card per palette preset, named by the constant it uses. + for (final preset in ['colorful', 'mono', 'gold', 'holographic']) { + expect(find.text(preset), findsOneWidget); + } + + // The four surface entry points. + for (final label in [ + 'BeamDecoration', + 'BeamFocusRing', + 'BeamHover', + 'BeamPress', + ]) { + expect(find.text(label), findsOneWidget); + } + + // The motion cards. + for (final label in ['reverse', 'bounce', 'beamCount 3', 'segments 8']) { + expect(find.text(label), findsOneWidget); + } + + expect(tester.takeException(), isNull); + }); + + testWidgets('gallery: the surface wrappers light on focus, hover, and ' + 'press', (tester) async { + await pumpGallery(tester); + + // Focus: the ring follows the field's own subtree. + await tapFinder(tester, find.byType(MockFocusField)); + expect( + Focus.of(tester.element(find.text('Tap to focus'))).hasFocus, + isTrue, + ); + + // Hover: a mouse entering the card starts the beam and steers it. + final card = find.text('BeamHover'); + await tester.ensureVisible(card); + await settle(tester); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(tester.getCenter(card)); + await settle(tester); + await mouse.moveTo(Offset.zero); + await settle(tester); + + // Press: pointer down lights it, and the release is held for the + // minimum duration. + final press = find.text('BeamPress'); + await tester.ensureVisible(press); + await settle(tester); + final finger = await tester.startGesture(tester.getCenter(press)); + await settle(tester); + await finger.up(); + await settle(tester); + + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: the Drive section feeds progress, the pointer, ' + 'and a signal', (tester) async { + await pumpGallery(tester); + + await tapControl(tester, 'Drive'); + await tapControl(tester, 'Progress'); + expect(snippetText(tester), contains('progress: 0.35')); + + await tapControl(tester, 'Strength from signal'); + expect(snippetText(tester), contains('strengthListenable: level')); + + // Follow loses to progress while both are on, so it reaches the snippet + // only once progress is off again. + await tapControl(tester, 'Follow pointer'); + expect(snippetText(tester), isNot(contains('follow: pointer'))); + await tapControl(tester, 'Progress'); + expect(snippetText(tester), contains('follow: pointer')); + + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: the sync toggle swaps in a BeamSync group', ( + tester, + ) async { + await pumpGallery(tester); + + await tapControl(tester, 'Theme'); + await tapControl(tester, 'BeamSync'); + expect(snippetText(tester), startsWith('BeamSync(')); + // The group's three beams are labelled with the phase each one runs at. + expect(find.text('phaseOffset 0.00'), findsWidgets); + expect(find.text('phaseOffset 0.33'), findsWidgets); + + await tapControl(tester, 'BeamSync'); + expect(snippetText(tester), startsWith('BorderBeam.rotate(')); + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: the controller transport carries pulse and ' + 'flash', (tester) async { + await pumpGallery(tester); + + await tapControl(tester, 'Controller'); + expect(snippetText(tester), contains('controller.pulse() / .flash()')); + + for (final action in ['Pulse', 'Flash', 'Pause', 'Resume', 'Start']) { + await tapControl(tester, action); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: controllers start when previews attach or return ' + 'from sync mode', (tester) async { + tester.view.physicalSize = const Size(519, 3600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(const BorderBeamDemoApp()); await tester.pump(const Duration(milliseconds: 300)); + + await tapControl(tester, 'Controller'); + var controlled = tester + .widgetList(find.byType(BorderBeam)) + .where((beam) => beam.controller != null) + .toList(); + expect(controlled, hasLength(1)); + expect(controlled.single.controller!.isActive, isTrue); + + tester.view.physicalSize = const Size(1200, 3600); + await settle(tester); + controlled = tester + .widgetList(find.byType(BorderBeam)) + .where((beam) => beam.controller != null) + .toList(); + expect(controlled, hasLength(2)); + expect(controlled.every((beam) => beam.controller!.isActive), isTrue); + + await tapControl(tester, 'Theme'); + await tapControl(tester, 'BeamSync'); + expect( + tester + .widgetList(find.byType(BorderBeam)) + .where((beam) => beam.controller != null), + isEmpty, + ); + + await tapControl(tester, 'BeamSync'); + controlled = tester + .widgetList(find.byType(BorderBeam)) + .where((beam) => beam.controller != null) + .toList(); + expect(controlled, hasLength(2)); + expect(controlled.every((beam) => beam.controller!.isActive), isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: the new palette modes reach the snippet', ( + tester, + ) async { + await pumpGallery(tester); + + await tapControl(tester, 'Holographic'); + expect(snippetText(tester), contains('colors: BeamColors.holographic')); + + await tapControl(tester, 'Seed'); + expect(snippetText(tester), contains('BeamColors.fromSeed(')); + await tapControl(tester, 'Triadic'); + expect(snippetText(tester), contains('BeamSeedHarmony.triadic')); + + await tapControl(tester, 'Lerp'); + expect(snippetText(tester), contains('BeamColors.lerp(')); + + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: reduced motion can be chosen and simulated', ( + tester, + ) async { + await pumpGallery(tester); + + await tapControl(tester, 'Slow'); + expect( + snippetText(tester), + contains('reducedMotion: BeamReducedMotion.slow'), + ); + + // Simulating is a preview concern: the beam's fields do not change. + final before = snippetText(tester); + await tapControl(tester, 'Simulate reduced motion'); + expect(snippetText(tester), before); + expect(tester.takeException(), isNull); + }); + + testWidgets('playground: the star contour and ring offset reach the ' + 'shape', (tester) async { + await pumpGallery(tester); + + await tapControl(tester, 'Star contour'); + expect( + snippetText(tester), + contains("// contour: BeamPathContour(builder: yourPath, key: 'star')"), + ); expect(tester.takeException(), isNull); }); } diff --git a/example/test/playground_config_test.dart b/example/test/playground_config_test.dart new file mode 100644 index 0000000..6521150 --- /dev/null +++ b/example/test/playground_config_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam_example/src/playground/playground_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The playground hands the widget four value objects it assembled itself, so +/// a gating mistake there is a beam that throws rather than a snippet that +/// reads oddly. These mount the extremes the controls can reach. +void main() { + Future pumpConfig( + WidgetTester tester, + PlaygroundState state, { + bool reducedMotion = false, + }) async { + Widget beam = SizedBox( + width: 320, + height: 128, + child: BorderBeam( + variant: state.variant, + active: state.buildActive(), + style: state.buildStyle(), + shape: state.buildShape(), + timing: state.buildTiming(), + playback: state.buildPlayback(), + progress: state.buildProgress(), + child: const SizedBox.expand(), + ), + ); + if (reducedMotion) { + beam = MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: beam, + ); + } + await tester.pumpWidget(MaterialApp(home: Center(child: beam))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 900)); + expect(tester.takeException(), isNull); + // Unmount so no ticker outlives the test. + await tester.pumpWidget(const SizedBox.shrink()); + } + + testWidgets('a rotate beam with every ring control at once paints', ( + tester, + ) async { + await pumpConfig( + tester, + PlaygroundState() + ..palette = PalettePreset.lerp + ..alphaScale = 0.7 + ..radius = 24 + ..superellipse = true + ..borderWidth = 3 + ..ringOffset = 8 + ..contour = true + ..hueMode = BeamHueMode.continuous + ..tailLength = 1.8 + ..glowSpread = 2.5 + ..comet = true + ..sparkle = 1 + ..segments = 16 + ..renderScale = 0.5 + ..direction = BeamDirection.bounce + ..phaseOffset = 0.75 + ..beamCount = 4 + ..repeatCycles = 3 + ..cssFadeCurve = true + ..pauseWhenOffscreen = true, + ); + }); + + testWidgets('a line beam on a non-default edge, with a contour, paints', ( + tester, + ) async { + await pumpConfig( + tester, + PlaygroundState() + ..variant = BeamVariant.line + ..palette = PalettePreset.seed + ..seedHarmony = BeamSeedHarmony.complementary + ..edge = BeamEdge.right + ..contour = true + ..stadium = true + ..sparkle = 0.6 + ..beamCount = 3 + ..cycleGapSeconds = 0.5, + ); + }); + + testWidgets('the stock pulse-outside recipe paints, tuned over', ( + tester, + ) async { + await pumpConfig( + tester, + PlaygroundState() + ..variant = BeamVariant.pulseOutside + ..stockPulseOutside = true + ..glowBoost = 1.6 + ..coreBlur = 24 + ..bloomBlur = 90 + ..segments = 12 + ..renderScale = 0.75, + ); + }); + + testWidgets('pulse-inside honours the inner wash scale', (tester) async { + await pumpConfig( + tester, + PlaygroundState() + ..variant = BeamVariant.pulseInside + ..innerSizeScale = 2 + ..glowBoost = 2.5 + ..perCorner = true + ..radiusTopLeft = 0 + ..radiusBottomRight = 48, + ); + }); + + testWidgets('a progress-driven beam under reduced motion paints', ( + tester, + ) async { + await pumpConfig( + tester, + PlaygroundState() + ..driveProgress = true + ..progress = 0.9 + ..reducedMotion = BeamReducedMotion.slow + ..strength = 0.4, + reducedMotion: true, + ); + }); + + testWidgets('a hidden beam under reduced motion paints nothing and does ' + 'not throw', (tester) async { + await pumpConfig( + tester, + PlaygroundState()..reducedMotion = BeamReducedMotion.hide, + reducedMotion: true, + ); + }); +} diff --git a/example/test/share_codec_test.dart b/example/test/share_codec_test.dart new file mode 100644 index 0000000..5e84814 --- /dev/null +++ b/example/test/share_codec_test.dart @@ -0,0 +1,408 @@ +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam_example/src/playground/playground_state.dart'; +import 'package:flutter_border_beam_example/src/playground/share_codec.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('encodePlaygroundState', () { + test('encodes the default configuration to the empty string', () { + expect(encodePlaygroundState(PlaygroundState()), isEmpty); + }); + + test('writes only the fields that differ from the default', () { + final state = PlaygroundState() + ..variant = BeamVariant.line + ..strength = 0.6; + expect(encodePlaygroundState(state), 'v=line&str=0.6'); + }); + + test('trims trailing zeros from numbers', () { + final state = PlaygroundState()..borderWidth = 2; + expect(encodePlaygroundState(state), 'bw=2'); + }); + + test('preserves custom swatches while another palette is selected', () { + final state = PlaygroundState() + ..palette = PalettePreset.ocean + ..customColors = [2, 5, 7]; + final decoded = decodePlaygroundState(encodePlaygroundState(state)); + expect(decoded.palette, PalettePreset.ocean); + expect(decoded.customColors, [2, 5, 7]); + }); + + test('preserves an explicit offscreen opt-out', () { + final state = PlaygroundState()..pauseWhenOffscreen = false; + final encoded = encodePlaygroundState(state); + expect(encoded, 'pwo=0'); + expect(decodePlaygroundState(encoded).pauseWhenOffscreen, isFalse); + }); + + test('emits only ASCII that needs no percent-escaping', () { + final state = PlaygroundState() + ..palette = PalettePreset.custom + ..customColors = [2, 5, 7] + ..hueBase = -120 + ..cycleSeconds = 2.5; + final encoded = encodePlaygroundState(state); + expect(encoded, matches(RegExp(r'^[A-Za-z0-9.,=&-]*$'))); + expect(encoded, contains('cc=2,5,7')); + expect(encoded, contains('hb=-120')); + expect(encoded, contains('cy=2.5')); + }); + }); + + group('decodePlaygroundState', () { + test('restores every field through a round trip', () { + final state = PlaygroundState() + ..variant = BeamVariant.pulseOutside + ..palette = PalettePreset.custom + ..customColors = [1, 4, 6] + ..customBase = PalettePreset.aurora + ..seedColor = 3 + ..seedHarmony = BeamSeedHarmony.triadic + ..lerpFrom = PalettePreset.ice + ..lerpTo = PalettePreset.gold + ..lerpT = 0.25 + ..alphaScale = 0.6 + ..stadium = true + ..perCorner = true + ..radius = 24 + ..radiusTopLeft = 4 + ..radiusTopRight = 8 + ..radiusBottomRight = 12 + ..radiusBottomLeft = 20 + ..superellipse = true + ..borderWidth = 2.5 + ..edge = BeamEdge.right + ..ringOffset = -6 + ..contour = true + ..segmentPreset = SegmentPreset.custom + ..segmentStartEdge = BeamEdge.bottom + ..segmentStartT = 0.2 + ..segmentEndEdge = BeamEdge.top + ..segmentEndT = 0.8 + ..segmentFeather = 72 + ..wrapCorners = true + ..cycleSeconds = 3.25 + ..cycleGapSeconds = 1.5 + ..speed = 2 + ..direction = BeamDirection.bounce + ..phaseOffset = 0.5 + ..beamCount = 3 + ..huePeriodSeconds = 7 + ..breatheFactor = 2 + ..spikeFactor = 0.75 + ..spike2Factor = 2.5 + ..staticColors = true + ..strength = 0.35 + ..brightness = 1.8 + ..saturation = 0.9 + ..hueRange = 12 + ..hueMode = BeamHueMode.continuous + ..hueBase = 45 + ..strokeOpacityFactor = 0.5 + ..innerOpacityFactor = 1.5 + ..bloomOpacityFactor = 0.25 + ..tailLength = 1.75 + ..glowSpread = 2.5 + ..comet = true + ..sparkle = 0.4 + ..segments = 12 + ..innerSizeScale = 1.4 + ..renderScale = 0.5 + ..stockPulseOutside = true + ..glowBoost = 2.2 + ..coreBlur = 18 + ..bloomBlur = 64 + ..glowBrightness = 1.4 + ..glowSaturation = 2.1 + ..active = false + ..repeatCycles = 3 + ..reducedMotion = BeamReducedMotion.slow + ..simulateReducedMotion = true + ..pauseWhenOffscreen = true + ..cssFadeCurve = true + ..driveProgress = true + ..progress = 0.8 + ..followPointer = true + ..strengthSignal = true + ..syncDemo = true + ..controllerMode = true + ..controllerSpeed = 3 + ..startAfterSeconds = 1.5 + ..durationSeconds = 9 + ..themeDemo = true; + + final encoded = encodePlaygroundState(state); + final decoded = decodePlaygroundState(encoded); + + expect(encodePlaygroundState(decoded), encoded); + expect(decoded.variant, BeamVariant.pulseOutside); + expect(decoded.palette, PalettePreset.custom); + expect(decoded.customColors, [1, 4, 6]); + expect(decoded.stadium, isTrue); + expect(decoded.perCorner, isTrue); + expect(decoded.superellipse, isTrue); + expect(decoded.borderWidth, 2.5); + expect(decoded.cycleSeconds, 3.25); + expect(decoded.huePeriodSeconds, 7); + expect(decoded.hueBase, 45); + expect(decoded.active, isFalse); + expect(decoded.controllerMode, isTrue); + expect(decoded.controllerSpeed, 3); + expect(decoded.themeDemo, isTrue); + expect(decoded.customBase, PalettePreset.aurora); + expect(decoded.seedColor, 3); + expect(decoded.seedHarmony, BeamSeedHarmony.triadic); + expect(decoded.lerpFrom, PalettePreset.ice); + expect(decoded.lerpTo, PalettePreset.gold); + expect(decoded.lerpT, 0.25); + expect(decoded.alphaScale, 0.6); + expect(decoded.edge, BeamEdge.right); + expect(decoded.ringOffset, -6); + expect(decoded.contour, isTrue); + expect(decoded.segmentPreset, SegmentPreset.custom); + expect(decoded.segmentStartEdge, BeamEdge.bottom); + expect(decoded.segmentStartT, 0.2); + expect(decoded.segmentEndEdge, BeamEdge.top); + expect(decoded.segmentEndT, 0.8); + expect(decoded.segmentFeather, 72); + expect(decoded.wrapCorners, isTrue); + expect(decoded.direction, BeamDirection.bounce); + expect(decoded.phaseOffset, 0.5); + expect(decoded.beamCount, 3); + expect(decoded.hueMode, BeamHueMode.continuous); + expect(decoded.tailLength, 1.75); + expect(decoded.glowSpread, 2.5); + expect(decoded.comet, isTrue); + expect(decoded.sparkle, 0.4); + expect(decoded.segments, 12); + expect(decoded.innerSizeScale, 1.4); + expect(decoded.renderScale, 0.5); + expect(decoded.stockPulseOutside, isTrue); + expect(decoded.pauseWhenOffscreen, isTrue); + expect(decoded.cssFadeCurve, isTrue); + expect(decoded.repeatCycles, 3); + expect(decoded.reducedMotion, BeamReducedMotion.slow); + expect(decoded.simulateReducedMotion, isTrue); + expect(decoded.driveProgress, isTrue); + expect(decoded.progress, 0.8); + expect(decoded.followPointer, isTrue); + expect(decoded.strengthSignal, isTrue); + expect(decoded.syncDemo, isTrue); + }); + + test('an empty string decodes to the defaults', () { + final decoded = decodePlaygroundState(''); + expect(encodePlaygroundState(decoded), isEmpty); + expect(decoded.variant, BeamVariant.rotate); + expect(decoded.cycleSeconds, isNull); + }); + + test('ignores unknown keys, malformed pairs, and bad values', () { + final decoded = decodePlaygroundState( + 'nope=1&&=5&v=not-a-variant&p=nope&str=huge&bw=99&hb=-999&sc=maybe', + ); + expect(decoded.variant, BeamVariant.rotate); + expect(decoded.palette, PalettePreset.colorful); + expect(decoded.strength, 1); + expect(decoded.borderWidth, 1, reason: 'out of the 0.5–4 range'); + expect(decoded.hueBase, 0, reason: 'out of the -180–180 range'); + expect(decoded.staticColors, isFalse); + }); + + test('drops out-of-bounds swatch indices and short custom lists', () { + expect(decodePlaygroundState('cc=0,99,x,3').customColors, [0, 3]); + expect( + decodePlaygroundState('cc=0').customColors, + PlaygroundState().customColors, + reason: 'fewer than two colors is not a usable custom palette', + ); + expect(decodePlaygroundState('cc=0,1,2,3,4,5').customColors.length, 4); + }); + }); + + group('playgroundStateStringFrom', () { + test('reads a query string', () { + expect( + playgroundStateStringFrom(Uri.parse('https://x.dev/?v=line&str=0.5')), + 'v=line&str=0.5', + ); + }); + + test('reads a bare fragment — the form the share button hands out', () { + expect( + playgroundStateStringFrom(Uri.parse('https://x.dev/#v=line')), + 'v=line', + ); + }); + + test('reads a fragment carrying a route and query', () { + expect( + playgroundStateStringFrom(Uri.parse('https://x.dev/#/?v=line')), + 'v=line', + ); + }); + + test('returns null when there is no state to read', () { + expect(playgroundStateStringFrom(Uri.parse('https://x.dev/')), isNull); + expect(playgroundStateStringFrom(Uri.parse('https://x.dev/#/')), isNull); + expect(playgroundStateStringFrom(Uri.parse('file:///tmp/app')), isNull); + }); + }); + + group('playgroundShareUrl', () { + test('appends the state to the published site as a fragment', () { + expect(playgroundShareUrl('v=line'), '$playgroundSiteUrl#v=line'); + }); + + test('a default configuration shares the bare site URL', () { + expect(playgroundShareUrl(''), playgroundSiteUrl); + }); + + test('round-trips through the URL it builds', () { + final state = PlaygroundState() + ..variant = BeamVariant.small + ..stadium = true; + final encoded = encodePlaygroundState(state); + final uri = Uri.parse(playgroundShareUrl(encoded)); + final decoded = decodePlaygroundState(playgroundStateStringFrom(uri)!); + expect(decoded.variant, BeamVariant.small); + expect(decoded.stadium, isTrue); + }); + }); + + group('the new controls', () { + test('each new palette mode round-trips its own fields', () { + for (final palette in [ + PalettePreset.holographic, + PalettePreset.seed, + PalettePreset.lerp, + PalettePreset.custom, + ]) { + final state = PlaygroundState()..palette = palette; + final decoded = decodePlaygroundState(encodePlaygroundState(state)); + expect(decoded.palette, palette); + } + }); + + test('a default configuration still encodes to the empty string', () { + // Every new field must default to what the package resolves on its + // own, or the share link stops being empty at rest. + expect(encodePlaygroundState(PlaygroundState()), isEmpty); + }); + + test('enum values encode as their names, still escaping-free', () { + final state = PlaygroundState() + ..edge = BeamEdge.left + ..direction = BeamDirection.reverse + ..hueMode = BeamHueMode.pingPong + ..reducedMotion = BeamReducedMotion.hide + ..seedHarmony = BeamSeedHarmony.monochrome; + final encoded = encodePlaygroundState(state); + expect(encoded, matches(RegExp(r'^[A-Za-z0-9.,=&-]*$'))); + expect(encoded, contains('ed=left')); + expect(encoded, contains('dir=reverse')); + expect(encoded, contains('hm=pingPong')); + expect(encoded, contains('rm=hide')); + expect(encoded, contains('sdh=monochrome')); + }); + + test('segment presets and custom anchors use the compact shape keys', () { + final preset = PlaygroundState() + ..segmentPreset = SegmentPreset.bottomHalf; + expect(encodePlaygroundState(preset), 'sgp=bottomHalf'); + + final custom = PlaygroundState() + ..segmentPreset = SegmentPreset.custom + ..segmentStartEdge = BeamEdge.top + ..segmentStartT = 0.25 + ..segmentEndEdge = BeamEdge.bottom + ..segmentEndT = 0.75 + ..segmentFeather = 64 + ..wrapCorners = true; + final encoded = encodePlaygroundState(custom); + expect(encoded, contains('sgp=custom')); + expect(encoded, contains('sga=top,0.25')); + expect(encoded, contains('sgb=bottom,0.75')); + expect(encoded, contains('sgf=64')); + expect(encoded, contains('wc=1')); + + final decoded = decodePlaygroundState(encoded); + expect(decoded.segmentPreset, SegmentPreset.custom); + expect(decoded.segmentStartEdge, BeamEdge.top); + expect(decoded.segmentStartT, 0.25); + expect(decoded.segmentEndEdge, BeamEdge.bottom); + expect(decoded.segmentEndT, 0.75); + expect(decoded.segmentFeather, 64); + expect(decoded.wrapCorners, isTrue); + }); + + test('unknown enum names and out-of-range numbers are ignored', () { + final decoded = decodePlaygroundState( + 'ed=diagonal&dir=sideways&hm=strobe&rm=never&sdh=nope&' + 'ro=999&po=4&bc=9&sg=7&rp=0&pr=2&al=-1<=5&tl=9&gs=9&sk=3&' + 'iss=9&rs=4', + ); + final d = PlaygroundState(); + expect(decoded.edge, d.edge); + expect(decoded.direction, d.direction); + expect(decoded.hueMode, isNull); + expect(decoded.reducedMotion, isNull); + expect(decoded.seedHarmony, d.seedHarmony); + expect(decoded.ringOffset, d.ringOffset); + expect(decoded.phaseOffset, d.phaseOffset); + expect(decoded.beamCount, d.beamCount); + expect(decoded.segments, isNull, reason: '7 is not an offered count'); + expect(decoded.repeatCycles, isNull); + expect(decoded.progress, d.progress); + expect(decoded.alphaScale, d.alphaScale); + expect(decoded.lerpT, d.lerpT); + expect(decoded.tailLength, d.tailLength); + expect(decoded.glowSpread, d.glowSpread); + expect(decoded.sparkle, d.sparkle); + expect(decoded.innerSizeScale, d.innerSizeScale); + expect(decoded.renderScale, d.renderScale); + }); + + test('bad segment ids, anchors, feather, and wrap values are ignored', () { + final decoded = decodePlaygroundState( + 'sgp=diagonal&sga=right,4&sgb=nope,0.5&sgf=121&wc=maybe', + ); + final d = PlaygroundState(); + expect(decoded.segmentPreset, d.segmentPreset); + expect(decoded.segmentStartEdge, d.segmentStartEdge); + expect(decoded.segmentStartT, d.segmentStartT); + expect(decoded.segmentEndEdge, d.segmentEndEdge); + expect(decoded.segmentEndT, d.segmentEndT); + expect(decoded.segmentFeather, d.segmentFeather); + expect(decoded.wrapCorners, d.wrapCorners); + }); + + test('an assembled palette cannot be a lerp endpoint or a custom ' + 'base', () { + final decoded = decodePlaygroundState('pb=seed&lfa=lerp<o=custom'); + final d = PlaygroundState(); + expect(decoded.customBase, d.customBase); + expect(decoded.lerpFrom, d.lerpFrom); + expect(decoded.lerpTo, d.lerpTo); + }); + + test('the drive and group toggles round-trip', () { + final state = PlaygroundState() + ..driveProgress = true + ..progress = 0.9 + ..followPointer = true + ..strengthSignal = true + ..simulateReducedMotion = true + ..syncDemo = true; + final decoded = decodePlaygroundState(encodePlaygroundState(state)); + expect(decoded.driveProgress, isTrue); + expect(decoded.progress, 0.9); + expect(decoded.followPointer, isTrue); + expect(decoded.strengthSignal, isTrue); + expect(decoded.simulateReducedMotion, isTrue); + expect(decoded.syncDemo, isTrue); + }); + }); +} diff --git a/example/test/snippet_test.dart b/example/test/snippet_test.dart new file mode 100644 index 0000000..7b9d1ff --- /dev/null +++ b/example/test/snippet_test.dart @@ -0,0 +1,540 @@ +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam_example/src/playground/playground_state.dart'; +import 'package:flutter_border_beam_example/src/playground/snippet.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('a default configuration prints the one-liner', () { + expect( + buildSnippet(PlaygroundState()), + 'BorderBeam.rotate(\n child: child,\n)', + ); + }); + + test('picks the named constructor for the variant', () { + for (final entry in { + BeamVariant.rotate: 'BorderBeam.rotate(', + BeamVariant.small: 'BorderBeam.small(', + BeamVariant.line: 'BorderBeam.line(', + BeamVariant.pulseInside: 'BorderBeam.pulseInside(', + BeamVariant.pulseOutside: 'BorderBeam.pulseOutside(', + }.entries) { + final state = PlaygroundState()..variant = entry.key; + expect(buildSnippet(state), startsWith(entry.value)); + } + }); + + test('the small variant leaves its preset radius inherited', () { + final state = PlaygroundState()..variant = BeamVariant.small; + expect(buildSnippet(state), isNot(contains('borderRadius:'))); + }); + + test('a plain radius uses the borderRadius shorthand', () { + final state = PlaygroundState()..radius = 24; + expect(buildSnippet(state), contains('borderRadius: 24')); + expect(buildSnippet(state), isNot(contains('BeamShape'))); + }); + + test('a radius with a squircle or a width uses the const BeamShape.all', () { + final state = PlaygroundState() + ..radius = 24 + ..superellipse = true + ..borderWidth = 2; + expect( + buildSnippet(state), + contains( + 'shape: const BeamShape.all(24, borderWidth: 2, ' + 'superellipse: true)', + ), + ); + }); + + test('stadium corners emit the const named constructor', () { + final state = PlaygroundState()..stadium = true; + expect(buildSnippet(state), contains('shape: const BeamShape.stadium()')); + }); + + test('per-corner mode emits a const BorderRadius.only', () { + final state = PlaygroundState() + ..perCorner = true + ..radiusTopLeft = 4 + ..radiusBottomRight = 28; + final snippet = buildSnippet(state); + expect(snippet, contains('shape: const BeamShape(')); + expect(snippet, contains('radius: BorderRadius.only(')); + expect(snippet, contains('topLeft: Radius.circular(4)')); + expect(snippet, contains('bottomRight: Radius.circular(28)')); + }); + + test('sub-second durations are emitted in milliseconds', () { + final state = PlaygroundState()..cycleGapSeconds = 0.9; + expect( + buildSnippet(state), + contains('cycleGap: Duration(milliseconds: 900)'), + ); + }); + + test('whole-second durations are emitted in seconds', () { + final state = PlaygroundState()..cycleGapSeconds = 2; + expect(buildSnippet(state), contains('cycleGap: Duration(seconds: 2)')); + }); + + test('custom colors are emitted as a const list of Color literals', () { + final state = PlaygroundState() + ..palette = PalettePreset.custom + ..customColors = [0, 1]; + expect( + buildSnippet(state), + contains( + 'colors: const BeamColors.custom([Color(0xFFFF0080), ' + 'Color(0xFF00E5FF)])', + ), + ); + }); + + test('controller mode declares the controller and drops playback', () { + final state = PlaygroundState() + ..controllerMode = true + ..controllerSpeed = 2 + ..speed = 3 + ..startAfterSeconds = 1 + ..durationSeconds = 5; + final snippet = buildSnippet(state); + expect(snippet, startsWith('final controller = BorderBeamController();')); + expect(snippet, contains('// controller.speed = 2;')); + expect(snippet, contains('controller: controller')); + // A controller owns scheduling and the rate exclusively. + expect(snippet, isNot(contains('playback:'))); + expect(snippet, isNot(contains('speed:'))); + }); + + test('scheduling fields ride BeamPlayback outside controller mode', () { + final state = PlaygroundState() + ..startAfterSeconds = 0.5 + ..durationSeconds = 6; + final snippet = buildSnippet(state); + expect(snippet, contains('playback: const BeamPlayback(')); + expect(snippet, contains('startAfter: Duration(milliseconds: 500)')); + expect(snippet, contains('duration: Duration(seconds: 6)')); + }); + + test('an inactive beam uses the active shorthand', () { + final state = PlaygroundState()..active = false; + expect(buildSnippet(state), contains('active: false')); + }); + + test('theme mode nests the beam under a BorderBeamTheme', () { + final state = PlaygroundState()..themeDemo = true; + final snippet = buildSnippet(state); + expect(snippet, startsWith('BorderBeamTheme(')); + expect( + snippet, + contains('style: const BeamStyle(colors: BeamColors.ocean)'), + ); + expect( + snippet, + contains('shape: BeamShape.circular(20, superellipse: true)'), + ); + expect(snippet, contains(' child: BorderBeam.rotate(')); + expect(snippet, endsWith(')')); + }); + + test('line-only timing fields are dropped on other variants', () { + final state = PlaygroundState() + ..variant = BeamVariant.rotate + ..breatheFactor = 2 + ..spikeFactor = 2; + expect(buildSnippet(state), isNot(contains('breatheFactor'))); + + state.variant = BeamVariant.line; + expect(buildSnippet(state), contains('breatheFactor: 2')); + expect(buildSnippet(state), contains('spikeFactor: 2')); + }); + + test('pulse-only style fields are dropped on other variants', () { + final state = PlaygroundState() + ..glowBoost = 2 + ..coreBlur = 20; + expect(buildSnippet(state), isNot(contains('glowBoost'))); + expect(buildSnippet(state), isNot(contains('coreBlur'))); + + state.variant = BeamVariant.pulseInside; + expect(buildSnippet(state), contains('glowBoost: 2')); + expect(buildSnippet(state), isNot(contains('coreBlur'))); + + state.variant = BeamVariant.pulseOutside; + expect(buildSnippet(state), contains('coreBlur: 20')); + }); + + test('numbers read as hand-written arguments', () { + final state = PlaygroundState() + ..strength = 0.5 + ..hueBase = -30 + ..strokeOpacityFactor = 1.25; + final snippet = buildSnippet(state); + expect(snippet, contains('strength: 0.5')); + expect(snippet, contains('hueBase: -30')); + expect(snippet, contains('strokeOpacityFactor: 1.25')); + }); + + group('palettes', () { + test('a preset emits its constant', () { + final state = PlaygroundState()..palette = PalettePreset.aurora; + expect(buildSnippet(state), contains('colors: BeamColors.aurora')); + }); + + test('seed mode emits BeamColors.fromSeed, harmony only when set', () { + final state = PlaygroundState() + ..palette = PalettePreset.seed + ..seedColor = 1; + expect( + buildSnippet(state), + contains('colors: const BeamColors.fromSeed(Color(0xFF00E5FF))'), + ); + + state.seedHarmony = BeamSeedHarmony.triadic; + expect( + buildSnippet(state), + contains( + 'const BeamColors.fromSeed(Color(0xFF00E5FF), ' + 'harmony: BeamSeedHarmony.triadic)', + ), + ); + }); + + test('lerp mode emits both endpoints and the blend', () { + final state = PlaygroundState() + ..palette = PalettePreset.lerp + ..lerpFrom = PalettePreset.ice + ..lerpTo = PalettePreset.ember + ..lerpT = 0.25; + expect( + buildSnippet(state), + contains( + 'colors: const BeamColors.lerp(BeamColors.ice, ' + 'BeamColors.ember, 0.25)', + ), + ); + }); + + test('a custom base is emitted only when it is not colorful', () { + final state = PlaygroundState()..palette = PalettePreset.custom; + expect(buildSnippet(state), isNot(contains('base:'))); + + state.customBase = PalettePreset.ocean; + expect(buildSnippet(state), contains('base: BeamColors.ocean')); + }); + + test('scaleAlpha wraps the palette, default colors included', () { + final state = PlaygroundState()..alphaScale = 0.6; + expect( + buildSnippet(state), + contains('colors: BeamColors.colorful.scaleAlpha(0.6)'), + ); + + state.palette = PalettePreset.gold; + expect( + buildSnippet(state), + contains('colors: BeamColors.gold.scaleAlpha(0.6)'), + ); + }); + }); + + group('shape', () { + test('the line edge is emitted only on the line variant', () { + final state = PlaygroundState()..edge = BeamEdge.top; + expect(buildSnippet(state), isNot(contains('edge:'))); + + state.variant = BeamVariant.line; + expect(buildSnippet(state), contains('edge: BeamEdge.top')); + }); + + test('a ring offset promotes the shorthand to a const BeamShape', () { + final state = PlaygroundState()..ringOffset = 6; + expect( + buildSnippet(state), + contains('shape: const BeamShape.all(16, ringOffset: 6)'), + ); + }); + + test('a contour emits a commented placeholder, since it takes a ' + 'builder', () { + final state = PlaygroundState()..contour = true; + final snippet = buildSnippet(state); + expect(snippet, contains('shape: const BeamShape.all(')); + expect( + snippet, + contains("// contour: BeamPathContour(builder: yourPath, key: 'star')"), + ); + }); + + test('segment presets emit their const BeamSegment fields', () { + final state = PlaygroundState()..segmentPreset = SegmentPreset.bottomHalf; + expect(buildSnippet(state), contains('segment: BeamSegment.bottomHalf')); + + state.segmentPreset = SegmentPreset.topEdge; + expect(buildSnippet(state), contains('segment: BeamSegment.topEdge')); + }); + + test('a custom segment emits const edge anchors and feathering', () { + final state = PlaygroundState() + ..segmentPreset = SegmentPreset.custom + ..segmentFeather = 48; + expect( + buildSnippet(state), + contains( + 'segment: BeamSegment(start: BeamAnchor.edge(BeamEdge.right), ' + 'end: BeamAnchor.edge(BeamEdge.left), feather: 48)', + ), + ); + + state.segmentStartT = 0.25; + expect( + buildSnippet(state), + contains('BeamAnchor.edge(BeamEdge.right, 0.25)'), + ); + }); + + test('corner wrap is line-only and segment takes precedence over edge', () { + final state = PlaygroundState() + ..wrapCorners = true + ..edge = BeamEdge.top; + expect(buildSnippet(state), isNot(contains('wrapCorners'))); + + state.variant = BeamVariant.line; + expect(buildSnippet(state), contains('wrapCorners: true')); + expect(buildSnippet(state), contains('edge: BeamEdge.top')); + + state.segmentPreset = SegmentPreset.bottomHalf; + final segmented = buildSnippet(state); + expect(segmented, contains('segment: BeamSegment.bottomHalf')); + expect(segmented, isNot(contains('wrapCorners'))); + expect(segmented, isNot(contains('edge:'))); + }); + }); + + group('style', () { + test('hue mode is emitted only when it is set', () { + final state = PlaygroundState(); + expect(buildSnippet(state), isNot(contains('hueMode'))); + + state.hueMode = BeamHueMode.continuous; + expect(buildSnippet(state), contains('hueMode: BeamHueMode.continuous')); + }); + + test('tail length and comet are ring-only', () { + final state = PlaygroundState() + ..variant = BeamVariant.line + ..tailLength = 1.5 + ..comet = true; + expect(buildSnippet(state), isNot(contains('tailLength'))); + expect(buildSnippet(state), isNot(contains('comet'))); + + state.variant = BeamVariant.small; + expect(buildSnippet(state), contains('tailLength: 1.5')); + expect(buildSnippet(state), contains('comet: true')); + }); + + test('sparkle is dropped on the pulse variants', () { + final state = PlaygroundState() + ..variant = BeamVariant.pulseInside + ..sparkle = 0.5; + expect(buildSnippet(state), isNot(contains('sparkle'))); + + state.variant = BeamVariant.line; + expect(buildSnippet(state), contains('sparkle: 0.5')); + }); + + test('segments are dropped on the line variant', () { + final state = PlaygroundState() + ..variant = BeamVariant.line + ..segments = 8; + expect(buildSnippet(state), isNot(contains('segments'))); + + state.variant = BeamVariant.pulseOutside; + expect(buildSnippet(state), contains('segments: 8')); + }); + + test('glow spread applies to every variant', () { + final state = PlaygroundState()..glowSpread = 2; + expect(buildSnippet(state), contains('glowSpread: 2')); + }); + }); + + group('timing', () { + test('the travel fields are dropped on the pulse variants', () { + final state = PlaygroundState() + ..variant = BeamVariant.pulseInside + ..direction = BeamDirection.bounce + ..phaseOffset = 0.25 + ..beamCount = 3; + final pulse = buildSnippet(state); + expect(pulse, isNot(contains('direction'))); + expect(pulse, isNot(contains('phaseOffset'))); + expect(pulse, isNot(contains('beamCount'))); + + state.variant = BeamVariant.rotate; + final rotate = buildSnippet(state); + expect(rotate, contains('direction: BeamDirection.bounce')); + expect(rotate, contains('phaseOffset: 0.25')); + expect(rotate, contains('beamCount: 3')); + }); + }); + + group('playback', () { + test('repeat emits the constructor the chip selects', () { + final state = PlaygroundState()..repeatCycles = 1; + expect(buildSnippet(state), contains('repeat: BeamRepeat.once()')); + + state.repeatCycles = 3; + expect(buildSnippet(state), contains('repeat: BeamRepeat.count(3)')); + + state.repeatCycles = null; + expect(buildSnippet(state), isNot(contains('repeat'))); + }); + + test('static-frame reduced motion sets no field', () { + final state = PlaygroundState(); + expect(buildSnippet(state), isNot(contains('reducedMotion'))); + + state.reducedMotion = BeamReducedMotion.slow; + expect( + buildSnippet(state), + contains('reducedMotion: BeamReducedMotion.slow'), + ); + }); + + test('repeat survives controller mode, where scheduling does not', () { + final state = PlaygroundState() + ..controllerMode = true + ..repeatCycles = 3 + ..startAfterSeconds = 1 + ..durationSeconds = 5; + final snippet = buildSnippet(state); + expect(snippet, contains('repeat: BeamRepeat.count(3)')); + expect(snippet, isNot(contains('startAfter'))); + expect(snippet, isNot(contains('duration:'))); + }); + }); + + group('drive', () { + test('progress rides a flat parameter, and only where a beam travels', () { + final state = PlaygroundState() + ..driveProgress = true + ..progress = 0.4; + expect(buildSnippet(state), contains('progress: 0.4')); + + state.variant = BeamVariant.pulseOutside; + expect(buildSnippet(state), isNot(contains('progress'))); + }); + + test('follow names the pointer variable and explains it', () { + final state = PlaygroundState()..followPointer = true; + final snippet = buildSnippet(state); + expect(snippet, contains('// pointer:')); + expect(snippet, contains('follow: pointer')); + }); + + test('progress wins over follow', () { + final state = PlaygroundState() + ..followPointer = true + ..driveProgress = true; + final snippet = buildSnippet(state); + expect(snippet, contains('progress:')); + expect(snippet, isNot(contains('follow:'))); + }); + + test('the strength signal declares its notifier', () { + final state = PlaygroundState()..strengthSignal = true; + final snippet = buildSnippet(state); + expect(snippet, startsWith('final level = ValueNotifier(1);')); + expect(snippet, contains('strengthListenable: level')); + }); + }); + + test('the sync demo wraps the beam in a BeamSync', () { + final state = PlaygroundState()..syncDemo = true; + final snippet = buildSnippet(state); + expect(snippet, startsWith('BeamSync(')); + expect(snippet, contains('child: Column(')); + expect('BorderBeam.rotate('.allMatches(snippet), hasLength(3)); + expect(snippet, contains('phaseOffset: 0.33')); + expect(snippet, contains('phaseOffset: 0.67')); + expect(snippet, endsWith(')')); + }); + + test('the sync demo owns playback and never emits a controller', () { + final state = PlaygroundState() + ..syncDemo = true + ..controllerMode = true + ..active = false + ..speed = 2 + ..reducedMotion = BeamReducedMotion.slow + ..repeatCycles = 3; + final snippet = buildSnippet(state); + expect(snippet, contains('active: false')); + expect(snippet, contains('speed: 2')); + expect(snippet, contains('reducedMotion: BeamReducedMotion.slow')); + expect(snippet, isNot(contains('BorderBeamController'))); + expect(snippet, isNot(contains('controller: controller'))); + expect(snippet, isNot(contains('repeat:'))); + }); + + test('the sync demo nests inside the theme demo', () { + final state = PlaygroundState() + ..syncDemo = true + ..themeDemo = true; + final snippet = buildSnippet(state); + expect(snippet, startsWith('BorderBeamTheme(')); + expect(snippet, contains('child: BeamSync(')); + }); + + group('the newest style and playback fields', () { + test('inner size is pulse-inside only, render scale is universal', () { + final state = PlaygroundState() + ..innerSizeScale = 1.4 + ..renderScale = 0.5; + expect(buildSnippet(state), isNot(contains('innerSizeScale'))); + expect(buildSnippet(state), contains('renderScale: 0.5')); + + state.variant = BeamVariant.pulseInside; + expect(buildSnippet(state), contains('innerSizeScale: 1.4')); + }); + + test('the stock recipe replaces the const style, and merges what is ' + 'set over it', () { + final state = PlaygroundState() + ..variant = BeamVariant.pulseOutside + ..stockPulseOutside = true; + expect( + buildSnippet(state), + contains('style: BeamStyle.pulseOutsideStock'), + ); + expect(buildSnippet(state), isNot(contains('merge('))); + + state.glowBoost = 1.4; + final merged = buildSnippet(state); + expect(merged, contains('BeamStyle.pulseOutsideStock.merge(')); + expect(merged, contains('glowBoost: 1.4')); + }); + + test('the stock recipe is dropped on every other variant', () { + final state = PlaygroundState()..stockPulseOutside = true; + expect(buildSnippet(state), isNot(contains('pulseOutsideStock'))); + }); + + test('offscreen pause and the CSS fade curve ride BeamPlayback', () { + final state = PlaygroundState() + ..pauseWhenOffscreen = true + ..cssFadeCurve = true; + final snippet = buildSnippet(state); + expect(snippet, contains('pauseWhenOffscreen: true')); + expect(snippet, contains('fadeCurve: BeamPlayback.cssEase')); + }); + + test('offscreen pausing can be explicitly disabled', () { + final state = PlaygroundState()..pauseWhenOffscreen = false; + expect(buildSnippet(state), contains('pauseWhenOffscreen: false')); + }); + }); +} diff --git a/example/web/index.html b/example/web/index.html index 3e01333..cc8d0f0 100644 --- a/example/web/index.html +++ b/example/web/index.html @@ -12,24 +12,44 @@ * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base This is a placeholder for base href that will be replaced by the value of - the `--base-href` argument provided to `flutter build`. + the `--base-href` argument provided to `flutter build`. The Pages workflow + (.github/workflows/pages.yaml) passes `--base-href /flutter_border_beam/`. --> - + + + + + + + + + + + + + + + + + + + + - + - border_beam_example + flutter_border_beam playground diff --git a/example/web/manifest.json b/example/web/manifest.json index db0a709..9cede53 100644 --- a/example/web/manifest.json +++ b/example/web/manifest.json @@ -1,12 +1,12 @@ { - "name": "border_beam_example", - "short_name": "border_beam_example", + "name": "flutter_border_beam playground", + "short_name": "border beam", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", + "background_color": "#070707", + "theme_color": "#070707", + "description": "Interactive playground and gallery for flutter_border_beam — animated border beam effects for Flutter.", + "orientation": "any", "prefer_related_applications": false, "icons": [ { diff --git a/lib/border_beam.dart b/lib/border_beam.dart deleted file mode 100644 index 1dcdfa4..0000000 --- a/lib/border_beam.dart +++ /dev/null @@ -1,14 +0,0 @@ -/// Animated border beam effects for Flutter. -/// -/// A faithful port of the border-beam React library: traveling and breathing -/// glow animations around any widget, with rotate, line, and pulse variants, -/// four color presets plus custom palettes, superellipse borders, and an -/// optional playback controller. -library; - -export 'src/border_beam.dart' show BorderBeam; -export 'src/border_beam_controller.dart' show BorderBeamController; -export 'src/models/beam_blob.dart' show BeamBlob, LineBlob; -export 'src/models/beam_colors.dart' show BeamColors; -export 'src/models/beam_theme.dart' show BeamTheme; -export 'src/models/beam_variant.dart' show BeamVariant; diff --git a/lib/flutter_border_beam.dart b/lib/flutter_border_beam.dart new file mode 100644 index 0000000..2907cff --- /dev/null +++ b/lib/flutter_border_beam.dart @@ -0,0 +1,36 @@ +/// Animated border beam effects for Flutter. +/// +/// A faithful port of the border-beam React library: traveling and breathing +/// glow animations around any widget, with rotate, line, and pulse variants, +/// eleven color presets plus custom palettes, superellipse borders, and an +/// optional playback controller. +library; + +export 'src/beam_sync.dart' show BeamSync; +export 'src/border_beam.dart' show BorderBeam; +export 'src/border_beam_controller.dart' show BorderBeamController; +export 'src/border_beam_theme.dart' show BorderBeamTheme, BorderBeamThemeData; +export 'src/models/beam_blob.dart' show BeamBlob, LineBlob; +export 'src/models/beam_colors.dart' show BeamColors, BeamSeedHarmony; +export 'src/models/beam_options.dart' + show + BeamContour, + BeamDirection, + BeamEdge, + BeamHueMode, + BeamPathContour, + BeamPulseOutsideTuning, + BeamReducedMotion, + BeamRepeat; +export 'src/models/beam_playback.dart' show BeamPlayback; +export 'src/models/beam_segment.dart' show BeamAnchor, BeamCorner, BeamSegment; +export 'src/models/beam_shape.dart' show BeamShape; +export 'src/models/beam_style.dart' show BeamStyle; +export 'src/models/beam_theme.dart' show BeamTheme; +export 'src/models/beam_theme_config.dart' show BeamThemeConfig; +export 'src/models/beam_timing.dart' show BeamTiming; +export 'src/models/beam_variant.dart' show BeamVariant; +export 'src/widgets/beam_decoration.dart' show BeamDecoration; +export 'src/widgets/beam_focus_ring.dart' show BeamFocusRing; +export 'src/widgets/beam_hover.dart' show BeamHover; +export 'src/widgets/beam_press.dart' show BeamPress; diff --git a/lib/src/animation/beam_clock.dart b/lib/src/animation/beam_clock.dart index 5afa1bf..bb912a8 100644 --- a/lib/src/animation/beam_clock.dart +++ b/lib/src/animation/beam_clock.dart @@ -1,6 +1,8 @@ +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; -import 'package:sprung/sprung.dart'; + +import 'spring_curve.dart'; /// The fade lifecycle stage of a beam. enum BeamFadeStage { @@ -20,18 +22,20 @@ enum BeamFadeStage { /// function of elapsed time, driven by ONE [Ticker] (the React library uses /// one shared requestAnimationFrame loop). The clock integrates scaled /// deltas so [speed] changes and pauses keep continuity, runs the -/// fade-in/fade-out envelope (spring-eased via `sprung`), and can cap its -/// notification rate for the pulse variants (~30fps in the source). +/// fade-in/fade-out envelope (spring-eased via [FadeSpringCurve] unless +/// [fadeCurve] replaces it), and can +/// cap its notification rate for the pulse variants (~30fps in the source). class BeamClock extends ChangeNotifier { /// Creates a clock. [createTicker] is typically /// `TickerProviderStateMixin.createTicker`. [maxFps] caps notification /// frequency while not fading (null = every frame). [onFadeComplete] fires /// with `true` when a fade-in finishes and `false` when a fade-out - /// finishes. + /// finishes. [fadeCurve] replaces the spring easing of both fades. BeamClock({ required Ticker Function(TickerCallback) createTicker, this.maxFps, this.onFadeComplete, + this.fadeCurve, }) : _createTicker = createTicker; /// Fade-in duration in seconds (React `beam-fade-in 0.6s`). @@ -40,8 +44,17 @@ class BeamClock extends ChangeNotifier { /// Fade-out duration in seconds (React `beam-fade-out 0.5s`). static const double fadeOutSeconds = 0.5; + /// How far [pulse] lifts [boost] at its peak. + static const double pulsePeak = 2; + + /// How far [flash] lifts [boost] at its peak. + /// + /// High enough that every layer reaches the clamp `BeamLayerUtils` + /// applies, which is what makes the blink read as full opacity. + static const double flashPeak = 4; + // Spring-eased fade, isolated here so the curve is trivially replaceable. - static final _fadeCurve = Sprung.criticallyDamped; + static const _defaultFadeCurve = FadeSpringCurve.instance; final Ticker Function(TickerCallback) _createTicker; @@ -52,19 +65,38 @@ class BeamClock extends ChangeNotifier { /// active (`true` after fade-in, `false` after fade-out). final ValueChanged? onFadeComplete; + /// The easing both fades run on; null uses the spring + /// ([FadeSpringCurve]). + /// + /// Settable so a beam can pick up `BeamPlayback.fadeCurve` after its clock + /// exists. It is read per frame, so a change lands on the next one — and + /// mid-fade, since both stages read the curve from where they already are. + Curve? fadeCurve; + + Curve get _fadeCurve => fadeCurve ?? _defaultFadeCurve; + Ticker? _ticker; Duration _lastRaw = Duration.zero; double _elapsed = 0; + double _activeSeconds = 0; double _speed = 1; BeamFadeStage _stage = BeamFadeStage.none; double _fadeStart = 0; double _fadeFromOpacity = 0; double _lastNotify = -1; bool _visible = false; + _Boost? _boost; /// Elapsed animation time in (speed-scaled) seconds. double get elapsedSeconds => _elapsed; + /// Unscaled wall time accumulated while the ticker is running. + /// + /// Unlike [elapsedSeconds], this is unaffected by playback speed and does + /// not advance while paused. Scheduling uses it for duration limits so an + /// offscreen pause suspends the remaining play time too. + double get activeSeconds => _activeSeconds; + /// Whether the ticker is currently producing frames. bool get isRunning => _ticker?.isActive ?? false; @@ -74,6 +106,45 @@ class BeamClock extends ChangeNotifier { /// The current fade stage. BeamFadeStage get stage => _stage; + /// Whether a [pulse] or [flash] envelope is still playing. + bool get isBoosting => _boost != null; + + /// The amplitude envelope on top of [fadeOpacity]: 1 at rest, rising to + /// [pulsePeak] or [flashPeak] while a [pulse] or [flash] plays. + /// + /// The painter multiplies it into the fade it hands the resolver, so it + /// scales every layer's opacity at once. Layer opacity is clamped at paint + /// time, so a boost brightens the dim layers and saturates the ones that + /// are already near full — it can never overflow. + double get boost { + final b = _boost; + if (b == null) return 1; + return 1 + b.envelopeAt(_elapsed) * (b.peak - 1); + } + + /// Lifts the beam to [pulsePeak] and settles back over ~0.6s — a one-shot + /// bump that marks a moment without restarting anything. + /// + /// No-op while the beam is hidden or frozen: there is no frame budget to + /// play the envelope on. + void pulse() => _startBoost( + const _Boost(peak: pulsePeak, rise: 0.24, hold: 0, fall: 0.36), + ); + + /// Blinks the beam to [flashPeak], holds for 120ms, and decays — a + /// sharper, brighter accent than [pulse]. + /// + /// No-op while the beam is hidden or frozen. + void flash() => _startBoost( + const _Boost(peak: flashPeak, rise: 0, hold: 0.12, fall: 0.28), + ); + + void _startBoost(_Boost boost) { + if (!_visible || !isRunning) return; + _boost = boost.startingAt(_elapsed); + notifyListeners(); + } + /// Playback rate multiplier. Takes effect from the next frame. double get speed => _speed; set speed(double value) { @@ -83,8 +154,8 @@ class BeamClock extends ChangeNotifier { /// The current fade envelope value (0–1). /// - /// The spring curve can numerically overshoot by ~1%; the result is - /// clamped so it is always a valid opacity. + /// The spring curve is lightly under-damped and overshoots its target by + /// up to ~3%; the result is clamped so it is always a valid opacity. double get fadeOpacity { final raw = switch (_stage) { BeamFadeStage.none => _visible ? 1.0 : 0.0, @@ -104,6 +175,31 @@ class BeamClock extends ChangeNotifier { return raw.clamp(0.0, 1.0); } + // Called when `elapsedSeconds` goes back to zero. Kept separate from the + // ChangeNotifier listeners, which fire every frame: a reset happens only + // on a restart, so registering for it costs nothing per frame. + final List _timelineResetListeners = []; + + /// Registers [listener] to hear that the timeline restarted from zero. + /// + /// Anything holding a correction *relative* to elapsed time — a hue shift, + /// a rescale offset, a `follow` hand-back — is meaningless once the + /// timeline restarts, and a shared group clock restarts without any one + /// member being told. This is how they find out. + void addTimelineResetListener(VoidCallback listener) => + _timelineResetListeners.add(listener); + + /// Removes a listener added with [addTimelineResetListener]. + void removeTimelineResetListener(VoidCallback listener) => + _timelineResetListeners.remove(listener); + + void _notifyTimelineReset() { + // Copied: a listener is free to detach itself while being called. + for (final listener in List.of(_timelineResetListeners)) { + listener(); + } + } + /// Activates the beam: resets the timeline (matching CSS animation restart /// when `data-active` is re-applied) and fades in. void activate() { @@ -115,6 +211,8 @@ class BeamClock extends ChangeNotifier { } else { _elapsed = 0; _fadeFromOpacity = 0; + _boost = null; + _notifyTimelineReset(); } _visible = true; _stage = BeamFadeStage.fadingIn; @@ -147,11 +245,32 @@ class BeamClock extends ChangeNotifier { notifyListeners(); } + /// Rescales the timeline by [factor], keeping every cycle-relative phase + /// where it is. + /// + /// Used when a beam's cycle duration changes mid-run: every animated + /// track derived from the cycle reads `elapsed / cycleSeconds`, so + /// multiplying elapsed time by `newCycle / oldCycle` leaves each track at + /// the exact fraction it had. The fade envelope and the frame-rate cap + /// are wall-clock schedules, not cycle-relative ones, so their anchors + /// move with the timeline rather than being scaled — the fade keeps its + /// current opacity and its remaining duration. + void retime(double factor) { + assert(factor > 0, 'retime factor must be positive'); + final shift = _elapsed * factor - _elapsed; + _elapsed += shift; + _fadeStart += shift; + _lastNotify += shift; + _boost = _boost?.shiftedBy(shift); + notifyListeners(); + } + /// Marks the beam visible at full opacity without animating (used for /// reduced motion and for initially-active beams that must not fade in). void showStatic() { _visible = true; _stage = BeamFadeStage.none; + _ticker?.stop(); notifyListeners(); } @@ -169,6 +288,7 @@ class BeamClock extends ChangeNotifier { final deltaSeconds = (raw - _lastRaw).inMicroseconds / Duration.microsecondsPerSecond; _lastRaw = raw; + _activeSeconds += deltaSeconds; _elapsed += deltaSeconds * _speed; var mustNotify = true; @@ -182,13 +302,17 @@ class BeamClock extends ChangeNotifier { _visible = false; _ticker!.stop(); _elapsed = 0; + _notifyTimelineReset(); onFadeComplete?.call(false); - } else if (_stage == BeamFadeStage.none && maxFps != null) { + } else if (_stage == BeamFadeStage.none && maxFps != null && !isBoosting) { // Rate cap (pulse variants): skip paint-frame notifications, matching - // the source's ~30fps pulse driver. Time still accumulates. + // the source's ~30fps pulse driver. Time still accumulates. A boost is + // short and steep, so it plays at the full frame rate. final interval = 1 / maxFps! - 0.002; if (_elapsed - _lastNotify < interval) mustNotify = false; } + // Drop a spent boost, keeping the frame that lands back at 1. + if (_boost?.isDoneAt(_elapsed) ?? false) _boost = null; if (mustNotify) { _lastNotify = _elapsed; notifyListeners(); @@ -197,8 +321,69 @@ class BeamClock extends ChangeNotifier { @override void dispose() { + _boost = null; + _timelineResetListeners.clear(); _ticker?.dispose(); _ticker = null; super.dispose(); } } + +/// One amplitude bump: a rise, a hold at the peak, and a decay back to rest. +class _Boost { + const _Boost({ + required this.peak, + required this.rise, + required this.hold, + required this.fall, + this.startedAt = 0, + }); + + /// The multiplier at the top of the envelope. + final double peak; + + /// Seconds spent climbing to [peak], spring-eased like the fade. + final double rise; + + /// Seconds held at [peak]. + final double hold; + + /// Seconds spent decaying back to rest, smootherstep-eased so the release + /// has no visible corner at either end. + final double fall; + + /// Timeline position the envelope started at. + final double startedAt; + + double get _total => rise + hold + fall; + + _Boost startingAt(double now) => + _Boost(peak: peak, rise: rise, hold: hold, fall: fall, startedAt: now); + + _Boost shiftedBy(double shift) => _Boost( + peak: peak, + rise: rise, + hold: hold, + fall: fall, + startedAt: startedAt + shift, + ); + + bool isDoneAt(double now) => now - startedAt >= _total; + + /// The 0–1 envelope at timeline position [now]. + double envelopeAt(double now) { + var x = now - startedAt; + if (x <= 0) return rise > 0 ? 0 : 1; + if (x < rise) return FadeSpringCurve.instance.transform(x / rise); + x -= rise; + if (x < hold) return 1; + x -= hold; + if (fall <= 0 || x >= fall) return 0; + return 1 - _smootherstep(x / fall); + } + + static double _smootherstep(double x) { + final c = x.clamp(0.0, 1.0); + return c * c * c * (c * (c * 6 - 15) + 10); + } +} diff --git a/lib/src/animation/beam_phases.dart b/lib/src/animation/beam_phases.dart index 02b86cc..be0e201 100644 --- a/lib/src/animation/beam_phases.dart +++ b/lib/src/animation/beam_phases.dart @@ -5,7 +5,9 @@ import 'package:flutter/animation.dart' show Curves; import '../constants/line_keyframes.dart'; import '../constants/pulse_params.dart'; import '../models/beam_config.dart'; +import '../models/beam_options.dart'; import '../models/beam_variant.dart'; +import 'beam_clock.dart'; import 'oscillator.dart'; /// Samples a keyframe table at cycle progress [t] (0–1). @@ -41,6 +43,10 @@ class BeamFramePhases { required this.hueDegrees, this.bloomHueDegrees = 0, this.angleRadians = 0, + this.travelProgress = 0, + this.travellers = const [0], + this.reversedNow = false, + this.finished = false, this.lineX = 0.06, this.lineW = 0.5, this.lineH = 0.8, @@ -50,18 +56,48 @@ class BeamFramePhases { this.pulse = const PulsePhaseSet.identity(), }); - /// Fade envelope (0–1), multiplied into every layer opacity. + /// Fade envelope (0–1), multiplied into every layer opacity. Carries the + /// rest between sweeps (`BeamTiming.cycleGap`) as well as the beam's own + /// fade in and out. final double fadeOpacity; /// Animated hue rotation in degrees (excludes the static hue base). final double hueDegrees; - /// The line bloom's separate hue track (±(hueRange+10)° over 8s). + /// The line bloom's separate hue track (±(hueRange+10)°, over + /// `BeamConfig.bloomHuePeriodSeconds`). final double bloomHueDegrees; /// Rotating conic window angle (rotate/small), radians. final double angleRadians; + /// Raw sweep progress (0–1) of the leading beam through the current cycle. + /// + /// Equal to `travellers.first`. Already carries the travel direction and + /// the phase offset, and is the value a driven `BorderBeam.progress` + /// replaces. + final double travelProgress; + + /// Sweep progress (0–1) of every beam travelling the contour, spaced + /// `1 / BeamConfig.beamCount` apart. Never empty; the first entry is + /// [travelProgress]. + final List travellers; + + /// Whether the current cycle runs mirrored — always false under + /// [BeamDirection.forward], always true under [BeamDirection.reverse], and + /// alternating per cycle under [BeamDirection.bounce]. + /// + /// The geometry in [travellers] is already mirrored; this flag lets a + /// strategy mirror asymmetric tables (a comet tail, a spike schedule) to + /// match. + final bool reversedNow; + + /// Whether the beam has run out its `BeamPlayback.repeat` budget. + /// + /// The widget reacts by deactivating the clock, so the beam fades out the + /// way an inactive one does rather than cutting off. + final bool finished; + /// Line beam x position, fraction of width. final double lineX; @@ -88,64 +124,117 @@ class BeamFramePhases { /// mirroring the source's CSS keyframes and JS pulse driver exactly. class BeamPhaseResolver { /// Creates a resolver for [config]. - BeamPhaseResolver(this.config) - : _bank = config.variant.isPulse - ? PulseOscillatorBank( - PulseParams.resolve( - config.variant, - config.brightness, - config.cycleSeconds, - ), - ) - : null, - _pulseParams = config.variant.isPulse - ? PulseParams.resolve( - config.variant, - config.brightness, - config.cycleSeconds, - ) - : null; + factory BeamPhaseResolver(BeamConfig config) => BeamPhaseResolver._( + config, + config.variant.isPulse + ? PulseParams.resolve( + config.variant, + config.brightness, + config.cycleSeconds, + ) + : null, + ); + + BeamPhaseResolver._(this.config, PulseParams? pulseParams) + : _bank = pulseParams == null ? null : PulseOscillatorBank(pulseParams); /// The resolved beam configuration. final BeamConfig config; final PulseOscillatorBank? _bank; - final PulseParams? _pulseParams; - /// The hue ping-pong keyframes of the rotate/line variants - /// (0% −range, 50% +range, 100% −range, ease-in-out per segment, 12s). - static const double huePeriodSeconds = 12; + /// Seconds added to the sample time of the fixed-period hue tracks only. + /// + /// The cycle-derived tracks (rotate angle, line travel, pulse + /// oscillators) all scale with [BeamConfig.cycleSeconds], so a cycle + /// change can be absorbed by rescaling elapsed time + /// ([BeamClock.retime]) — their fractions come out unchanged. The hue + /// tracks run on fixed periods ([BeamConfig.huePeriodSeconds], + /// [BeamConfig.bloomHuePeriodSeconds]) and would jump under that rescale, + /// so the widget shifts them back by the amount the timeline moved. + /// + /// See [breatheTimeOffset] for the cycle-derived tracks that still need a + /// correction, because the retime target is not always a pure rescale. + double hueTimeOffset = 0; + + /// Cycles the beam is allowed to run before it reports + /// [BeamFramePhases.finished], or null to run forever. + /// + /// The resolved form of `BeamPlayback.repeat`: playback is the widget's + /// business rather than a painted value, so it rides on the resolver + /// instead of on [BeamConfig]. + int? repeatCycles; - /// The line bloom hue period (8s). - static const double bloomHuePeriodSeconds = 8; + /// Seconds added to the sample time of the travel tracks only. + /// + /// Shifts where the sweep sits without touching the hue tracks. The widget + /// uses it to hand a `BorderBeam.follow` gesture back to the clock: the + /// timed sweep picks up from wherever the pointer left the beam instead of + /// snapping back to its own schedule. + double travelTimeOffset = 0; + + /// Seconds added to the sample time of the line variant's breathe and + /// spike tracks only. + /// + /// Those tracks scale with the cycle, so a *pure ratio* rescale of elapsed + /// time is what holds their phase. A retime does not always produce one: + /// once a cycle gap, a phase offset, or a [travelTimeOffset] hand-back is + /// in play, the clock is moved to put the *sweep* where it belongs, and + /// that target is not the pure rescale. The widget sets this to the + /// difference, so these tracks read the timeline they would have had. + /// + /// It corrects for a *cycle* change only. Changing one of the factors that + /// define these periods (`BeamTiming.breatheFactor` and friends) re-phases + /// the affected track by design, the same way changing + /// `BeamTiming.huePeriod` re-phases the hue: a period change describes a + /// different animation, so there is no phase to carry over. The widget + /// drops this offset whenever the timeline restarts. + double breatheTimeOffset = 0; /// Samples all phases at [t] seconds with the given [fadeOpacity]. - BeamFramePhases sample(double t, double fadeOpacity) { + /// + /// [progress] takes the travel over when non-null (`BorderBeam.progress` + /// and `BorderBeam.follow`): the sweep sits at that fraction (0–1) instead + /// of running with the clock, while the fade envelope and the hue tracks + /// keep running. A driven progress is used as given — + /// [BeamConfig.direction] and [BeamConfig.phaseOffset] shape the timed + /// sweep, not this value. + BeamFramePhases sample(double t, double fadeOpacity, {double? progress}) { final v = config.variant; final hue = _hue(t); + final sweep = _sweep(t, progress); switch (v) { case BeamVariant.rotate || BeamVariant.small: - final cycle = (t / config.cycleSeconds) % 1.0; return BeamFramePhases( - fadeOpacity: fadeOpacity, + fadeOpacity: fadeOpacity * sweep.envelope, hueDegrees: hue, - angleRadians: cycle * 2 * math.pi, + angleRadians: sweep.travellers.first * 2 * math.pi, + travelProgress: sweep.travellers.first, + travellers: sweep.travellers, + reversedNow: sweep.reversed, + finished: sweep.finished, ); case BeamVariant.line: - final cycle = (t / config.cycleSeconds) % 1.0; - final breathe = (t / (config.cycleSeconds * 1.3)) % 1.0; - final spikeT = (t / (config.cycleSeconds * 1.33)) % 1.0; - final spike2T = (t / (config.cycleSeconds * 1.7)) % 1.0; + final head = sweep.travellers.first; + final cs = config.cycleSeconds; + final bt = t + breatheTimeOffset; + final breathe = (bt / (cs * config.breatheFactor)) % 1.0; + final spikeT = (bt / (cs * config.spikeFactor)) % 1.0; + final spike2T = (bt / (cs * config.spike2Factor)) % 1.0; return BeamFramePhases( - fadeOpacity: fadeOpacity, + fadeOpacity: fadeOpacity * sweep.envelope, hueDegrees: hue, bloomHueDegrees: _pingPongHue( - t, - bloomHuePeriodSeconds, + t + hueTimeOffset, + config.bloomHuePeriodSeconds, config.hueRange + 10, ), - lineX: sampleKeyframes(lineTravelX, cycle), - lineW: sampleKeyframes(lineTravelW, cycle), - edge: sampleKeyframes(lineEdgeFade, cycle), + travelProgress: head, + travellers: sweep.travellers, + reversedNow: sweep.reversed, + finished: sweep.finished, + lineX: sampleKeyframes(lineTravelX, head), + lineW: sampleKeyframes(lineTravelW, head), + edge: sampleKeyframes(lineEdgeFade, head), lineH: sampleKeyframes(lineBreatheH, breathe, easedSegments: true), spike: sampleKeyframes(lineSpike, spikeT, easedSegments: true), spike2: sampleKeyframes(lineSpike2, spike2T, easedSegments: true), @@ -154,32 +243,154 @@ class BeamPhaseResolver { return BeamFramePhases( fadeOpacity: fadeOpacity, hueDegrees: hue, + finished: sweep.finished, pulse: _bank!.sample(t), ); } } + /// Whether the beam has spent its [repeatCycles] budget at [t] seconds. + /// + /// The same test [sample] reports through [BeamFramePhases.finished], + /// without building a frame: the widget checks it every tick. + bool finishedAt(double t) { + final budget = repeatCycles; + if (budget == null) return false; + return _cycleIndex(_shift(t)) >= budget; + } + + /// The travel timeline at [t]: the phase offset and any follow hand-back + /// folded in. + double _shift(double t) => + t + config.phaseOffset * config.cycleSeconds + travelTimeOffset; + + int _cycleIndex(double shifted) { + final period = + config.cycleSeconds + (config.variant.isPulse ? 0 : config.gapSeconds); + return period <= 0 ? 0 : (shifted / period).floor(); + } + + /// The travel state of every beam at [t]: their progress, the gap + /// envelope, whether this cycle runs mirrored, and whether the repeat + /// budget is spent. + _Sweep _sweep(double t, double? driven) { + // The phase offset moves the beam along its own timeline; the hue tracks + // run on fixed periods of their own and stay where they are. + final shifted = _shift(t); + final cycleIndex = _cycleIndex(shifted); + final budget = repeatCycles; + final finished = budget != null && cycleIndex >= budget; + final reversed = switch (config.direction) { + BeamDirection.forward => false, + BeamDirection.reverse => true, + BeamDirection.bounce => cycleIndex.isOdd, + }; + + if (driven != null) { + // A driven progress replaces the sweep outright, so there is no gap to + // rest in either. + final head = driven.clamp(0.0, 1.0); + return _Sweep(_spread(head), 1, false, finished); + } + final (forward, envelope) = _travel(shifted); + final head = reversed ? 1 - forward : forward; + return _Sweep( + _spread(head, reversed: reversed), + envelope, + reversed, + finished, + ); + } + + /// Places [head] and the remaining `beamCount - 1` beams evenly around the + /// cycle, keeping [head] first. + List _spread(double head, {bool reversed = false}) { + final n = config.beamCount; + if (n <= 1) return [head]; + final step = 1 / n; + return List.generate(n, (i) { + if (i == 0) return head; + final offset = reversed ? -i * step : i * step; + return (head + offset) % 1.0; + }); + } + /// A representative static frame for reduced motion: mid-cycle, no hue /// offset, full opacity. - BeamFramePhases staticFrame() { - final t = config.cycleSeconds / 2; - final phases = sample(t, 1); + /// + /// The frame is shown for as long as reduced motion lasts, so it carries + /// the palette's own colors — a hue sampled from the ping-pong would tint + /// the whole effect by an arbitrary offset. + /// + /// A driven [progress] is honored: a beam whose sweep is a progress + /// readout still reads its value under reduced motion. + BeamFramePhases staticFrame({double? progress}) { if (config.variant.isPulse) { // Freeze the breathing at rest, matching the source's // prefers-reduced-motion behavior (animations disabled entirely). - return BeamFramePhases(fadeOpacity: 1, hueDegrees: 0); + return const BeamFramePhases(fadeOpacity: 1, hueDegrees: 0); + } + // The traveling variants keep their mid-cycle geometry. + final phases = sample(config.cycleSeconds / 2, 1, progress: progress); + return BeamFramePhases( + fadeOpacity: 1, + hueDegrees: 0, + angleRadians: phases.angleRadians, + travelProgress: phases.travelProgress, + travellers: phases.travellers, + reversedNow: phases.reversedNow, + lineX: phases.lineX, + lineW: phases.lineW, + lineH: phases.lineH, + spike: phases.spike, + spike2: phases.spike2, + edge: phases.edge, + ); + } + + /// Travel progress through one sweep (0–1) and the gap envelope that + /// multiplies into the fade at [t]. + /// + /// Without a gap this is the plain `t / cycle` wrap. With one, the sweep + /// runs over the first `cycleSeconds` of each `cycle + gap` period and then + /// parks at progress 1 while the envelope eases out and back in over + /// `min(0.25s, gap / 2)` at each end of the rest. + (double progress, double envelope) _travel(double t) { + final cycle = config.cycleSeconds; + final gap = config.gapSeconds; + if (gap <= 0) return ((t / cycle) % 1.0, 1.0); + final local = t % (cycle + gap); + if (local < cycle) return (local / cycle, 1.0); + final intoGap = local - cycle; + final fade = math.min(0.25, gap / 2); + if (fade <= 0) return (1.0, 0.0); + if (intoGap < fade) return (1.0, 1 - _smoothstep(intoGap / fade)); + final outFrom = gap - fade; + if (intoGap > outFrom) { + return (1.0, _smoothstep((intoGap - outFrom) / fade)); } - return phases; + return (1.0, 0.0); + } + + // Smoothstep: zero slope at both ends, so the rest opens and closes without + // a visible corner. + static double _smoothstep(double x) { + final c = x.clamp(0.0, 1.0); + return c * c * (3 - 2 * c); } double _hue(double t) { if (config.staticColors) return 0; - if (config.variant.isPulse) { + final hueT = t + hueTimeOffset; + return switch (config.hueMode) { // Continuous full revolution (sawtooth 0→360°). - final period = _pulseParams!.huePeriod; - return ((t / period) % 1.0) * 360; - } - return _pingPongHue(t, huePeriodSeconds, config.hueRange); + BeamHueMode.continuous => ((hueT / config.huePeriodSeconds) % 1.0) * 360, + BeamHueMode.pingPong => _pingPongHue( + hueT, + config.huePeriodSeconds, + config.hueRange, + ), + }; } // CSS `beam-hue-shift`: keyframes −range @0%, +range @50%, −range @100%, @@ -191,3 +402,14 @@ class BeamPhaseResolver { return -range + 2 * range * eased; } } + +/// One frame of travel: where every beam sits, the gap envelope, whether the +/// cycle is mirrored, and whether the repeat budget is spent. +class _Sweep { + const _Sweep(this.travellers, this.envelope, this.reversed, this.finished); + + final List travellers; + final double envelope; + final bool reversed; + final bool finished; +} diff --git a/lib/src/animation/oscillator.dart b/lib/src/animation/oscillator.dart index d554775..4a89067 100644 --- a/lib/src/animation/oscillator.dart +++ b/lib/src/animation/oscillator.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart'; + import '../constants/pulse_params.dart'; /// The cosine ping-pong easing of the source's pulse driver: @@ -157,6 +159,33 @@ class PulseOscillatorBank { final PulseOscillator _bgh; final List _bop; + /// The bank's 17 oscillators keyed by the source's CSS custom-property + /// name, in the source's declaration order. + /// + /// Exposed so `test/constants/spec_parity_test.dart` can assert the whole + /// table against `pulse...oscillators` in the upstream + /// spec, which carries the same names. + @visibleForTesting + Map get oscillators => { + 'bw1': _bw[0], + 'bh1': _bh[0], + 'bx1': _bx[0], + 'by1': _by[0], + 'bw2': _bw[1], + 'bh2': _bh[1], + 'bx2': _bx[1], + 'by2': _by[1], + 'bw3': _bw[2], + 'bh3': _bh[2], + 'bx3': _bx[2], + 'by3': _by[2], + 'bgh': _bgh, + 'bop-tl': _bop[0], + 'bop-tr': _bop[1], + 'bop-bl': _bop[2], + 'bop-br': _bop[3], + }; + /// Samples every oscillator at [t] seconds. PulsePhaseSet sample(double t) => PulsePhaseSet( bw: [for (final o in _bw) o.sample(t)], diff --git a/lib/src/animation/spring_curve.dart b/lib/src/animation/spring_curve.dart new file mode 100644 index 0000000..9c7eecf --- /dev/null +++ b/lib/src/animation/spring_curve.dart @@ -0,0 +1,44 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter/physics.dart'; + +/// The easing curve behind the beam's fade-in / fade-out envelope. +/// +/// The envelope is driven by a real spring simulation rather than a cubic +/// bezier so the beam settles with a little momentum instead of decelerating +/// mechanically. The spring is displaced by 1 with no initial velocity and +/// simulated over `t ∈ [0, 1]`: +/// +/// * mass `1.0` +/// * stiffness `180.0` +/// * damping `20.0` +/// +/// Those give a damping ratio of `ζ = damping / (2·√(mass·stiffness)) ≈ 0.745`, +/// i.e. a lightly under-damped spring: the value rises past its target, peaks +/// at about `1.03` near `t ≈ 0.35`, and oscillates back down. Callers that +/// feed the result to an opacity must clamp it (see `BeamClock.fadeOpacity`). +/// +/// The simulation has not fully settled at `t = 1`, so the residual is +/// distributed linearly across the curve; that keeps `transform(1) == 1` +/// exactly while leaving the shape untouched. +final class FadeSpringCurve extends Curve { + /// Creates the fade envelope curve. The curve is stateless — prefer the + /// shared [instance]. + const FadeSpringCurve(); + + /// The shared instance of the curve. + static const FadeSpringCurve instance = FadeSpringCurve(); + + static final SpringSimulation _simulation = SpringSimulation( + // mass, stiffness, damping — see the class doc. + SpringDescription(mass: 1, stiffness: 180, damping: 20), + 0, + 1, + 0, + ); + + /// How far short of 1 the simulation is when `t` reaches 1. + static final double _endCorrection = 1 - _simulation.x(1); + + @override + double transformInternal(double t) => _simulation.x(t) + t * _endCorrection; +} diff --git a/lib/src/beam_sync.dart b/lib/src/beam_sync.dart new file mode 100644 index 0000000..78f6b77 --- /dev/null +++ b/lib/src/beam_sync.dart @@ -0,0 +1,202 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'animation/beam_clock.dart'; +import 'models/beam_options.dart'; + +/// Runs every [BorderBeam] below it off one shared clock, so a group of +/// beams animates in lockstep. +/// +/// A beam normally owns its own [Ticker]. Ten beams on a screen means ten +/// tickers, each started at a different instant, so their sweeps drift apart +/// — fine for beams that have nothing to do with each other, wrong for a row +/// of cards that should read as one system. [BeamSync] hands them a single +/// clock: one ticker for the whole subtree, one timeline, identical phases. +/// +/// ```dart +/// BeamSync( +/// child: Row( +/// children: [ +/// for (final card in cards) +/// BorderBeam.rotate(child: card), +/// ], +/// ), +/// ) +/// ``` +/// +/// Beams stay individually configurable — palette, variant, shape, and +/// `BeamTiming.phaseOffset` are still per beam, so a group can run evenly +/// spaced around the cycle rather than perfectly on top of each other: +/// +/// ```dart +/// BorderBeam.rotate( +/// timing: BeamTiming(phaseOffset: i / cards.length), +/// child: card, +/// ) +/// ``` +/// +/// The group owns playback. A synced beam does not start, stop, pause, or +/// fade on its own, so `BeamPlayback`'s `active`, `autoPlay`, `startAfter`, +/// `duration`, and `repeat` are ignored below a [BeamSync] — use [active] +/// and [speed] here instead. A `BorderBeamController` is playback control +/// for one beam and asserts if it meets a [BeamSync]. +/// +/// Reduced motion is necessarily group-owned too: one shared clock cannot +/// simultaneously be static for one member and run slowly for another. Set +/// [reducedMotion] here; per-beam reduced-motion settings are ignored while +/// they are below a [BeamSync]. +class BeamSync extends StatefulWidget { + /// Creates a scope whose descendant beams share one clock. + const BeamSync({ + super.key, + required this.child, + this.active = true, + this.speed = 1, + this.reducedMotion = BeamReducedMotion.staticFrame, + }); + + /// The subtree whose beams share the clock. + final Widget child; + + /// Whether the group is playing. Toggling fades every beam in (0.6s) or + /// out (0.5s) together. + final bool active; + + /// Playback rate for the group; must be positive. + final double speed; + + /// How the whole group responds to the platform reduced-motion request. + final BeamReducedMotion reducedMotion; + + /// The shared clock for [context], or null when there is no enclosing + /// [BeamSync]. Internal — do not call from application code. + static BeamClock? clockOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.clock; + + /// The reduced-motion policy owned by the nearest group, or null when the + /// beam is not synchronized. + static BeamReducedMotion? reducedMotionOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.reducedMotion; + + @override + State createState() => _BeamSyncState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(FlagProperty('active', value: active, ifFalse: 'stopped')) + ..add(DoubleProperty('speed', speed, defaultValue: 1.0)) + ..add(EnumProperty('reducedMotion', reducedMotion)); + } +} + +class _BeamSyncState extends State + with SingleTickerProviderStateMixin { + late final BeamClock _clock = BeamClock(createTicker: createTicker) + ..speed = widget.speed; + + bool _started = false; + bool _reduced = false; + + void _applySpeed() { + final factor = _reduced && widget.reducedMotion == BeamReducedMotion.slow + ? 0.25 + : 1.0; + _clock.speed = widget.speed * factor; + } + + // Puts the group's clock back in motion. A clock that is mid-fade-out is + // still visible, so resuming it would only carry the fade to invisible — + // activate() is what reverses it, from the opacity it has reached. + void _runClock() { + if (_clock.isVisible && _clock.stage != BeamFadeStage.fadingOut) { + _clock.resume(); + } else { + _clock.activate(); + } + } + + void _applyReducedMotion() { + _applySpeed(); + if (!widget.active) return; + final motion = + !_reduced || + widget.reducedMotion == BeamReducedMotion.animate || + widget.reducedMotion == BeamReducedMotion.slow; + if (motion) { + _runClock(); + return; + } + _clock.showStatic(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Reduced motion is delivered here, and the first frame needs it before + // the clock starts: a group that begins under reduced motion never + // starts a ticker at all. + final reduced = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + if (_started && reduced == _reduced) return; + _reduced = reduced; + if (!_started) { + _started = true; + if (!widget.active) return; + _applyReducedMotion(); + return; + } + _applyReducedMotion(); + } + + @override + void didUpdateWidget(BeamSync oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.speed != oldWidget.speed || + widget.reducedMotion != oldWidget.reducedMotion) { + _applyReducedMotion(); + } + if (widget.active == oldWidget.active) return; + if (widget.active) { + _applyReducedMotion(); + } else { + _clock.deactivate(); + } + } + + @override + void dispose() { + _clock.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BeamSyncScope( + clock: _clock, + reducedMotion: widget.reducedMotion, + child: widget.child, + ); +} + +/// Carries a [BeamSync]'s shared clock down the tree. Internal — read it +/// through [BeamSync.clockOf]. +class BeamSyncScope extends InheritedWidget { + /// Creates the scope for [clock]. + const BeamSyncScope({ + super.key, + required this.clock, + required this.reducedMotion, + required super.child, + }); + + /// The clock every beam in this subtree runs on. + final BeamClock clock; + + /// Group-owned reduced-motion behavior. + final BeamReducedMotion reducedMotion; + + @override + bool updateShouldNotify(BeamSyncScope oldWidget) => + oldWidget.clock != clock || oldWidget.reducedMotion != reducedMotion; +} diff --git a/lib/src/border_beam.dart b/lib/src/border_beam.dart index 3d7c365..c07dc6e 100644 --- a/lib/src/border_beam.dart +++ b/lib/src/border_beam.dart @@ -1,21 +1,32 @@ import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'animation/beam_clock.dart'; import 'animation/beam_phases.dart'; +import 'beam_sync.dart'; import 'border_beam_controller.dart'; +import 'border_beam_theme.dart'; import 'models/beam_colors.dart'; import 'models/beam_config.dart'; +import 'models/beam_options.dart'; +import 'models/beam_playback.dart'; +import 'models/beam_shape.dart'; +import 'models/beam_style.dart'; import 'models/beam_theme.dart'; +import 'models/beam_timing.dart'; import 'models/beam_variant.dart'; +import 'models/model_validation.dart'; import 'painting/beam_painter.dart'; import 'painting/variant_strategy.dart'; /// An animated glow around [child]'s border. /// /// A faithful Flutter port of the border-beam React library. Pick a variant -/// through the named constructors: +/// through the named constructors — or pass one to the generic constructor +/// when the choice is made at runtime: /// /// - [BorderBeam.rotate] — full border traveling beam (cards, surfaces). /// - [BorderBeam.small] — compact traveling beam (buttons, icons). @@ -31,19 +42,37 @@ import 'painting/variant_strategy.dart'; /// ) /// ``` /// +/// Everything else is grouped into four value objects — [style], [shape], +/// [timing], and [playback] — whose fields are all nullable: +/// +/// ```dart +/// BorderBeam( +/// variant: variant, +/// style: const BeamStyle(colors: BeamColors.sunset, strength: 0.8), +/// shape: const BeamShape.all(24, superellipse: true), +/// timing: const BeamTiming(cycleGap: Duration(seconds: 1)), +/// child: card, +/// ) +/// ``` +/// +/// A field left null falls through to the nearest [BorderBeamTheme], then to +/// the variant's own preset — so app-wide defaults live in one place. +/// /// The beam layers are purely decorative: they never intercept pointer /// events and only paint — [child] is laid out and hit-tested normally. /// /// ## Scheduling /// -/// Without a [controller], the beam plays by itself: [autoPlay] starts it -/// (after [startAfter], if given) and [duration] bounds the total play time -/// (null loops forever). Toggling [active] fades the beam in (0.6s) and out -/// (0.5s) with spring-eased envelopes; [onActivate]/[onDeactivate] fire when -/// the fades complete. +/// Without a [controller], the beam plays by itself: `playback.autoPlay` +/// starts it (after `playback.startAfter`, if given) and `playback.duration` +/// bounds the total play time (null loops forever). Toggling [active] fades +/// the beam in (0.6s) and out (0.5s) with spring-eased envelopes; +/// [onActivate]/[onDeactivate] fire when the fades complete. /// /// With a [BorderBeamController] attached, the controller owns playback -/// exclusively and [startAfter]/[duration] must not be set. +/// exclusively: `playback.startAfter` and `playback.duration` must not be set +/// (on the widget or on a [BorderBeamTheme]), and the controller's own +/// `speed` replaces `timing.speed`. /// /// ## pulse-outside requirements /// @@ -52,42 +81,80 @@ import 'painting/variant_strategy.dart'; /// should carry its own 1px border for a defined idle edge, and needs /// clip-free room around it (padding in the parent; no tight [ClipRRect]). class BorderBeam extends StatefulWidget { - const BorderBeam._({ + /// Creates a beam of any [variant], for when the variant is picked at + /// runtime. The named constructors are the readable form when it is not. + const BorderBeam({ super.key, required this.variant, required this.child, - this.colors = BeamColors.colorful, - this.theme = BeamTheme.auto, - this.strength = 1, - this.active = true, + this.colors, + this.active, this.borderRadius, - this.useSuperellipse = false, - this.borderWidth, - this.brightness, - this.saturation, - this.hueRange = 30, - this.hueBase = 0, - this.staticColors = false, - this.respectReducedMotion = true, - this.cycleDuration, + this.style, + this.shape, + this.timing, + this.playback, this.controller, - this.startAfter, - this.duration, - this.autoPlay = true, + this.progress, + this.follow, + this.strengthListenable, + this.speedListenable, this.onActivate, this.onDeactivate, - this.strokeOpacityFactor = 1, - this.innerOpacityFactor = 1, - this.bloomOpacityFactor = 1, - this.glowBoost = 1, - this.coreBlur, - this.bloomBlur, - this.glowBrightness, - this.glowSaturation, - }) : assert( - controller == null || (startAfter == null && duration == null), - 'When a BorderBeamController is attached it owns playback: ' - 'startAfter and duration must not be set.', + }); + + /// Paints the beam with no child of its own, sized by its parent. + /// + /// The beam is the whole widget: drop it into a [Stack] under a + /// [Positioned.fill] and it traces the stack's bounds, over content it + /// does not have to wrap. Everything else behaves exactly as the generic + /// constructor. + /// + /// ```dart + /// Stack( + /// children: [ + /// content, + /// const Positioned.fill( + /// child: BorderBeam.overlay(borderRadius: 16), + /// ), + /// ], + /// ) + /// ``` + const BorderBeam.overlay({ + Key? key, + BeamVariant variant = BeamVariant.rotate, + BeamColors? colors, + bool? active, + double? borderRadius, + BeamStyle? style, + BeamShape? shape, + BeamTiming? timing, + BeamPlayback? playback, + BorderBeamController? controller, + double? progress, + Offset? follow, + ValueListenable? strengthListenable, + ValueListenable? speedListenable, + VoidCallback? onActivate, + VoidCallback? onDeactivate, + }) : this( + key: key, + variant: variant, + child: const SizedBox.expand(), + colors: colors, + active: active, + borderRadius: borderRadius, + style: style, + shape: shape, + timing: timing, + playback: playback, + controller: controller, + progress: progress, + follow: follow, + strengthListenable: strengthListenable, + speedListenable: speedListenable, + onActivate: onActivate, + onDeactivate: onDeactivate, ); /// Full border traveling beam (React `md`). The default look, tuned for @@ -95,56 +162,38 @@ class BorderBeam extends StatefulWidget { const BorderBeam.rotate({ Key? key, required Widget child, - BeamColors colors = BeamColors.colorful, - BeamTheme theme = BeamTheme.auto, - double strength = 1, - bool active = true, + BeamColors? colors, + bool? active, double? borderRadius, - bool useSuperellipse = false, - double? borderWidth, - double? brightness, - double? saturation, - double hueRange = 30, - double hueBase = 0, - bool staticColors = false, - bool respectReducedMotion = true, - Duration? cycleDuration, + BeamStyle? style, + BeamShape? shape, + BeamTiming? timing, + BeamPlayback? playback, BorderBeamController? controller, - Duration? startAfter, - Duration? duration, - bool autoPlay = true, + double? progress, + Offset? follow, + ValueListenable? strengthListenable, + ValueListenable? speedListenable, VoidCallback? onActivate, VoidCallback? onDeactivate, - double strokeOpacityFactor = 1, - double innerOpacityFactor = 1, - double bloomOpacityFactor = 1, - }) : this._( + }) : this( key: key, variant: BeamVariant.rotate, child: child, colors: colors, - theme: theme, - strength: strength, active: active, borderRadius: borderRadius, - useSuperellipse: useSuperellipse, - borderWidth: borderWidth, - brightness: brightness, - saturation: saturation, - hueRange: hueRange, - hueBase: hueBase, - staticColors: staticColors, - respectReducedMotion: respectReducedMotion, - cycleDuration: cycleDuration, + style: style, + shape: shape, + timing: timing, + playback: playback, controller: controller, - startAfter: startAfter, - duration: duration, - autoPlay: autoPlay, + progress: progress, + follow: follow, + strengthListenable: strengthListenable, + speedListenable: speedListenable, onActivate: onActivate, onDeactivate: onDeactivate, - strokeOpacityFactor: strokeOpacityFactor, - innerOpacityFactor: innerOpacityFactor, - bloomOpacityFactor: bloomOpacityFactor, ); /// Compact traveling beam for small elements (React `sm`) — icon buttons, @@ -152,56 +201,38 @@ class BorderBeam extends StatefulWidget { const BorderBeam.small({ Key? key, required Widget child, - BeamColors colors = BeamColors.colorful, - BeamTheme theme = BeamTheme.auto, - double strength = 1, - bool active = true, + BeamColors? colors, + bool? active, double? borderRadius, - bool useSuperellipse = false, - double? borderWidth, - double? brightness, - double? saturation, - double hueRange = 30, - double hueBase = 0, - bool staticColors = false, - bool respectReducedMotion = true, - Duration? cycleDuration, + BeamStyle? style, + BeamShape? shape, + BeamTiming? timing, + BeamPlayback? playback, BorderBeamController? controller, - Duration? startAfter, - Duration? duration, - bool autoPlay = true, + double? progress, + Offset? follow, + ValueListenable? strengthListenable, + ValueListenable? speedListenable, VoidCallback? onActivate, VoidCallback? onDeactivate, - double strokeOpacityFactor = 1, - double innerOpacityFactor = 1, - double bloomOpacityFactor = 1, - }) : this._( + }) : this( key: key, variant: BeamVariant.small, child: child, colors: colors, - theme: theme, - strength: strength, active: active, borderRadius: borderRadius, - useSuperellipse: useSuperellipse, - borderWidth: borderWidth, - brightness: brightness, - saturation: saturation, - hueRange: hueRange, - hueBase: hueBase, - staticColors: staticColors, - respectReducedMotion: respectReducedMotion, - cycleDuration: cycleDuration, + style: style, + shape: shape, + timing: timing, + playback: playback, controller: controller, - startAfter: startAfter, - duration: duration, - autoPlay: autoPlay, + progress: progress, + follow: follow, + strengthListenable: strengthListenable, + speedListenable: speedListenable, onActivate: onActivate, onDeactivate: onDeactivate, - strokeOpacityFactor: strokeOpacityFactor, - innerOpacityFactor: innerOpacityFactor, - bloomOpacityFactor: bloomOpacityFactor, ); /// Bottom-edge traveling beam (React `line`) — search bars, text inputs. @@ -209,56 +240,38 @@ class BorderBeam extends StatefulWidget { const BorderBeam.line({ Key? key, required Widget child, - BeamColors colors = BeamColors.colorful, - BeamTheme theme = BeamTheme.auto, - double strength = 1, - bool active = true, + BeamColors? colors, + bool? active, double? borderRadius, - bool useSuperellipse = false, - double? borderWidth, - double? brightness, - double? saturation, - double hueRange = 30, - double hueBase = 0, - bool staticColors = false, - bool respectReducedMotion = true, - Duration? cycleDuration, + BeamStyle? style, + BeamShape? shape, + BeamTiming? timing, + BeamPlayback? playback, BorderBeamController? controller, - Duration? startAfter, - Duration? duration, - bool autoPlay = true, + double? progress, + Offset? follow, + ValueListenable? strengthListenable, + ValueListenable? speedListenable, VoidCallback? onActivate, VoidCallback? onDeactivate, - double strokeOpacityFactor = 1, - double innerOpacityFactor = 1, - double bloomOpacityFactor = 1, - }) : this._( + }) : this( key: key, variant: BeamVariant.line, child: child, colors: colors, - theme: theme, - strength: strength, active: active, borderRadius: borderRadius, - useSuperellipse: useSuperellipse, - borderWidth: borderWidth, - brightness: brightness, - saturation: saturation, - hueRange: hueRange, - hueBase: hueBase, - staticColors: staticColors, - respectReducedMotion: respectReducedMotion, - cycleDuration: cycleDuration, + style: style, + shape: shape, + timing: timing, + playback: playback, controller: controller, - startAfter: startAfter, - duration: duration, - autoPlay: autoPlay, + progress: progress, + follow: follow, + strengthListenable: strengthListenable, + speedListenable: speedListenable, onActivate: onActivate, onDeactivate: onDeactivate, - strokeOpacityFactor: strokeOpacityFactor, - innerOpacityFactor: innerOpacityFactor, - bloomOpacityFactor: bloomOpacityFactor, ); /// Contained breathing glow (React `pulse-inner`) — working states, @@ -266,125 +279,81 @@ class BorderBeam extends StatefulWidget { const BorderBeam.pulseInside({ Key? key, required Widget child, - BeamColors colors = BeamColors.colorful, - BeamTheme theme = BeamTheme.auto, - double strength = 1, - bool active = true, + BeamColors? colors, + bool? active, double? borderRadius, - bool useSuperellipse = false, - double? borderWidth, - double? brightness, - double? saturation, - double hueBase = 0, - bool staticColors = false, - bool respectReducedMotion = true, - Duration? cycleDuration, + BeamStyle? style, + BeamShape? shape, + BeamTiming? timing, + BeamPlayback? playback, BorderBeamController? controller, - Duration? startAfter, - Duration? duration, - bool autoPlay = true, + double? progress, + Offset? follow, + ValueListenable? strengthListenable, + ValueListenable? speedListenable, VoidCallback? onActivate, VoidCallback? onDeactivate, - double strokeOpacityFactor = 1, - double innerOpacityFactor = 1, - double bloomOpacityFactor = 1, - double glowBoost = 1, - }) : this._( + }) : this( key: key, variant: BeamVariant.pulseInside, child: child, colors: colors, - theme: theme, - strength: strength, active: active, borderRadius: borderRadius, - useSuperellipse: useSuperellipse, - borderWidth: borderWidth, - brightness: brightness, - saturation: saturation, - hueBase: hueBase, - staticColors: staticColors, - respectReducedMotion: respectReducedMotion, - cycleDuration: cycleDuration, + style: style, + shape: shape, + timing: timing, + playback: playback, controller: controller, - startAfter: startAfter, - duration: duration, - autoPlay: autoPlay, + progress: progress, + follow: follow, + strengthListenable: strengthListenable, + speedListenable: speedListenable, onActivate: onActivate, onDeactivate: onDeactivate, - strokeOpacityFactor: strokeOpacityFactor, - innerOpacityFactor: innerOpacityFactor, - bloomOpacityFactor: bloomOpacityFactor, - glowBoost: glowBoost, ); /// Outward-blooming breathing halo (React `pulse-outside`). /// /// The glow paints behind and outside the child — see the class docs for - /// the opaque-child / border / overflow-room requirements. [coreBlur], - /// [bloomBlur], [glowBrightness], and [glowSaturation] port the source's - /// consumer tuning hooks. + /// the opaque-child / border / overflow-room requirements. `BeamStyle`'s + /// `coreBlur`, `bloomBlur`, `glowBrightness`, and `glowSaturation` port the + /// source's consumer tuning hooks for this variant. const BorderBeam.pulseOutside({ Key? key, required Widget child, - BeamColors colors = BeamColors.colorful, - BeamTheme theme = BeamTheme.auto, - double strength = 1, - bool active = true, + BeamColors? colors, + bool? active, double? borderRadius, - bool useSuperellipse = false, - double? borderWidth, - double? brightness, - double? saturation, - double hueBase = 0, - bool staticColors = false, - bool respectReducedMotion = true, - Duration? cycleDuration, + BeamStyle? style, + BeamShape? shape, + BeamTiming? timing, + BeamPlayback? playback, BorderBeamController? controller, - Duration? startAfter, - Duration? duration, - bool autoPlay = true, + double? progress, + Offset? follow, + ValueListenable? strengthListenable, + ValueListenable? speedListenable, VoidCallback? onActivate, VoidCallback? onDeactivate, - double strokeOpacityFactor = 1, - double innerOpacityFactor = 1, - double bloomOpacityFactor = 1, - double glowBoost = 1, - double? coreBlur, - double? bloomBlur, - double? glowBrightness, - double? glowSaturation, - }) : this._( + }) : this( key: key, variant: BeamVariant.pulseOutside, child: child, colors: colors, - theme: theme, - strength: strength, active: active, borderRadius: borderRadius, - useSuperellipse: useSuperellipse, - borderWidth: borderWidth, - brightness: brightness, - saturation: saturation, - hueBase: hueBase, - staticColors: staticColors, - respectReducedMotion: respectReducedMotion, - cycleDuration: cycleDuration, + style: style, + shape: shape, + timing: timing, + playback: playback, controller: controller, - startAfter: startAfter, - duration: duration, - autoPlay: autoPlay, + progress: progress, + follow: follow, + strengthListenable: strengthListenable, + speedListenable: speedListenable, onActivate: onActivate, onDeactivate: onDeactivate, - strokeOpacityFactor: strokeOpacityFactor, - innerOpacityFactor: innerOpacityFactor, - bloomOpacityFactor: bloomOpacityFactor, - glowBoost: glowBoost, - coreBlur: coreBlur, - bloomBlur: bloomBlur, - glowBrightness: glowBrightness, - glowSaturation: glowSaturation, ); /// Which effect this beam paints. @@ -393,69 +362,83 @@ class BorderBeam extends StatefulWidget { /// The wrapped content. Laid out and hit-tested normally. final Widget child; - /// Color scheme: a preset, [BeamColors.custom], or [BeamColors.spec]. - final BeamColors colors; + /// Shorthand for `style.colors`; a non-null value here wins over it. + final BeamColors? colors; - /// Background adaptation: dark, light, or follow the ambient theme. - final BeamTheme theme; + /// Shorthand for `playback.active`; a non-null value here wins over it. + final bool? active; - /// Effect opacity 0–1 (clamped). Scales only the beam layers. - final double strength; - - /// Declarative play state: toggling fades the beam in/out. Ignored when a - /// [controller] is attached. - final bool active; - - /// Corner radius in logical px; null uses the variant preset (16, or 32 - /// for [BeamVariant.small]). Match your child's decoration radius. + /// Shorthand for a uniform `shape.radius` in logical px; a non-null value + /// here wins over it. Match your child's decoration radius. final double? borderRadius; - /// Shape the beam as a rounded superellipse (Apple-style squircle) instead - /// of a circular-arc rounded rectangle. - final bool useSuperellipse; - - /// Stroke ring thickness in logical px (default 1, as in the source). - final double? borderWidth; + /// Colors, theme adaptation, and every filter/opacity tuning hook. + final BeamStyle? style; - /// Glow brightness multiplier; null uses the variant/theme preset. - final double? brightness; + /// Corner radii, ring width, and corner family. + final BeamShape? shape; - /// Glow saturation multiplier; null uses the variant/theme preset. - final double? saturation; + /// Cycle length, rest between sweeps, playback rate, and hue periods. + final BeamTiming? timing; - /// Hue animation amplitude in degrees (default 30; the line variant caps - /// it at 13). Not used by pulse variants, whose hue cycles continuously. - final double hueRange; - - /// Static hue offset in degrees added to the whole palette. - final double hueBase; - - /// Disables the hue animation. Forced on by [BeamColors.mono]. - final bool staticColors; - - /// When true (default), honors [MediaQuery.disableAnimationsOf] by - /// painting a single static frame instead of animating. - final bool respectReducedMotion; - - /// Length of one animation cycle; null uses the variant default - /// (1.96s rotate/small, 3.1s line, 2.3s pulse). - final Duration? cycleDuration; + /// Play state and scheduling. Ignored while a [controller] is attached. + final BeamPlayback? playback; /// Optional playback controller. When set it owns playback exclusively — - /// [startAfter] and [duration] must be null and [active]/[autoPlay] are - /// ignored. + /// `playback.startAfter`/`duration` must be null and [active] is ignored. final BorderBeamController? controller; - /// Delay before autoplay starts. Only without a [controller]. - final Duration? startAfter; + /// Drives the beam's travel from a value instead of the clock, 0–1. + /// + /// The sweep sits where this says rather than running with time, which + /// turns the traveling variants into readouts: [BorderBeam.rotate] becomes + /// a glowing progress ring, [BorderBeam.line] a progress bar. The clock + /// keeps running underneath — the fade envelope, the hue shift, and the + /// line variant's breathe and spike tracks are all still alive, so the + /// beam looks lit rather than frozen. + /// + /// Changing it repaints without re-resolving the beam's configuration, so + /// it is cheap to drive from an animation. Null hands the travel back to + /// the clock; the pulse variants have no travel to drive. + final double? progress; - /// Total play time before the beam fades out by itself; null plays - /// forever. Only without a [controller]. - final Duration? duration; + /// Pulls the traveling beam toward a point in the child's box, given in + /// normalized coordinates (0–1 on each axis). + /// + /// The beam leaves its schedule and eases to the perimeter position + /// nearest the point — critically damped, ~150ms — which reads as the glow + /// following the pointer. Setting it back to null hands the sweep back to + /// the clock from wherever it is, without a snap. + /// + /// Feed it from a [MouseRegion] or [Listener]: + /// + /// ```dart + /// MouseRegion( + /// onHover: (e) => setState(() { + /// final box = context.findRenderObject()! as RenderBox; + /// final local = box.globalToLocal(e.position); + /// _follow = Offset(local.dx / box.size.width, local.dy / box.size.height); + /// }), + /// onExit: (_) => setState(() => _follow = null), + /// child: BorderBeam.rotate(follow: _follow, child: card), + /// ) + /// ``` + /// + /// [progress] wins over it, and the pulse variants ignore both. + final Offset? follow; - /// Whether the beam starts by itself (default true). Only without a - /// [controller]. - final bool autoPlay; + /// Scales every layer's opacity each frame, without rebuilding. + /// + /// The live twin of `BeamStyle.strength`: point it at a mic level, a + /// download rate, or any other signal and the beam breathes with it. Values + /// above 1 brighten up to the clamp every layer opacity already carries. + final ValueListenable? strengthListenable; + + /// Drives the playback rate each frame, without rebuilding. + /// + /// The live twin of `BeamTiming.speed`, and it wins over both that and a + /// [controller]'s rate while it is set. Values must stay positive. + final ValueListenable? speedListenable; /// Called when the fade-in completes. final VoidCallback? onActivate; @@ -463,92 +446,573 @@ class BorderBeam extends StatefulWidget { /// Called when the fade-out completes. final VoidCallback? onDeactivate; - /// Stroke ring opacity multiplier (React `--beam-stroke-opacity`). - final double strokeOpacityFactor; + // The widget's own style/shape/playback, with the shorthands folded in. + BeamStyle? get _styleInput => colors == null + ? style + : (style ?? const BeamStyle()).copyWith(colors: colors); - /// Inner glow opacity multiplier (React `--beam-inner-opacity`). - final double innerOpacityFactor; + BeamShape? get _shapeInput => borderRadius == null + ? shape + : (shape ?? const BeamShape()).copyWith( + radius: BorderRadius.circular(borderRadius!), + ); - /// Bloom opacity multiplier (React `--beam-bloom-opacity`). - final double bloomOpacityFactor; + BeamPlayback? get _playbackInput => active == null + ? playback + : (playback ?? const BeamPlayback()).copyWith(active: active); - /// Pulse glow prominence multiplier (React `--pulse-glow-boost`). - final double glowBoost; + @override + State createState() => _BorderBeamState(); - /// pulse-outside core glow blur override in px (React `--beam-core-blur`). - final double? coreBlur; + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('variant', variant)) + ..add(DiagnosticsProperty('style', _styleInput)) + ..add(DiagnosticsProperty('shape', _shapeInput)) + ..add(DiagnosticsProperty('timing', timing)) + ..add(DiagnosticsProperty('playback', _playbackInput)) + ..add( + DiagnosticsProperty( + 'controller', + controller, + defaultValue: null, + ), + ) + ..add(DoubleProperty('progress', progress, defaultValue: null)) + ..add(DiagnosticsProperty('follow', follow, defaultValue: null)) + ..add( + DiagnosticsProperty>( + 'strengthListenable', + strengthListenable, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty>( + 'speedListenable', + speedListenable, + defaultValue: null, + ), + ); + } +} - /// pulse-outside halo blur override in px (React `--beam-bloom-blur`). - final double? bloomBlur; +// The cycle length a resolved timing implies for a variant. +double _cycleSecondsOf(BeamTiming timing, BeamVariant variant) => + (timing.cycle ?? variant.defaultCycleDuration).inMicroseconds / + Duration.microsecondsPerSecond; - /// pulse-outside glow brightness override - /// (React `--beam-glow-brightness`). - final double? glowBrightness; +// The timing fields the painted config is built from — everything except +// `speed`, which is applied to the clock and never reaches BeamConfig. +// Keeping it out of the cache key is what lets a rate change ride through +// without re-resolving the config or rebuilding the phase resolver. +BeamTiming _configTiming(BeamTiming timing) => BeamTiming( + cycle: timing.cycle, + cycleGap: timing.cycleGap, + direction: timing.direction, + phaseOffset: timing.phaseOffset, + beamCount: timing.beamCount, + huePeriod: timing.huePeriod, + bloomHuePeriod: timing.bloomHuePeriod, + breatheFactor: timing.breatheFactor, + spikeFactor: timing.spikeFactor, + spike2Factor: timing.spike2Factor, +); - /// pulse-outside glow saturation override (React `--beam-glow-saturate`). - final double? glowSaturation; +// BeamReducedMotion.slow runs the clock at a quarter rate. +const double _slowMotionFactor = 0.25; - @override - State createState() => _BorderBeamState(); +// How far outside the viewport a beam keeps running before its clock is +// paused, matching the root margin the source's IntersectionObserver uses. +const double _offscreenMarginPx = 256; + +// How long the follow easing takes to cover most of the distance to the +// pointer. Short enough to feel attached, long enough to smooth a jittery +// pointer stream. +const double _followResponseSeconds = 0.15; + +// A critically damped step toward [target]: no overshoot, no ringing, and it +// carries velocity, so a target that keeps moving never produces a corner. +({double value, double velocity}) _smoothDamp({ + required double current, + required double target, + required double velocity, + required double dt, + required double response, +}) { + final omega = 2 / response; + final x = omega * dt; + // Padé approximation of exp(-x) — stable at any frame duration. + final decay = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x); + final change = current - target; + final temp = (velocity + omega * change) * dt; + return ( + value: target + (change + temp) * decay, + velocity: (velocity - omega * temp) * decay, + ); } // TickerProviderStateMixin (not Single-): a variant change rebuilds the // clock, creating a second ticker over this State's lifetime. class _BorderBeamState extends State with TickerProviderStateMixin { - late BeamClock _clock; + // Exactly one of these is set: a beam owns a clock unless a BeamSync hands + // it the group's. + BeamClock? _ownClock; + BeamClock? _sharedClock; Timer? _startTimer; - Timer? _durationTimer; + double? _durationStartedAt; BeamConfig? _config; BeamPhaseResolver? _resolver; Object? _configKey; + // Timing and playback drive scheduling, so they are resolved whenever the + // widget or its dependencies change rather than at paint time. + BeamTiming _timing = const BeamTiming(); + BeamPlayback _playback = const BeamPlayback(); + double _cycleSeconds = 0; + + // The sweep position when something other than the clock drives it. It + // reaches the painter as a listenable so `progress` and `follow` move the + // beam without rebuilding the config. + final ValueNotifier _driven = ValueNotifier(null); + double? _followValue; + double _followVelocity = 0; + double _lastTickSeconds = 0; + BeamClock? _listeningTo; + BeamClock? _resetBoundTo; + + BeamClock get _clock => _sharedClock ?? _ownClock!; + + /// Whether this beam runs on a [BeamSync] group clock. + bool get _synced => _sharedClock != null; + BeamVariantStrategy get _strategy => strategyFor(widget.variant); + bool get _active => _playback.active ?? true; + bool get _autoPlay => _playback.autoPlay ?? true; + + // A frozen beam paints one instant of its timeline forever, so its clock + // is never started at all. + bool get _frozen => _playback.debugFrozenAt != null; + bool _autoPlayScheduled = false; + bool _hasStarted = false; + BeamReducedMotion? _reducedApplied; + // Set only when reduced motion paused the clock, so turning reduced motion + // back off never overrides a pause the controller asked for. + bool _pausedForReducedMotion = false; + // The same idea for the offscreen pause: set only when this beam stopped + // the clock because nobody could see it, so resuming never overrides a + // pause a controller or reduced motion asked for. + bool _pausedForOffscreen = false; + ScrollableState? _scrollable; + ScrollPosition? _scrollPosition; + bool _offscreenCheckScheduled = false; + double _hueTimeOffset = 0; + double _travelTimeOffset = 0; + double _breatheTimeOffset = 0; @override void initState() { super.initState(); - _createClock(); + _createOwnClock(); + // The theme is not reachable yet; the widget's own values carry the beam + // until didChangeDependencies resolves them properly, before first build. + _timing = widget.timing ?? const BeamTiming(); + _playback = widget._playbackInput ?? const BeamPlayback(); + validateBeamTiming(_timing); + validateRepeat(_playback.repeat); + _cycleSeconds = _cycleSecondsOf(_timing, widget.variant); + _applySpeed(); + _applyFadeCurve(); widget.controller?.attach(_clock); + widget.speedListenable?.addListener(_applySpeed); + } + + // Resolves the scheduling inputs against the enclosing BorderBeamTheme; + // widget-level values (shorthands folded in) win over theme ones. + void _resolveScheduling({required bool retime}) { + final data = BorderBeamTheme.of(context); + final playback = (data.playback ?? const BeamPlayback()).merge( + widget._playbackInput, + ); + assert( + widget.controller == null || + (playback.startAfter == null && playback.duration == null), + 'When a BorderBeamController is attached it owns playback: startAfter ' + 'and duration must not be set, on the widget or on a BorderBeamTheme.', + ); + final timing = (data.timing ?? const BeamTiming()).merge(widget.timing); + validateBeamTiming(timing); + validateRepeat(playback.repeat); + final previousCycle = _cycleSeconds; + final previousGap = + (_timing.cycleGap ?? Duration.zero).inMicroseconds / + Duration.microsecondsPerSecond; + final previousPhase = _timing.phaseOffset ?? 0; + _playback = playback; + _timing = timing; + _cycleSeconds = _cycleSecondsOf(timing, widget.variant); + final nextGap = + (timing.cycleGap ?? Duration.zero).inMicroseconds / + Duration.microsecondsPerSecond; + if (retime) { + _retimeToNewCycle( + previousCycle, + _cycleSeconds, + oldGap: previousGap, + newGap: nextGap, + oldPhase: previousPhase, + newPhase: timing.phaseOffset ?? 0, + ); + } + _applySpeed(); + _applyFadeCurve(); + _syncResolverPlayback(); + _syncTickListener(); + _syncScrollListener(); + } + + // A group clock's fade belongs to its BeamSync, not to one member. + void _applyFadeCurve() { + if (_synced) return; + _clock.fadeCurve = _playback.fadeCurve; + } + + // The rate the clock runs at: a live speedListenable first, then the + // controller that owns playback, then the resolved timing — scaled down + // while reduced motion asks for slow motion. A synced beam leaves the + // group's rate to its BeamSync. + void _applySpeed() { + if (_synced) return; + final base = + widget.speedListenable?.value ?? + widget.controller?.speed ?? + _timing.speed ?? + 1; + final factor = _reducedApplied == BeamReducedMotion.slow + ? _slowMotionFactor + : 1.0; + _clock.speed = base * factor; + } + + void _scheduleAutoplayStart() { + _startTimer?.cancel(); + _startTimer = null; + if (_synced || + widget.controller != null || + !_autoPlay || + !_active || + _frozen || + _clock.isVisible) { + return; + } + final delay = _playback.startAfter; + if (delay == null) { + _start(); + return; + } + _startTimer = Timer(delay, () { + _startTimer = null; + if (!mounted || + _synced || + widget.controller != null || + !_autoPlay || + !_active) { + return; + } + _start(); + }); + } + + void _applyReducedMotionChange() { + final reduced = _reduced; + if (reduced == _reducedApplied) return; + _reducedApplied = reduced; + _applySpeed(); + // A group clock follows BeamSync's group-level policy. + if (_synced) return; + if (_motionFrozen) { + _pauseForReducedMotion(); + } else { + _leaveReducedMotion(); + } + } + + void _applySchedulingTransition( + BeamPlayback previous, { + required bool controllerChanged, + }) { + final wasActive = previous.active ?? true; + final wasAutoPlay = previous.autoPlay ?? true; + final activeChanged = wasActive != _active; + + if (_synced || widget.controller != null) { + _startTimer?.cancel(); + _startTimer = null; + _clearDurationBudget(); + return; + } + + if (activeChanged) { + _startTimer?.cancel(); + _startTimer = null; + if (_active) { + _start(); + } else { + _clearDurationBudget(); + _clock.deactivate(); + } + return; + } + + final startScheduleChanged = + wasAutoPlay != _autoPlay || + previous.startAfter != _playback.startAfter || + controllerChanged; + if (startScheduleChanged && !_hasStarted) { + if (_autoPlay) { + _scheduleAutoplayStart(); + } else { + _startTimer?.cancel(); + _startTimer = null; + } + } + + if (previous.duration != _playback.duration) { + if (_clock.isVisible && _playback.duration != null) { + _armDurationBudget(); + } else { + _clearDurationBudget(); + } + } } @override void didChangeDependencies() { super.didChangeDependencies(); // Autoplay needs MediaQuery (reduced motion), so it can't run from - // initState. - if (_autoPlayScheduled) return; - _autoPlayScheduled = true; - if (widget.controller == null && widget.autoPlay && widget.active) { - if (widget.startAfter != null) { - _startTimer = Timer(widget.startAfter!, _start); - } else { - _start(); + // initState. Reduced motion is also tracked here, where a change to it + // is delivered, as is the enclosing BeamSync. + final first = !_autoPlayScheduled; + final previousPlayback = _playback; + _adoptSharedClock(BeamSync.clockOf(context)); + _resolveScheduling(retime: !first); + if (first) { + _autoPlayScheduled = true; + _reducedApplied = _reduced; + _applySpeed(); + _scheduleAutoplayStart(); + return; + } + _applyReducedMotionChange(); + _applySchedulingTransition(previousPlayback, controllerChanged: false); + } + + // Moves this beam onto (or off) a BeamSync group clock. + void _adoptSharedClock(BeamClock? shared) { + if (shared == _sharedClock) return; + assert( + shared == null || widget.controller == null, + 'A BorderBeam under a BeamSync runs on the group clock, so it cannot ' + 'also take a BorderBeamController — a controller owns a clock of its ' + 'own. Drive the group through BeamSync, or move the beam out of it.', + ); + _detachTickListener(); + _detachResetListener(); + if (shared != null) { + _startTimer?.cancel(); + _startTimer = null; + _durationStartedAt = null; + final own = _ownClock; + _ownClock = null; + _sharedClock = shared; + own?.dispose(); + } else { + _sharedClock = null; + _createOwnClock(); + _applySpeed(); + _applyFadeCurve(); + if (widget.controller == null && _autoPlay && _active) _start(); + } + _syncResetListener(); + _syncTickListener(); + } + + void _pauseForReducedMotion() { + if (!_clock.isRunning) return; + _clock.pause(); + _pausedForReducedMotion = true; + } + + void _leaveReducedMotion() { + if (_pausedForReducedMotion) { + _pausedForReducedMotion = false; + if (_clock.isVisible) { + _clock.resume(); + // Motion is allowed again, but visibility may not be — re-check + // rather than leaving an offscreen beam ticking. + _scheduleOffscreenCheck(); + return; } } + // The beam never got to start: reduced motion was on when autoplay ran. + // A beam that has already played is left alone — its duration may have + // run out. startAfter belongs to that first autoplay only, so it is + // honored while its timer is pending and skipped once it has fired. + if (_hasStarted || widget.controller != null || !_autoPlay || !_active) { + return; + } + if (_clock.isVisible || (_startTimer?.isActive ?? false)) return; + _start(); } - void _createClock() { - _clock = BeamClock( + void _createOwnClock() { + _ownClock = BeamClock( createTicker: createTicker, maxFps: _strategy.preferredFps, onFadeComplete: _onFadeComplete, ); + // A replacement clock starts its own timeline at zero, so it takes the + // reset listener and drops every correction that described the old one. + _syncResetListener(); + _onTimelineReset(); } void _start() { - if (_reducedMotion) return; + if (_motionFrozen || _synced || _frozen) return; + _hasStarted = true; _clock.activate(); - _armDurationTimer(); + _armDurationBudget(); + } + + // Every offset below is a correction measured against the running + // timeline: the hue shift a retime accumulated, the line tracks' rescale + // correction, and a `follow` hand-back. A restart puts elapsed time back + // to zero, which leaves all of them describing a timeline that no longer + // exists — so the clock tells us, and we drop them. A mid-fade-out + // re-activation keeps its timeline, and so keeps the corrections. + void _onTimelineReset() { + _setHueTimeOffset(0); + _setBreatheTimeOffset(0); + _setTravelTimeOffset(0); + } + + void _syncResetListener() { + if (_resetBoundTo == _clock) return; + _detachResetListener(); + _resetBoundTo = _clock..addTimelineResetListener(_onTimelineReset); + } + + void _detachResetListener() { + _resetBoundTo?.removeTimelineResetListener(_onTimelineReset); + _resetBoundTo = null; + } + + void _setHueTimeOffset(double seconds) { + _hueTimeOffset = seconds; + _resolver?.hueTimeOffset = seconds; } - void _armDurationTimer() { - _durationTimer?.cancel(); - if (widget.controller == null && widget.duration != null) { - _durationTimer = Timer(widget.duration!, _clock.deactivate); + void _setTravelTimeOffset(double seconds) { + _travelTimeOffset = seconds; + _resolver?.travelTimeOffset = seconds; + } + + void _setBreatheTimeOffset(double seconds) { + _breatheTimeOffset = seconds; + _resolver?.breatheTimeOffset = seconds; + } + + // A cycle-duration change mid-run must not snap the beam. Rescaling + // elapsed time by the cycle ratio holds every cycle-derived track at its + // current fraction; shifting the hue clock back by the same amount holds + // the fixed-period hue tracks, which do not scale with the cycle. A + // cycle-gap change needs none of this: the sweep keeps its position and the + // rest simply appears at the next cycle end. + void _retimeToNewCycle( + double oldCycle, + double newCycle, { + required double oldGap, + required double newGap, + required double oldPhase, + required double newPhase, + }) { + if (!_clock.isVisible || oldCycle <= 0 || oldCycle == newCycle) return; + if (widget.variant.isPulse) { + oldGap = 0; + newGap = 0; + } + final before = _clock.elapsedSeconds; + final oldPeriod = oldCycle + oldGap; + final newPeriod = newCycle + newGap; + // The resolver samples the shifted timeline, not the clock's raw elapsed + // time, so sweep-or-gap has to be decided there: an offset beam resting + // in the gap sits at a different point of the period than its elapsed + // time alone says, and segmenting the raw timeline would drop it into + // the middle of the sweep. + final travel = _travelTimeOffset; + final shifted = before + oldPhase * oldCycle + travel; + final completed = oldPeriod <= 0 ? 0 : (shifted / oldPeriod).floor(); + final local = oldPeriod <= 0 ? shifted : shifted - completed * oldPeriod; + final newLocal = local < oldCycle || oldGap <= 0 + ? local * newCycle / oldCycle + : newCycle + (local - oldCycle) * newGap / oldGap; + // Back out of the shifted timeline: the resolver re-applies the new + // offset and the unchanged travel hand-back on top of what the clock + // reports. + var target = + completed * newPeriod + newLocal - newPhase * newCycle - travel; + // A follow hand-back leaves a negative travel offset behind, so backing + // it out can put the target at or below zero even though the phase it + // names is perfectly valid. Every track the target feeds is periodic, so + // lift it by whole periods into a clock time that exists rather than + // refusing the retime and letting the beam jump. + if (target <= 0 && newPeriod > 0) { + target += newPeriod * (1 + (-target / newPeriod).floor()); + } + final factor = before == 0 ? newCycle / oldCycle : target / before; + if (!factor.isFinite || factor <= 0) return; + _clock.retime(factor); + final after = _clock.elapsedSeconds; + // The fixed-period hue tracks keep the timeline they had. + _setHueTimeOffset(_hueTimeOffset + before - after); + // The line variant's breathe and spike tracks scale with the cycle, so + // what holds their phase is the pure ratio rescale — not the target + // above, which is bent by the gap, the phase offset, the hand-back, and + // any whole-period lift. Put them back on the rescaled timeline. + _setBreatheTimeOffset( + (before + _breatheTimeOffset) * newCycle / oldCycle - after, + ); + } + + void _armDurationBudget() { + final duration = _playback.duration; + if (widget.controller == null && duration != null) { + _durationStartedAt = _clock.activeSeconds; + } else { + _durationStartedAt = null; } + _syncTickListener(); + } + + void _clearDurationBudget() { + _durationStartedAt = null; + _syncTickListener(); + } + + void _checkDurationBudget() { + final startedAt = _durationStartedAt; + final duration = _playback.duration; + if (startedAt == null || duration == null) return; + final allowed = duration.inMicroseconds / Duration.microsecondsPerSecond; + if (_clock.activeSeconds - startedAt < allowed) return; + _durationStartedAt = null; + _clock.deactivate(); + _syncTickListener(); } void _onFadeComplete(bool active) { @@ -559,117 +1023,345 @@ class _BorderBeamState extends State with TickerProviderStateMixin { } } - bool get _reducedMotion => - widget.respectReducedMotion && - (MediaQuery.maybeDisableAnimationsOf(context) ?? false); + /// The reduced-motion behavior in force, or null when the platform is not + /// asking for it (or this beam ignores the ask). + BeamReducedMotion? get _reduced { + if (!(MediaQuery.maybeDisableAnimationsOf(context) ?? false)) return null; + final mode = + BeamSync.reducedMotionOf(context) ?? + _playback.reducedMotion ?? + BeamReducedMotion.staticFrame; + return mode == BeamReducedMotion.animate ? null : mode; + } + + // A frozen beam stops ticking: the static frame and the hidden beam both + // have nothing to advance. Slow motion keeps running, at a quarter rate. + bool get _motionFrozen => + _reducedApplied == BeamReducedMotion.staticFrame || + _reducedApplied == BeamReducedMotion.hide; + + // ─── Repeat budget & driven travel ────────────────────────────────────── + + void _syncResolverPlayback() { + // Playback belongs to the group under a BeamSync, so one member's repeat + // budget must not stop everyone's clock. + _resolver?.repeatCycles = _synced ? null : validateRepeat(_playback.repeat); + } + + // The per-frame hook is only worth its cost while something needs it. + void _syncTickListener() { + final needed = + widget.follow != null || + _followValue != null || + _durationStartedAt != null || + (!_synced && _playback.repeat?.cycles != null); + if (!needed) { + _detachTickListener(); + return; + } + if (_listeningTo == _clock) return; + _detachTickListener(); + _listeningTo = _clock..addListener(_onClockTick); + _lastTickSeconds = _clock.elapsedSeconds; + } + + void _detachTickListener() { + _listeningTo?.removeListener(_onClockTick); + _listeningTo = null; + } + + void _onClockTick() { + final now = _clock.elapsedSeconds; + final dt = now - _lastTickSeconds; + _lastTickSeconds = now; + // A restart rewinds the timeline and a retime rescales it; neither step + // is a frame's worth of time, so it is dropped rather than eased across. + if (dt > 0 && dt < 0.25) _advanceFollow(dt); + _checkDurationBudget(); + _checkRepeatBudget(); + } + + void _checkRepeatBudget() { + final resolver = _resolver; + if (resolver == null || resolver.repeatCycles == null) return; + final clock = _clock; + if (!clock.isVisible || clock.stage == BeamFadeStage.fadingOut) return; + // The beam fades out the way an inactive one does, so the last cycle + // ends on a fade rather than a cut. + if (resolver.finishedAt(clock.elapsedSeconds)) clock.deactivate(); + } + + // The perimeter position the pointer is asking for, as sweep progress. + double? _followTarget() { + final follow = widget.follow; + final config = _config; + if (follow == null || config == null) return null; + switch (widget.variant) { + case BeamVariant.rotate || BeamVariant.small: + // Progress 0 sits at 12 o'clock and runs clockwise, so the angle to + // the point is measured from straight up. + final angle = + math.atan2(follow.dy - 0.5, follow.dx - 0.5) + math.pi / 2; + return (angle / (2 * math.pi)) % 1.0; + case BeamVariant.line: + // The beam travels one edge, so only the coordinate along it counts. + final along = switch (config.edge) { + BeamEdge.top || BeamEdge.bottom => follow.dx, + BeamEdge.left || BeamEdge.right => follow.dy, + }; + return along.clamp(0.0, 1.0); + case BeamVariant.pulseInside || BeamVariant.pulseOutside: + // Breathing has no travel to steer. + return null; + } + } + + void _advanceFollow(double dt) { + final target = _followTarget(); + final resolver = _resolver; + if (target == null || resolver == null) return; + final from = _followValue ?? _timedProgress(resolver); + // Shortest way round the contour: the beam never takes the long way to a + // point just behind it. + final delta = ((target - from) + 0.5) % 1.0 - 0.5; + final step = _smoothDamp( + current: from, + target: from + delta, + velocity: _followVelocity, + dt: dt, + response: _followResponseSeconds, + ); + _followVelocity = step.velocity; + _followValue = step.value % 1.0; + if (widget.progress == null) _driven.value = _followValue; + } + + double _timedProgress(BeamPhaseResolver resolver) => + resolver.sample(_clock.elapsedSeconds, 1).travelProgress; + + // Hands the sweep back to the clock where the pointer left it. Shifting + // the travel timeline is the same move as a phase offset, so the beam + // carries on from here instead of snapping back to its own schedule. + void _releaseFollow() { + final held = _followValue; + _followValue = null; + _followVelocity = 0; + _driven.value = widget.progress; + final resolver = _resolver; + if (held == null || resolver == null || _cycleSeconds <= 0) return; + final phases = resolver.sample(_clock.elapsedSeconds, 1); + var delta = held - phases.travelProgress; + // Travel time always runs forward; a mirrored cycle spends it backwards. + if (phases.reversedNow) delta = -delta; + delta = ((delta + 0.5) % 1.0) - 0.5; + _setTravelTimeOffset(_travelTimeOffset + delta * _cycleSeconds); + } + + // ─── Offscreen pause ──────────────────────────────────────────────────── + + bool get _pauseWhenOffscreen => _playback.pauseWhenOffscreen ?? true; + + // Watches the nearest enclosing Scrollable, if there is one and this beam + // has a clock of its own to pause. Scrollable.maybeOf registers a + // dependency, so a scroll view swapping its position re-runs this. + void _syncScrollListener() { + final scrollable = (_pauseWhenOffscreen && !_synced && !_frozen) + ? Scrollable.maybeOf(context) + : null; + final position = scrollable?.position; + if (position == _scrollPosition) return; + _scrollPosition?.removeListener(_onScrollChanged); + _scrollable = scrollable; + _scrollPosition = position; + if (position == null) { + // Nothing left to tell this beam it is hidden, so it must not stay + // paused on the strength of a check nobody will repeat. + _resumeFromOffscreen(); + return; + } + position.addListener(_onScrollChanged); + _scheduleOffscreenCheck(); + } + + void _onScrollChanged() => _scheduleOffscreenCheck(); + + // Coalesced to one check per frame, and deferred to the end of it: a + // scroll position moves before layout, so the beam's paint transform is + // only settled once the frame is done. + void _scheduleOffscreenCheck() { + if (_offscreenCheckScheduled) return; + _offscreenCheckScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _offscreenCheckScheduled = false; + _updateOffscreenPause(); + }); + } + + void _updateOffscreenPause() { + if (!mounted || !_pauseWhenOffscreen || _synced || _frozen) return; + final viewport = _scrollable?.context.findRenderObject(); + final self = context.findRenderObject(); + if (viewport is! RenderBox || self is! RenderBox) return; + if (!viewport.attached || + !viewport.hasSize || + !self.attached || + !self.hasSize) { + return; + } + final origin = self.localToGlobal(Offset.zero, ancestor: viewport); + final visible = (origin & self.size) + .inflate(_offscreenMarginPx) + .overlaps(Offset.zero & viewport.size); + if (visible) { + _resumeFromOffscreen(); + } else { + _pauseForOffscreen(); + } + } + + void _pauseForOffscreen() { + // A clock that is already stopped was stopped by someone else — a + // controller, reduced motion, or a finished fade-out — and stays theirs. + if (_pausedForOffscreen || !_clock.isRunning) return; + _clock.pause(); + _pausedForOffscreen = true; + } + + void _resumeFromOffscreen() { + if (!_pausedForOffscreen) return; + _pausedForOffscreen = false; + // Reduced motion outranks visibility: a beam that must not move stays + // still whether or not it is on screen. + if (_motionFrozen || _pausedForReducedMotion) return; + if (_clock.isVisible) _clock.resume(); + } + + // ─── Lifecycle ────────────────────────────────────────────────────────── @override void didUpdateWidget(BorderBeam oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { + final previousPlayback = _playback; + final controllerChanged = oldWidget.controller != widget.controller; + if (controllerChanged) { oldWidget.controller?.detach(_clock); widget.controller?.attach(_clock); } - if (oldWidget.variant != widget.variant) { + if (oldWidget.speedListenable != widget.speedListenable) { + oldWidget.speedListenable?.removeListener(_applySpeed); + widget.speedListenable?.addListener(_applySpeed); + } + final variantChanged = oldWidget.variant != widget.variant; + _resolveScheduling(retime: !variantChanged); + if (variantChanged && !_synced) { // The fps cap is variant-bound; rebuild the clock. final wasVisible = _clock.isVisible; widget.controller?.detach(_clock); - _clock.dispose(); - _createClock(); + _detachTickListener(); + _detachResetListener(); + _ownClock!.dispose(); + _createOwnClock(); + _applySpeed(); + _applyFadeCurve(); widget.controller?.attach(_clock); - if (wasVisible && widget.controller == null && widget.active) _start(); + _syncTickListener(); + if (wasVisible && widget.controller == null && _active) _start(); } - if (widget.controller == null && oldWidget.active != widget.active) { - _startTimer?.cancel(); - if (widget.active) { - _start(); - } else { - _durationTimer?.cancel(); - _clock.deactivate(); - } + _applyReducedMotionChange(); + _applySchedulingTransition( + previousPlayback, + controllerChanged: controllerChanged, + ); + // The pointer left: release now rather than waiting for a tick, which a + // paused or stopped clock would never deliver. + if (widget.follow == null && _followValue != null) { + _releaseFollow(); + _syncTickListener(); } } @override void dispose() { _startTimer?.cancel(); - _durationTimer?.cancel(); + _durationStartedAt = null; + widget.speedListenable?.removeListener(_applySpeed); + _scrollPosition?.removeListener(_onScrollChanged); + _scrollPosition = null; + _scrollable = null; + _detachTickListener(); + _detachResetListener(); widget.controller?.detach(_clock); - _clock.dispose(); + _ownClock?.dispose(); + _driven.dispose(); super.dispose(); } - BeamConfig _resolveConfig(Brightness ambient) { - final brightness = widget.theme.resolve(ambient); + BeamConfig _resolveConfig( + BeamStyle style, + BeamShape shape, + Brightness ambient, + TextDirection textDirection, + ) { + final brightness = (style.theme ?? BeamTheme.auto).resolve(ambient); + final timing = _configTiming(_timing); final key = ( widget.variant, - widget.colors, + style, + shape, + timing, brightness, - widget.borderRadius, - widget.borderWidth, - widget.useSuperellipse, - widget.strength, - widget.brightness, - widget.saturation, - widget.hueRange, - widget.hueBase, - widget.staticColors, - widget.cycleDuration, - widget.strokeOpacityFactor, - widget.innerOpacityFactor, - widget.bloomOpacityFactor, - widget.glowBoost, - widget.coreBlur, - widget.bloomBlur, - widget.glowBrightness, - widget.glowSaturation, + textDirection, ); if (_config == null || key != _configKey) { _configKey = key; _config = BeamConfig.resolve( variant: widget.variant, - palette: widget.colors.resolve(), + palette: (style.colors ?? BeamColors.colorful).resolve(), brightness: brightness, - borderRadius: widget.borderRadius, - borderWidth: widget.borderWidth, - useSuperellipse: widget.useSuperellipse, - strength: widget.strength, - brightnessFactor: widget.brightness, - saturation: widget.saturation, - hueRange: widget.hueRange, - hueBase: widget.hueBase, - staticColors: widget.staticColors, - cycleDuration: widget.cycleDuration, - strokeOpacityFactor: widget.strokeOpacityFactor, - innerOpacityFactor: widget.innerOpacityFactor, - bloomOpacityFactor: widget.bloomOpacityFactor, - glowBoost: widget.glowBoost, - coreBlur: widget.coreBlur, - bloomBlur: widget.bloomBlur, - glowBrightness: widget.glowBrightness, - glowSaturation: widget.glowSaturation, + style: style, + shape: shape, + timing: timing, + textDirection: textDirection, ); - _resolver = BeamPhaseResolver(_config!); + // A config change rebuilds the resolver, and both time offsets are + // timeline state the beam has already committed to: dropping them would + // snap the hue back and throw away a follow hand-back. + _resolver = BeamPhaseResolver(_config!) + ..hueTimeOffset = _hueTimeOffset + ..travelTimeOffset = _travelTimeOffset + ..breatheTimeOffset = _breatheTimeOffset; + _syncResolverPlayback(); } return _config!; } @override Widget build(BuildContext context) { - final ambient = Theme.of(context).brightness; - final config = _resolveConfig(ambient); + final data = BorderBeamTheme.of(context); + final style = (data.style ?? const BeamStyle()).merge(widget._styleInput); + final shape = (data.shape ?? const BeamShape()).merge(widget._shapeInput); + final config = _resolveConfig( + style, + shape, + Theme.of(context).brightness, + Directionality.maybeOf(context) ?? TextDirection.ltr, + ); final strategy = _strategy; - final reduced = _reducedMotion; - if (reduced && _clock.isRunning) _clock.pause(); + final playing = (widget.controller != null || _synced) + ? _clock.isVisible + : _active && _autoPlay; final staticMode = - reduced && - (widget.controller != null - ? _clock.isVisible - : widget.active && widget.autoPlay); + _reducedApplied == BeamReducedMotion.staticFrame && playing; + // BeamReducedMotion.hide leaves the child bare: no painter, no ticks. + final hidden = _reducedApplied == BeamReducedMotion.hide; + + // An explicit progress owns the sweep; a follow gesture only steers what + // the clock would otherwise drive. + _driven.value = widget.progress?.clamp(0.0, 1.0) ?? _followValue; return RepaintBoundary( child: CustomPaint( - painter: widget.variant == BeamVariant.pulseOutside + painter: !hidden && widget.variant == BeamVariant.pulseOutside ? BeamPainter( clock: _clock, config: config, @@ -677,16 +1369,24 @@ class _BorderBeamState extends State with TickerProviderStateMixin { strategy: strategy, behind: true, staticMode: staticMode, + progress: _driven, + strength: widget.strengthListenable, + frozenAt: _playback.debugFrozenAt, ) : null, - foregroundPainter: BeamPainter( - clock: _clock, - config: config, - resolver: _resolver!, - strategy: strategy, - behind: false, - staticMode: staticMode, - ), + foregroundPainter: hidden + ? null + : BeamPainter( + clock: _clock, + config: config, + resolver: _resolver!, + strategy: strategy, + behind: false, + staticMode: staticMode, + progress: _driven, + strength: widget.strengthListenable, + frozenAt: _playback.debugFrozenAt, + ), // The child never re-rasterizes with the beam's frames. child: RepaintBoundary(child: widget.child), ), diff --git a/lib/src/border_beam_controller.dart b/lib/src/border_beam_controller.dart index 7937d41..095387a 100644 --- a/lib/src/border_beam_controller.dart +++ b/lib/src/border_beam_controller.dart @@ -66,6 +66,25 @@ class BorderBeamController extends ChangeNotifier { notifyListeners(); } + /// Bumps the beam's brightness once and lets it settle (~0.6s), without + /// touching the timeline — the way to mark a moment (a message arrived, a + /// step finished) on a beam that is already running. + /// + /// No-op while the beam is hidden or frozen. + void pulse() { + _clock?.pulse(); + notifyListeners(); + } + + /// Blinks the beam to full opacity, holds 120ms, and decays — a sharper, + /// brighter accent than [pulse]. + /// + /// No-op while the beam is hidden or frozen. + void flash() { + _clock?.flash(); + notifyListeners(); + } + /// Jumps the animation timeline to [position]. void seek(Duration position) { _clock?.seek(position.inMicroseconds / Duration.microsecondsPerSecond); diff --git a/lib/src/border_beam_theme.dart b/lib/src/border_beam_theme.dart new file mode 100644 index 0000000..b601b42 --- /dev/null +++ b/lib/src/border_beam_theme.dart @@ -0,0 +1,146 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'models/beam_playback.dart'; +import 'models/beam_shape.dart'; +import 'models/beam_style.dart'; +import 'models/beam_timing.dart'; + +/// App-wide defaults for every [BorderBeamTheme] descendant's beams. +/// +/// Each slot is one of the beam value objects, and each of their fields is +/// nullable: a field set here fills in for every beam below that leaves it +/// null, and a beam that sets it wins. +@immutable +class BorderBeamThemeData { + /// Creates theme defaults. Every omitted slot inherits. + const BorderBeamThemeData({ + this.style, + this.shape, + this.timing, + this.playback, + }); + + /// Default colors, theme adaptation, and filter hooks. + final BeamStyle? style; + + /// Default corner radii, ring width, and corner family. + final BeamShape? shape; + + /// Default cycle length, rest, rate, and hue periods. + final BeamTiming? timing; + + /// Default play state and scheduling. + final BeamPlayback? playback; + + /// Returns a copy with the given slots replaced. A null argument keeps the + /// current slot. + BorderBeamThemeData copyWith({ + BeamStyle? style, + BeamShape? shape, + BeamTiming? timing, + BeamPlayback? playback, + }) => BorderBeamThemeData( + style: style ?? this.style, + shape: shape ?? this.shape, + timing: timing ?? this.timing, + playback: playback ?? this.playback, + ); + + /// Layers [other] over this data, field by field: a field set in [other] + /// wins, every other field falls through to this data. + BorderBeamThemeData merge(BorderBeamThemeData? other) => other == null + ? this + : BorderBeamThemeData( + style: style?.merge(other.style) ?? other.style, + shape: shape?.merge(other.shape) ?? other.shape, + timing: timing?.merge(other.timing) ?? other.timing, + playback: playback?.merge(other.playback) ?? other.playback, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BorderBeamThemeData && + other.style == style && + other.shape == shape && + other.timing == timing && + other.playback == playback; + + @override + int get hashCode => Object.hash(style, shape, timing, playback); + + @override + String toString() => + 'BorderBeamThemeData(style: $style, shape: $shape, ' + 'timing: $timing, playback: $playback)'; +} + +/// Supplies default [BorderBeamThemeData] to the beams below it. +/// +/// ```dart +/// BorderBeamTheme( +/// data: const BorderBeamThemeData( +/// style: BeamStyle(colors: BeamColors.ocean), +/// shape: BeamShape.circular(20, superellipse: true), +/// ), +/// child: app, +/// ) +/// ``` +/// +/// Themes nest: [of] walks every enclosing theme and merges them from the +/// outside in, so an inner theme overrides only the fields it sets. +class BorderBeamTheme extends InheritedWidget { + /// Creates a theme scope carrying [data]. + const BorderBeamTheme({super.key, required this.data, required super.child}); + + /// The defaults this scope contributes. Merged under any inner scope. + final BorderBeamThemeData data; + + /// The merged defaults at [context], or empty data when there is no + /// enclosing [BorderBeamTheme]. + static BorderBeamThemeData of(BuildContext context) => + maybeOf(context) ?? const BorderBeamThemeData(); + + /// The merged defaults at [context], or null when there is no enclosing + /// [BorderBeamTheme]. + /// + /// [context] is registered as a dependent of every theme in the chain, so a + /// change to an outer one rebuilds it just as an inner one does. + static BorderBeamThemeData? maybeOf(BuildContext context) { + BorderBeamThemeData? merged; + BuildContext? scope = context; + while (scope != null) { + final element = scope + .getElementForInheritedWidgetOfExactType(); + if (element == null) break; + context.dependOnInheritedElement(element); + final data = (element.widget as BorderBeamTheme).data; + // Walking inward-out: what is already merged sits inside `data`. + merged = data.merge(merged); + scope = _parentOf(element); + } + return merged; + } + + // The element directly above [element], where the search for the next + // enclosing scope resumes — an inherited element's own scope map includes + // itself, so continuing from it would find the same widget forever. + static BuildContext? _parentOf(Element element) { + BuildContext? parent; + element.visitAncestorElements((ancestor) { + parent = ancestor; + return false; + }); + return parent; + } + + @override + bool updateShouldNotify(BorderBeamTheme oldWidget) => data != oldWidget.data; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('data', data)); + } +} diff --git a/lib/src/constants/extra_palettes.dart b/lib/src/constants/extra_palettes.dart new file mode 100644 index 0000000..10674cb --- /dev/null +++ b/lib/src/constants/extra_palettes.dart @@ -0,0 +1,68 @@ +import 'dart:ui'; + +// Flutter-only palette additions — NOT transcriptions of the React source. +// The React library ships four presets (colorful, mono, ocean, sunset), which +// live verbatim in `palettes.dart`. The seven below are original to this +// package: each is a short list of source colors that `BeamColors.custom` +// distributes over the `colorful` preset's blob geometry, so every table +// (border, small, line, bloom) keeps the source library's positions, sizes +// and per-entry alpha — only the hues change. Editing a list here changes +// how the matching `BeamColors` preset looks; it does not affect parity with +// the React source. + +/// Source colors for `BeamColors.aurora` — a northern-lights sweep from +/// teal through violet to green. +const List auroraColors = [ + Color(0xFF1FD9C0), // teal + Color(0xFF7A5CFF), // violet + Color(0xFF35E08A), // green + Color(0xFF2FB6E8), // glacier blue +]; + +/// Source colors for `BeamColors.neon` — fully saturated magenta, cyan and +/// lime, the loudest palette in the set. +const List neonColors = [ + Color(0xFFFF00C8), // magenta + Color(0xFF00F0FF), // cyan + Color(0xFFB6FF00), // lime +]; + +/// Source colors for `BeamColors.candy` — pastel pink, lavender and peach. +const List candyColors = [ + Color(0xFFFF9EC4), // pink + Color(0xFFC5B3FF), // lavender + Color(0xFFFFC49B), // peach +]; + +/// Source colors for `BeamColors.ember` — deep red through orange to gold, +/// the hot end of a fire. +const List emberColors = [ + Color(0xFFC1121F), // deep red + Color(0xFFFF6B1A), // orange + Color(0xFFFFC233), // gold +]; + +/// Source colors for `BeamColors.ice` — pale blue, white and cyan. +const List iceColors = [ + Color(0xFFBFE9FF), // pale blue + Color(0xFFFFFFFF), // white + Color(0xFF63E2FF), // cyan +]; + +/// Source colors for `BeamColors.gold` — a warm monochrome run from amber +/// through gold to bronze. +const List goldColors = [ + Color(0xFFFFC24B), // amber + Color(0xFFE8A317), // gold + Color(0xFFA9762F), // bronze +]; + +/// Source colors for `BeamColors.holographic` — desaturated pastels chosen +/// to stay pleasant under a continuous hue drift. +const List holographicColors = [ + Color(0xFFC9B8FF), // lilac + Color(0xFFB8E6FF), // sky + Color(0xFFFFC8E4), // blush + Color(0xFFC8FFE0), // mint + Color(0xFFFFE9B8), // cream +]; diff --git a/lib/src/constants/line_geometry.dart b/lib/src/constants/line_geometry.dart new file mode 100644 index 0000000..34b3c9e --- /dev/null +++ b/lib/src/constants/line_geometry.dart @@ -0,0 +1,210 @@ +// Verbatim transcription of the line variant's mask and blob geometry from +// the React library (border-beam v1.3.0, `src/styles.ts` — +// generateLineVariantCSS and getLineBloomGradients). Every length is in +// logical px in the authored coordinate space, where the beam rides the +// bottom edge of the box; every stop is a CSS gradient stop 0–1. +// +// The mask/highlight radii are multiplied at paint time by the animated +// width/height factors (`lineW`/`lineH`) and the two spike tracks, which is +// why only the base numbers live here. + +/// Radial window mask radii shared by the inner and stroke layers. +const double lineWindowRadiusX = 78; + +/// Vertical radius of [lineWindowRadiusX]'s window. +const double lineWindowRadiusY = 60; + +/// Mid stop of the inner/stroke window. +const double lineWindowMidStop = 0.45; + +/// Alpha at [lineWindowMidStop]. +const double lineWindowMidAlpha = 0.5; + +/// Horizontal radius of the bloom layer's (wider, taller) window mask. +const double lineBloomWindowRadiusX = 84; + +/// Vertical radius of the bloom window. +const double lineBloomWindowRadiusY = 110; + +/// Mid stop of the bloom window. +const double lineBloomWindowMidStop = 0.35; + +/// Alpha at [lineBloomWindowMidStop]. +const double lineBloomWindowMidAlpha = 0.5; + +/// Inner shadow blur of the line variant's inner layer. +const double lineInnerShadowBlur = 9; + +/// Bloom blur sigma while the hue animation runs (CSS `blur(8px)`). +const double lineBloomBlurSigma = 8; + +/// Bloom blur sigma for the mono palette (CSS `blur(6px)`). +const double lineBloomBlurSigmaMono = 6; + +// ─── Traveling highlight ──────────────────────────────────────────────────── + +/// How far below the bottom edge the stroke highlight is centred. +const double lineHighlightOffsetY = 2; + +/// Dark-theme highlight radii and stops. +const double lineHighlightRadiusXDark = 24; + +/// Vertical radius of the dark-theme highlight. +const double lineHighlightRadiusYDark = 28; + +/// Dark-theme highlight alphas (centre, mid, edge). +const List lineHighlightAlphasDark = [0.38, 0.12, 0.0]; + +/// Stops of [lineHighlightAlphasDark]. +const List lineHighlightStopsDark = [0, 0.30, 0.65]; + +/// Light-theme highlight horizontal radius. +const double lineHighlightRadiusXLight = 35; + +/// Vertical radius of the light-theme highlight. +const double lineHighlightRadiusYLight = 28; + +/// Light-theme highlight alphas (centre, mid, edge). +const List lineHighlightAlphasLight = [0.6, 0.25, 0.0]; + +/// Stops of [lineHighlightAlphasLight]. +const List lineHighlightStopsLight = [0, 0.35, 0.70]; + +// ─── Bloom spikes ─────────────────────────────────────────────────────────── + +/// One bloom spike's fixed geometry: [fx] is its position along the edge as +/// a fraction of the width, [yInset] how far above the edge it is centred, +/// and [midStop]/[endStop] its two gradient stops. +typedef LineSpikeGeometry = ({ + double fx, + double yInset, + double midStop, + double endStop, +}); + +/// The seven fixed spikes, at 8/22/36/50/64/78/92% of the edge. +const List lineSpikes = [ + (fx: 0.08, yInset: 2, midStop: 0.30, endStop: 0.88), + (fx: 0.22, yInset: 4, midStop: 0.50, endStop: 0.95), + (fx: 0.36, yInset: 3, midStop: 0.40, endStop: 0.90), + (fx: 0.50, yInset: 2, midStop: 0.55, endStop: 0.96), + (fx: 0.64, yInset: 4, midStop: 0.35, endStop: 0.89), + (fx: 0.78, yInset: 2, midStop: 0.48, endStop: 0.94), + (fx: 0.92, yInset: 3, midStop: 0.42, endStop: 0.91), +]; + +/// Horizontal radius of the wide spike at 22%. +const double lineSpikeWideRadiusX22 = 10; + +/// Vertical radius of the spike at 22%. +const double lineSpikeRadiusY22 = 35; + +/// Horizontal radius of the wide spike at 50%. +const double lineSpikeWideRadiusX50 = 14; + +/// Vertical radius of the spike at 50%. +const double lineSpikeRadiusY50 = 28; + +/// Horizontal radius of the spike at 78%. +const double lineSpikeRadiusX78 = 7; + +/// Vertical radius of the spike at 78%. +const double lineSpikeRadiusY78 = 45; + +/// Widths of the four thin spikes (8/36/64/92%) with a colour palette. +const List lineThinSpikeWidths = [0.8, 2.0, 1.2, 0.6]; + +/// The light-theme width of the thin spike at 92%. +const double lineThinSpikeWidthLight92 = 1.0; + +/// Heights of the four thin spikes (8/36/64/92%) with a colour palette. +const List lineThinSpikeHeights = [92.0, 72.0, 85.0, 60.0]; + +/// Widths the mono palette widens the four thin spikes to. +const List lineMonoThinSpikeWidths = [12.0, 14.0, 12.0, 10.0]; + +/// The mono width of the thin spike at 92% on the light theme. +const double lineMonoThinSpikeWidthLight92 = 12.0; + +/// Heights the mono palette shortens the four thin spikes to. +const List lineMonoThinSpikeHeights = [42.0, 38.0, 40.0, 32.0]; + +/// Mono attenuation of the primary spike colour. +const double lineMonoSpike1 = 0.14; + +/// Mono attenuation of the primary spike colour's dark mid stop. +const double lineMonoSpike1MidDark = 0.09; + +/// Mono attenuation of the primary spike colour's light mid stop. +const double lineMonoSpike1MidLight = 0.11; + +/// The non-mono light-theme alpha of the primary spike's mid stop. +const double lineSpike1MidLightAlpha = 0.85; + +/// Mono attenuation of the secondary spike colour. +const double lineMonoSpike2 = 0.12; + +/// Mono alpha of the secondary spike colour's dark mid stop. +const double lineMonoSpike2MidDark = 0.06; + +/// Mono attenuation of the secondary spike colour's light mid stop. +const double lineMonoSpike2MidLight = 0.09; + +/// The non-mono dark-theme alpha of the secondary spike's mid stop. +const double lineSpike2MidDarkAlpha = 0.49; + +/// The non-mono light-theme alpha of the secondary spike's mid stop. +const double lineSpike2MidLightAlpha = 0.7; + +/// Mono attenuation of a table spike's first colour. +const double lineMonoTableSpike1 = 0.14; + +/// Mono attenuation of a table spike's second colour. +const double lineMonoTableSpike2 = 0.14 * 0.7; + +// ─── Traveling dot, ambient glow, and the light theme's shadow ────────────── + +/// How far below the edge the dark theme's traveling dot is centred. +const double lineDotOffsetY = 1; + +/// Horizontal radius of the traveling dot. +const double lineDotRadiusX = 21; + +/// Vertical radius of the traveling dot. +const double lineDotRadiusY = 15; + +/// Dot alphas (centre, 20%, 50%) with a colour palette. +const List lineDotAlphas = [1.0, 0.9, 0.5]; + +/// Dot alphas for the mono palette. +const List lineDotAlphasMono = [0.5, 0.45, 0.25]; + +/// Stops of the dot gradient. +const List lineDotStops = [0, 0.20, 0.50, 1.0]; + +/// Horizontal radius of the ambient glow around the dot. +const double lineAmbientRadiusX = 42; + +/// Vertical radius of the ambient glow. +const double lineAmbientRadiusY = 40; + +/// Ambient glow alphas (centre, 25%, 55%) with a colour palette. +const List lineAmbientAlphas = [0.3, 0.12, 0.03]; + +/// Ambient glow alphas for the mono palette. +const List lineAmbientAlphasMono = [0.15, 0.06, 0.015]; + +/// Stops of the ambient glow gradient. +const List lineAmbientStops = [0, 0.25, 0.55, 0.80]; + +/// Horizontal radius of the light theme's traveling shadow blob. +const double lineShadowRadiusX = 50; + +/// Vertical radius of the traveling shadow blob. +const double lineShadowRadiusY = 32; + +/// Alphas of the traveling shadow blob. +const List lineShadowAlphas = [0.5, 0.18, 0.03, 0.0]; + +/// Stops of the traveling shadow blob. +const List lineShadowStops = [0, 0.30, 0.60, 0.85]; diff --git a/lib/src/constants/palettes.dart b/lib/src/constants/palettes.dart index 41288e6..c8e4857 100644 --- a/lib/src/constants/palettes.dart +++ b/lib/src/constants/palettes.dart @@ -1,5 +1,7 @@ import 'dart:ui'; +import 'package:flutter/foundation.dart'; + import '../models/beam_blob.dart'; // Verbatim transcription of the color palette tables from the React library @@ -53,6 +55,39 @@ class BeamPresetData { /// 5 fixed bloom spike color pairs, light theme. final List lineBloomLight; + + // Structural equality: derived bundles (custom/seed/lerp palettes) are + // built fresh on every resolve, so two bundles carrying the same tables + // must compare equal for `BeamConfig` — and therefore + // `BeamPainter.shouldRepaint` — to see them as the same paint. + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamPresetData && + listEquals(other.border, border) && + other.spike == spike && + other.spikeLt == spikeLt && + listEquals(other.smallBorder, smallBorder) && + listEquals(other.smallInner, smallInner) && + listEquals(other.lineDark, lineDark) && + listEquals(other.lineLight, lineLight) && + listEquals(other.lineInner, lineInner) && + listEquals(other.lineBloomDark, lineBloomDark) && + listEquals(other.lineBloomLight, lineBloomLight); + + @override + int get hashCode => Object.hash( + Object.hashAll(border), + spike, + spikeLt, + Object.hashAll(smallBorder), + Object.hashAll(smallInner), + Object.hashAll(lineDark), + Object.hashAll(lineLight), + Object.hashAll(lineInner), + Object.hashAll(lineBloomDark), + Object.hashAll(lineBloomLight), + ); } // ── colorful ──────────────────────────────────────────────────────────────── diff --git a/lib/src/constants/pulse_constants.dart b/lib/src/constants/pulse_constants.dart new file mode 100644 index 0000000..6a9a1c2 --- /dev/null +++ b/lib/src/constants/pulse_constants.dart @@ -0,0 +1,86 @@ +// The pulse variants' painting constants. +// +// Two provenances, kept apart below: +// +// * VERBATIM — transcribed from the React library (border-beam v1.3.0, +// `src/styles.ts`): the pulse-outside outward-glow transform, the +// reference element the glow geometry was authored against, and the +// pulse-inside corner accents. +// * FLUTTER-SIDE TUNING — the demo-hero recipe +// (`.beam-host--pulse-outside-tuned` in the source's demo page), which the +// React library layers on top of its own raw defaults. It is baked in here +// because the pulse-outside look everyone knows from +// beam.jakubantalik.com is the tuned one; every value stays overridable +// through the widget's coreBlur/bloomBlur/glowBrightness/glowSaturation/ +// glowBoost/opacity hooks. + +// ─── Verbatim: pulse-outside geometry ─────────────────────────────────────── + +/// Horizontal factor of the source's constant outward-glow transform +/// (`scale(0.95, 0.9)`). +const double pulseOuterScaleX = 0.95; + +/// Vertical factor of the outward-glow transform. +const double pulseOuterScaleY = 0.9; + +/// Reference child width the glow geometry was authored for. +const double pulseOuterReferenceWidth = 350; + +/// Reference child height the glow geometry was authored for. +const double pulseOuterReferenceHeight = 140; + +/// Lower clamp on the glow's size scale. +const double pulseOuterMinScale = 0.35; + +/// Upper clamp on the glow's size scale. +const double pulseOuterMaxScale = 4; + +/// Damping applied to the size-derived halo unit (`--sub-glow-unit`), which +/// scales reach and blur with the element's size relative to the demo's +/// Subscribe button baseline. +const double pulseOuterGlowUnitDamping = 0.7; + +// ─── Verbatim: pulse-inside corner accents ────────────────────────────────── + +/// Radius of the fixed corner-accent ellipses of the pulse-inside inner +/// layer. +const double pulseInnerCornerRadius = 60; + +/// Dark-theme alpha of a corner accent at full breath. +const double pulseInnerCornerAlphaDark = 0.18; + +/// Light-theme alpha of a corner accent at full breath. +const double pulseInnerCornerAlphaLight = 0.08; + +/// The stop the corner accent has faded out by. +const double pulseInnerCornerEndStop = 0.70; + +/// Blur sigma of the pulse-inside frozen bloom layer. +const double pulseInnerBloomBlurSigma = 8; + +// ─── Flutter-side tuning: the pulse-outside demo recipe ───────────────────── + +/// Prominence boost multiplied into the glow blob geometry. +const double pulseOuterTunedBoost = 1.05; + +/// Multiplier on the glow layers' opacity, brightness, and saturation. +const double pulseOuterTunedGlowMultiplier = 1.71; + +/// Unit-scaled inset the core glow is grown past the child's bounds. +const double pulseOuterTunedCoreInset = 6; + +/// Unit-scaled inset the bloom halo is grown past the child's bounds. +const double pulseOuterTunedBloomInset = 14; + +/// Unit-scaled blur of the core glow — the value that melts the separate +/// blobs into one continuous edge-hugging glow. +const double pulseOuterTunedCoreBlur = 10; + +/// Unit-scaled blur of the ambient bloom halo. +const double pulseOuterTunedBloomBlur = 19; + +/// Default glow brightness before the tuning multiplier. +const double pulseOuterGlowBrightness = 1.3; + +/// Default glow saturation before the tuning multiplier. +const double pulseOuterGlowSaturation = 1.2; diff --git a/lib/src/constants/rotate_stops.dart b/lib/src/constants/rotate_stops.dart new file mode 100644 index 0000000..16b24b0 --- /dev/null +++ b/lib/src/constants/rotate_stops.dart @@ -0,0 +1,86 @@ +// Verbatim transcription of the conic gradient stop/alpha tables of the +// rotate (React `md`) and small (React `sm`) variants, from the React library +// (border-beam v1.3.0, `src/styles.ts` — generateBorderVariantCSS / +// generateSmallVariantCSS, whose window/highlight/bloom tables are identical +// apart from the wider `smallMask` window). Every list is a CSS +// `conic-gradient` colour-stop list flattened into parallel stop and alpha +// lists: `stops[i]` is the CSS stop position 0–1 and `alphas[i]` the alpha of +// the white (dark theme) or black (light theme) colour at it. +// +// The tables are asymmetric in the stop axis: the bright core is followed by +// a short falloff on the leading side and a long soft foot on the trailing +// side, which is what makes the beam read as a head dragging a tail. + +/// `beam-mask` — the rotating soft window that reveals the rotate variant's +/// stroke and inner layers. +const List rotateWindowStops = [ + 0.0, 0.30, 0.36, 0.44, 0.52, 0.80, 0.86, 0.92, 0.95, 1.0, // +]; + +/// Alphas of [rotateWindowStops]. +const List rotateWindowAlphas = [ + 0.0, 0.0, 0.1, 0.35, 1.0, 1.0, 0.35, 0.1, 0.0, 0.0, // +]; + +/// `smallMask` — the wider window the small variant's inner layer uses. +const List smallWindowStops = [ + 0.0, 0.22, 0.28, 0.36, 0.46, 0.82, 0.88, 0.94, 0.97, 1.0, // +]; + +/// Alphas of [smallWindowStops]. +const List smallWindowAlphas = [ + 0.0, 0.0, 0.12, 0.4, 1.0, 1.0, 0.4, 0.12, 0.0, 0.0, // +]; + +/// The stroke's highlight sweep — white on the dark theme, black on the +/// light one. +const List rotateHighlightStops = [ + 0.0, 0.54, 0.57, 0.60, 0.63, 0.66, 0.69, 0.72, 0.75, 0.78, 1.0, // +]; + +/// Dark-theme alphas of [rotateHighlightStops]. +const List rotateHighlightAlphasDark = [ + 0.0, 0.0, 0.1, 0.3, 0.6, 0.75, 0.6, 0.3, 0.1, 0.0, 0.0, // +]; + +/// Light-theme alphas of [rotateHighlightStops]. +const List rotateHighlightAlphasLight = [ + 0.0, 0.0, 0.08, 0.2, 0.4, 0.55, 0.4, 0.2, 0.08, 0.0, 0.0, // +]; + +/// The sharp bloom band, painted into a blurred layer. +const List rotateBloomStops = [ + 0.0, 0.58, 0.62, 0.65, 0.67, 0.69, 0.70, 0.705, 0.715, 0.73, 0.75, 0.78, + 0.82, 1.0, // +]; + +/// Dark-theme alphas of [rotateBloomStops]. +const List rotateBloomAlphasDark = [ + 0.0, 0.0, 0.03, 0.08, 0.2, 0.45, 0.85, 0.85, 0.45, 0.2, 0.08, 0.03, 0.0, + 0.0, // +]; + +/// Light-theme alphas of [rotateBloomStops]. +const List rotateBloomAlphasLight = [ + 0.0, 0.0, 0.02, 0.08, 0.2, 0.4, 0.6, 0.6, 0.4, 0.2, 0.08, 0.02, 0.0, 0.0, // +]; + +/// The blur sigma of the rotate/small bloom layer (CSS `filter: blur(8px)`). +const double rotateBloomBlurSigma = 8; + +/// The inner shadow blur of the rotate variant's inner layer. +const double rotateInnerShadowBlur = 9; + +/// The inner shadow blur of the small variant's inner layer. +const double smallInnerShadowBlur = 5; + +/// The alpha the rotate variant's inner blobs are fixed at (its inner layer +/// is derived from the border blob table at 0.9× size). +const double rotateInnerBlobAlpha = 0.45; + +/// The mono palette's replacement for [rotateInnerBlobAlpha]. +const double rotateInnerBlobAlphaMono = 0.225; + +/// The size factor the rotate variant's inner blobs take from the border +/// table. +const double rotateInnerBlobScale = 0.9; diff --git a/lib/src/constants/upstream.dart b/lib/src/constants/upstream.dart new file mode 100644 index 0000000..2c8eb13 --- /dev/null +++ b/lib/src/constants/upstream.dart @@ -0,0 +1,28 @@ +// Provenance of the tables in this directory. +// +// Everything under `lib/src/constants/` is a hand transcription of the React +// border-beam library's `src/styles.ts`. The constants below name the exact +// upstream artefact those transcriptions were audited against, so a reader +// (and `test/constants/spec_parity_test.dart`) can tell which upstream +// release the numbers belong to. They are internal — the barrel does not +// export them. + +/// Version of the React `border-beam` library the constant tables are +/// transcribed from, as reported by `sourceLibrary.version` in the upstream +/// machine-readable spec. +/// +/// The upstream npm package is versioned separately and currently reads +/// `1.4.0`; that release is visually identical to `1.3.0` and its spec still +/// declares `1.3.0` as the visual baseline. +const String upstreamLibraryVersion = '1.3.0'; + +/// Schema version of the upstream `spec/beam-spec.json` the parity test +/// reads (`specVersion` in that file). +const String upstreamSpecVersion = '1.0.0'; + +/// Where the upstream source and its generated spec live. +/// +/// `spec/beam-spec.json` under this path is vendored at +/// `test/fixtures/beam-spec.json`; refresh it with `tool/spec/refresh.sh`. +const String upstreamRepository = + 'https://github.com/Jakubantalik/Libraries/tree/main/packages/border-beam'; diff --git a/lib/src/models/beam_blob.dart b/lib/src/models/beam_blob.dart index 2bd3450..b874c59 100644 --- a/lib/src/models/beam_blob.dart +++ b/lib/src/models/beam_blob.dart @@ -1,15 +1,18 @@ import 'dart:ui'; +import 'package:flutter/foundation.dart'; + /// One radial-gradient color blob positioned around the border. /// /// This is the core building block of every beam palette: an ellipse of -/// [size] logical pixels, centered at [position] (expressed as a fraction of -/// the decorated box, so `Offset(0.33, -0.074)` is the CSS position -/// `33% -7.4%`), fading from [color] at the center to transparent at the -/// edge. +/// [size] radii in logical pixels, centered at [position] (expressed as a +/// fraction of the decorated box, so `Offset(0.33, -0.074)` is the CSS +/// position `33% -7.4%`), fading from [color] at the center to transparent +/// at the edge. +@immutable class BeamBlob { /// Creates a blob. [position] is fractional (may exceed 0–1 to sit on or - /// beyond the edge); [size] is the ellipse diameter in logical pixels. + /// beyond the edge); [size] holds the ellipse radii in logical pixels. const BeamBlob({ required this.color, required this.position, @@ -23,20 +26,40 @@ class BeamBlob { /// (0,0 = top-left, 1,1 = bottom-right; values outside 0–1 are valid). final Offset position; - /// Ellipse diameter (width × height) in logical pixels. + /// Ellipse radii in logical pixels: [Size.width] is the horizontal radius + /// and [Size.height] the vertical one, matching CSS + /// `radial-gradient(ellipse W H ...)`, whose sizes are radii. Painters + /// pass these straight through as `radiusX`/`radiusY`. final Size size; /// Returns a copy with a different [color], keeping the geometry. BeamBlob withColor(Color color) => BeamBlob(color: color, position: position, size: size); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamBlob && + other.color == color && + other.position == position && + other.size == size; + + @override + int get hashCode => Object.hash(color, position, size); + + @override + String toString() => + 'BeamBlob(color: $color, position: $position, ' + 'size: $size)'; } /// A blob used by the line variant. Line blobs ride the bottom edge: their /// x-position is `beamX + offsetX` px and their y sits at the bottom edge /// shifted by [offsetY] px, with the ellipse scaled by the animated /// width/height factors. +@immutable class LineBlob { - /// Creates a line blob with base ellipse size and pixel offsets from the + /// Creates a line blob with base ellipse radii and pixel offsets from the /// traveling beam center. const LineBlob({ required this.color, @@ -49,11 +72,12 @@ class LineBlob { /// Blob color (may carry alpha for inner-glow tables). final Color color; - /// Base ellipse width in px, multiplied by the animated beam width factor. + /// Base horizontal ellipse radius in px, multiplied by the animated beam + /// width factor and passed to the painter as `radiusX`. final double sizeW; - /// Base ellipse height in px, multiplied by the animated beam height - /// factor. + /// Base vertical ellipse radius in px, multiplied by the animated beam + /// height factor and passed to the painter as `radiusY`. final double sizeH; /// Horizontal offset in px from the traveling beam center. @@ -70,10 +94,29 @@ class LineBlob { offsetX: offsetX, offsetY: offsetY, ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LineBlob && + other.color == color && + other.sizeW == sizeW && + other.sizeH == sizeH && + other.offsetX == offsetX && + other.offsetY == offsetY; + + @override + int get hashCode => Object.hash(color, sizeW, sizeH, offsetX, offsetY); + + @override + String toString() => + 'LineBlob(color: $color, size: ${sizeW}x$sizeH, ' + 'offset: $offsetX, $offsetY)'; } /// A pair of colors used by one fixed bloom spike of the line variant /// (center color and mid-stop color). +@immutable class SpikePair { /// Creates a spike color pair. const SpikePair(this.color1, this.color2); @@ -83,10 +126,22 @@ class SpikePair { /// Color at the spike's mid gradient stop. final Color color2; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpikePair && other.color1 == color1 && other.color2 == color2; + + @override + int get hashCode => Object.hash(color1, color2); + + @override + String toString() => 'SpikePair($color1, $color2)'; } /// Primary/secondary spike colors used by the line variant's traveling /// accents. +@immutable class SpikeColors { /// Creates the spike color pair. const SpikeColors({required this.primary, required this.secondary}); @@ -96,6 +151,21 @@ class SpikeColors { /// The secondary spike color. final Color secondary; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpikeColors && + other.primary == primary && + other.secondary == secondary; + + @override + int get hashCode => Object.hash(primary, secondary); + + @override + String toString() => + 'SpikeColors(primary: $primary, ' + 'secondary: $secondary)'; } /// Which oscillator group (1–3) scales/drifts a pulse blob. diff --git a/lib/src/models/beam_colors.dart b/lib/src/models/beam_colors.dart index c4262c0..717aba8 100644 --- a/lib/src/models/beam_colors.dart +++ b/lib/src/models/beam_colors.dart @@ -1,19 +1,49 @@ +import 'dart:collection'; import 'dart:ui'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show ColorScheme; +import 'package:flutter/painting.dart' show HSLColor; + +import '../constants/extra_palettes.dart'; import '../constants/palettes.dart'; import 'beam_blob.dart'; import 'beam_palette.dart'; +import 'model_validation.dart'; + +/// How [BeamColors.fromSeed] spreads one brand color into a multi-color +/// palette. +/// +/// Every harmony works in HSL and keeps the derived colors inside the +/// lightness band a glow reads well in, so a very dark or very light seed +/// still produces visible beams. +enum BeamSeedHarmony { + /// Four neighbours of the seed hue (0°, +25°, −25°, +50°). The calmest + /// option: the palette still reads as one color. + analogous, + + /// Four colors split across the seed hue and its opposite (0°, +15°, + /// +180°, +195°) — maximum contrast between the two halves of the beam. + complementary, + + /// Three colors evenly spaced around the wheel (0°, +120°, +240°). + triadic, + + /// Four tints of the seed hue, stepping through the lightness band with + /// falling saturation. Keeps a single-hue brand look. + monochrome, +} /// The color scheme of a beam. /// -/// Use one of the four presets ported from the React library: +/// Use one of the presets: /// /// ```dart /// BorderBeam.rotate(colors: BeamColors.ocean, child: card) /// ``` /// -/// or bring your own colors — [BeamColors.custom] distributes them over the -/// preset blob geometry so the effect keeps its organic look: +/// or bring your own colors — [BeamColors.custom] distributes them over a +/// preset's blob geometry so the effect keeps its organic look: /// /// ```dart /// BorderBeam.rotate( @@ -22,10 +52,21 @@ import 'beam_palette.dart'; /// ) /// ``` /// -/// For pixel-level control, [BeamColors.spec] accepts explicit blob tables. +/// [BeamColors.fromSeed] and [BeamColors.fromScheme] derive a palette from a +/// single brand color or a Material [ColorScheme]; [BeamColors.lerp] and +/// [scaleAlpha] transform an existing one. For pixel-level control, +/// [BeamColors.spec] accepts explicit blob tables. +/// +/// Every variant is a value type: two instances built from equal inputs are +/// `==`, so rebuilding `BeamColors.custom([...])` inline in a `build` method +/// does not force the widget to re-resolve its gradient tables. +@immutable sealed class BeamColors { const BeamColors._(); + /// Immutable value used by the resolved-palette LRU. + Object get _cacheKey; + /// Rainbow spectrum. The default. static const BeamColors colorful = _PresetBeamColors(colorfulPreset); @@ -39,18 +80,112 @@ sealed class BeamColors { /// Warm orange, yellow, and red tones. static const BeamColors sunset = _PresetBeamColors(sunsetPreset); - /// Distributes [colors] over the default preset's blob geometry (cycling - /// when fewer colors than blob slots are given). + // ─── Flutter-only presets ───────────────────────────────────────────────── + // Each distributes a short color list (`extra_palettes.dart`) over the + // `colorful` blob geometry, exactly as `BeamColors.custom` does. + + /// Northern lights: teal, violet, green and glacier blue. + static const BeamColors aurora = _CustomBeamColors(auroraColors); + + /// Fully saturated magenta, cyan and lime — the loudest preset. + static const BeamColors neon = _CustomBeamColors(neonColors); + + /// Pastel pink, lavender and peach. + static const BeamColors candy = _CustomBeamColors(candyColors); + + /// Deep red, orange and gold — the hot end of a fire. + static const BeamColors ember = _CustomBeamColors(emberColors); + + /// Pale blue, white and cyan. + static const BeamColors ice = _CustomBeamColors(iceColors); + + /// Warm monochrome: amber, gold and bronze. + /// + /// Like [mono] it pins the hue — a hue sweep over a single-hue metal reads + /// as the metal changing material — so `gold` resolves with + /// `forcesStaticColors: true`. Unlike [mono] it keeps full layer opacity: + /// the ×0.5 multiplier exists to stop a *grayscale* beam blowing out, and + /// halving a warm amber only makes it muddy. + static const BeamColors gold = _CustomBeamColors(goldColors, staticHue: true); + + /// Desaturated pastels built for a hue sweep. + /// + /// The colors are deliberately low-contrast; the iridescence comes from + /// pairing them with a fast continuous hue drift: + /// + /// ```dart + /// BorderBeam.rotate( + /// colors: BeamColors.holographic, + /// style: const BeamStyle(hueMode: BeamHueMode.continuous), + /// timing: const BeamTiming(huePeriod: Duration(seconds: 3)), + /// child: card, + /// ) + /// ``` + static const BeamColors holographic = _CustomBeamColors(holographicColors); + + // ─── Factories ──────────────────────────────────────────────────────────── + + /// Distributes [colors] over [base]'s blob geometry (cycling when fewer + /// colors than blob slots are given). /// - /// The list must not be empty. Alpha channels of the preset tables (inner + /// The list must not be empty. Alpha channels of the base tables (inner /// glows, bloom spikes) are preserved and applied to your colors, so the /// layered depth of the effect is kept. - const factory BeamColors.custom(List colors) = _CustomBeamColors; + /// + /// [base] chooses which resolved tables supply that geometry and alpha + /// structure — any [BeamColors] is accepted, including another custom + /// palette. Only the tables are taken: a mono [base] does not make the + /// result mono. + const factory BeamColors.custom(List colors, {BeamColors base}) = + _CustomBeamColors; + + /// Derives a multi-blob palette from a single brand color. + /// + /// [harmony] picks how the seed hue is spread (see [BeamSeedHarmony]). + /// The derived colors are lifted into a readable glow band — lightness + /// 0.55–0.70, saturation at least 0.55 — so a black, white or gray seed + /// still yields visible, distinguishable blobs. + /// + /// The palette *geometry* always comes from the [colorful] preset; only + /// the colors are derived. + /// + /// ```dart + /// BorderBeam.rotate( + /// colors: const BeamColors.fromSeed(Color(0xFF18A8F0)), + /// child: card, + /// ) + /// ``` + const factory BeamColors.fromSeed(Color seed, {BeamSeedHarmony harmony}) = + _SeedBeamColors; + + /// Builds a palette from a Material [ColorScheme]'s primary, secondary and + /// tertiary roles. + /// + /// Roles that are near-duplicates of an earlier one are dropped, so a + /// scheme whose secondary matches its primary yields a two-color palette + /// rather than a doubled one. Geometry comes from the [colorful] preset. + factory BeamColors.fromScheme(ColorScheme scheme) => _CustomBeamColors( + _dedupe([scheme.primary, scheme.secondary, scheme.tertiary]), + ); + + /// Blends two color choices. + /// + /// The result resolves to a palette whose every table entry is the + /// [Color.lerp] of the corresponding entries of [a] and [b] at [t]. + /// Positions and sizes come from [a]; where [b]'s table is shorter its + /// colors cycle. `forcesStaticColors` and the mono treatment come from + /// whichever end is nearer, and the opacity multiplier is interpolated. + /// + /// [t] is not clamped — values outside 0–1 extrapolate, as with + /// [Color.lerp]. + const factory BeamColors.lerp(BeamColors a, BeamColors b, double t) = + _LerpBeamColors; /// Advanced: full per-blob control. /// /// [border] replaces the 9-blob border table (any length ≥ 1) used by the - /// rotate stroke and as the pulse color source. Tables not provided + /// rotate stroke and as the pulse color source; each [BeamBlob.size] holds + /// the ellipse *radii*, not its diameters. Tables not provided /// ([smallBorder], [lineBlobs]) are derived by cycling the border colors /// over the default geometry. const factory BeamColors.spec({ @@ -59,10 +194,291 @@ sealed class BeamColors { List? lineBlobs, }) = _SpecBeamColors; + // ─── Transforms ─────────────────────────────────────────────────────────── + + /// Multiplies every table entry's alpha by [factor], clamping the result + /// to 0–1. + /// + /// This dims the palette itself rather than the layer opacity, so the + /// relative depth of the inner/stroke/bloom tables is preserved. [factor] + /// must not be negative. + BeamColors scaleAlpha(double factor) => _ScaledBeamColors(this, factor); + + // ─── Resolution ─────────────────────────────────────────────────────────── + /// Resolves this color choice to concrete gradient tables. - BeamPalette resolve(); + /// + /// The result is memoized by *value*: two separately constructed but equal + /// instances return the identical [BeamPalette], which is what lets a + /// config compare palettes cheaply. The memo is a bounded LRU, so a + /// long-lived app cycling through many palettes cannot grow it without + /// limit; an evicted palette is simply rebuilt, and compares equal to the + /// one it replaces. + BeamPalette resolve() => _resolveCached(this); + + /// Builds the palette. Called at most once per distinct value while that + /// value stays in the memo. + BeamPalette _buildPalette(); +} + +// ─── Palette memo ─────────────────────────────────────────────────────────── + +/// How many distinct color choices keep a resolved palette. Comfortably +/// above the handful a screen uses at once, small enough that the retained +/// tables stay negligible. +const int _paletteCacheCapacity = 32; + +// Insertion-ordered, so the first key is the least recently used: a hit +// re-inserts its key at the end, and an overflowing insert drops the front. +final LinkedHashMap _paletteCache = + LinkedHashMap(); + +BeamPalette _resolveCached(BeamColors colors) { + // User-provided tables may come from growable lists. Never put the public + // value object itself in a hash map: mutating one of those source lists + // would change its hash while it was resident and make the entry + // unreachable. Each implementation supplies an immutable snapshot of the + // inputs that affect resolution instead. + final key = colors._cacheKey; + final hit = _paletteCache.remove(key); + if (hit != null) { + _paletteCache[key] = hit; + return hit; + } + final built = colors._buildPalette(); + _paletteCache[key] = built; + if (_paletteCache.length > _paletteCacheCapacity) { + _paletteCache.remove(_paletteCache.keys.first); + } + return built; +} + +/// A hash-stable snapshot of a list used by a palette cache key. +class _ListKey { + _ListKey(Iterable values) : values = List.unmodifiable(values); + + final List values; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _ListKey && listEquals(values, other.values); + + @override + int get hashCode => Object.hashAll(values); +} + +// The presets bypass the LRU: they are canonicalized const instances, so an +// identity-keyed Expando memoizes them for the life of the isolate without +// hashing a color table or competing with user palettes for a cache slot. +final Expando _presetPaletteCache = Expando( + 'BeamColors.preset', +); + +// ─── Table helpers ────────────────────────────────────────────────────────── + +/// Distributes [colors] over [base]'s blob geometry, cycling when there are +/// fewer colors than blob slots and preserving each table entry's alpha so +/// the layered depth of the effect survives the substitution. +BeamPresetData _distribute(List colors, BeamPresetData base) { + Color pick(int i, Color original) { + final c = colors[i % colors.length]; + // Keep the preset's alpha so inner/bloom layering depth is preserved. + return c.withValues(alpha: original.a); + } + + return BeamPresetData( + border: [ + for (final (i, b) in base.border.indexed) b.withColor(pick(i, b.color)), + ], + spike: SpikeColors( + primary: pick(0, base.spike.primary), + secondary: pick(1, base.spike.secondary), + ), + spikeLt: SpikeColors( + primary: pick(0, base.spikeLt.primary), + secondary: pick(1, base.spikeLt.secondary), + ), + smallBorder: [ + for (final (i, b) in base.smallBorder.indexed) + b.withColor(pick(i, b.color)), + ], + smallInner: [ + for (final (i, b) in base.smallInner.indexed) + b.withColor(pick(i, b.color)), + ], + lineDark: [ + for (final (i, b) in base.lineDark.indexed) b.withColor(pick(i, b.color)), + ], + lineLight: [ + for (final (i, b) in base.lineLight.indexed) + b.withColor(pick(i, b.color)), + ], + lineInner: [ + for (final (i, b) in base.lineInner.indexed) + b.withColor(pick(i, b.color)), + ], + lineBloomDark: [ + for (final (i, s) in base.lineBloomDark.indexed) + SpikePair(pick(i, s.color1), pick(i, s.color2)), + ], + lineBloomLight: [ + for (final (i, s) in base.lineBloomLight.indexed) + SpikePair(pick(i, s.color1), pick(i, s.color2)), + ], + ); +} + +/// Rebuilds [data] with every table color passed through [f], keeping all +/// geometry. +BeamPresetData _mapColors(BeamPresetData data, Color Function(Color) f) => + BeamPresetData( + border: [for (final b in data.border) b.withColor(f(b.color))], + spike: SpikeColors( + primary: f(data.spike.primary), + secondary: f(data.spike.secondary), + ), + spikeLt: SpikeColors( + primary: f(data.spikeLt.primary), + secondary: f(data.spikeLt.secondary), + ), + smallBorder: [for (final b in data.smallBorder) b.withColor(f(b.color))], + smallInner: [for (final b in data.smallInner) b.withColor(f(b.color))], + lineDark: [for (final b in data.lineDark) b.withColor(f(b.color))], + lineLight: [for (final b in data.lineLight) b.withColor(f(b.color))], + lineInner: [for (final b in data.lineInner) b.withColor(f(b.color))], + lineBloomDark: [ + for (final s in data.lineBloomDark) SpikePair(f(s.color1), f(s.color2)), + ], + lineBloomLight: [ + for (final s in data.lineBloomLight) + SpikePair(f(s.color1), f(s.color2)), + ], + ); + +/// Pairs [a]'s tables with [b]'s and combines their colors through [mix], +/// keeping [a]'s geometry. Where [b]'s table is shorter its entries cycle; +/// where it is empty, [a]'s color passes through untouched. +BeamPresetData _zipColors( + BeamPresetData a, + BeamPresetData b, + Color Function(Color, Color) mix, +) { + Color at(List other, int i, Color own, Color Function(T) color) => + other.isEmpty ? own : mix(own, color(other[i % other.length])); + + List blobs(List xa, List xb) => [ + for (final (i, blob) in xa.indexed) + blob.withColor(at(xb, i, blob.color, (o) => o.color)), + ]; + List lines(List xa, List xb) => [ + for (final (i, blob) in xa.indexed) + blob.withColor(at(xb, i, blob.color, (o) => o.color)), + ]; + List pairs(List xa, List xb) => [ + for (final (i, s) in xa.indexed) + SpikePair( + at(xb, i, s.color1, (o) => o.color1), + at(xb, i, s.color2, (o) => o.color2), + ), + ]; + + return BeamPresetData( + border: blobs(a.border, b.border), + spike: SpikeColors( + primary: mix(a.spike.primary, b.spike.primary), + secondary: mix(a.spike.secondary, b.spike.secondary), + ), + spikeLt: SpikeColors( + primary: mix(a.spikeLt.primary, b.spikeLt.primary), + secondary: mix(a.spikeLt.secondary, b.spikeLt.secondary), + ), + smallBorder: blobs(a.smallBorder, b.smallBorder), + smallInner: blobs(a.smallInner, b.smallInner), + lineDark: lines(a.lineDark, b.lineDark), + lineLight: lines(a.lineLight, b.lineLight), + lineInner: lines(a.lineInner, b.lineInner), + lineBloomDark: pairs(a.lineBloomDark, b.lineBloomDark), + lineBloomLight: pairs(a.lineBloomLight, b.lineBloomLight), + ); +} + +/// RGB distance below which two scheme roles count as the same color. +const double _dedupeThreshold = 0.06; + +/// Drops colors within [_dedupeThreshold] of one already kept, comparing +/// straight-line distance in unpremultiplied RGB. +List _dedupe(List colors) { + final kept = []; + for (final c in colors) { + final duplicate = kept.any((k) { + final dr = k.r - c.r; + final dg = k.g - c.g; + final db = k.b - c.b; + return dr * dr + dg * dg + db * db < _dedupeThreshold * _dedupeThreshold; + }); + if (!duplicate) kept.add(c); + } + return kept; } +// ─── Seed harmonies ───────────────────────────────────────────────────────── + +/// Lowest lightness a derived seed color may take — below this a glow reads +/// as a smudge on a dark surface. +const double _seedMinLightness = 0.55; + +/// Highest lightness a derived seed color may take — above this the blobs +/// wash out against a light surface. +const double _seedMaxLightness = 0.70; + +/// Saturation floor, so an achromatic seed still produces hue separation. +const double _seedMinSaturation = 0.55; + +List _seedColors(Color seed, BeamSeedHarmony harmony) { + final hsl = HSLColor.fromColor(seed); + final hue = hsl.hue; + final saturation = clampDouble(hsl.saturation, _seedMinSaturation, 1); + final lightness = clampDouble( + hsl.lightness, + _seedMinLightness, + _seedMaxLightness, + ); + + Color at(double hueDelta, [double lightnessDelta = 0]) => HSLColor.fromAHSL( + 1, + ((hue + hueDelta) % 360 + 360) % 360, + saturation, + clampDouble( + lightness + lightnessDelta, + _seedMinLightness, + _seedMaxLightness, + ), + ).toColor(); + + return switch (harmony) { + BeamSeedHarmony.analogous => [at(0), at(25, 0.05), at(-25, -0.05), at(50)], + BeamSeedHarmony.complementary => [ + at(0), + at(15, 0.05), + at(180), + at(195, -0.05), + ], + BeamSeedHarmony.triadic => [at(0), at(120), at(240)], + BeamSeedHarmony.monochrome => [ + for (var i = 0; i < 4; i++) + HSLColor.fromAHSL( + 1, + hue, + clampDouble(saturation * (1 - 0.1 * i), 0, 1), + lerpDouble(_seedMinLightness, _seedMaxLightness, i / 3)!, + ).toColor(), + ], + }; +} + +// ─── Implementations ──────────────────────────────────────────────────────── + class _PresetBeamColors extends BeamColors { const _PresetBeamColors(this.preset, {this.isMono = false}) : super._(); @@ -70,74 +486,180 @@ class _PresetBeamColors extends BeamColors { final bool isMono; @override - BeamPalette resolve() => BeamPalette( + Object get _cacheKey => (#preset, preset, isMono); + + @override + BeamPalette resolve() => _presetPaletteCache[this] ??= _buildPalette(); + + @override + BeamPalette _buildPalette() => BeamPalette( data: preset, forcesStaticColors: isMono, opacityMultiplier: isMono ? 0.5 : 1.0, monoTreatment: isMono, ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _PresetBeamColors && + identical(other.preset, preset) && + other.isMono == isMono; + + @override + int get hashCode => Object.hash(identityHashCode(preset), isMono); + + @override + String toString() => 'BeamColors(preset, isMono: $isMono)'; } class _CustomBeamColors extends BeamColors { - const _CustomBeamColors(this.colors) : super._(); + const _CustomBeamColors( + this.colors, { + this.base = BeamColors.colorful, + this.staticHue = false, + }) : super._(); final List colors; + final BeamColors base; + + // Set only by the built-in single-hue presets; `BeamColors.custom` never + // exposes it, because pinning the hue of a palette the caller chose would + // silently ignore their `hueMode`. + final bool staticHue; @override - BeamPalette resolve() { - assert(colors.isNotEmpty, 'BeamColors.custom requires at least one color'); - Color pick(int i, Color original) { - final c = colors[i % colors.length]; - // Keep the preset's alpha so inner/bloom layering depth is preserved. - return c.withValues(alpha: original.a); - } + Object get _cacheKey => + (#custom, _ListKey(colors), base._cacheKey, staticHue); - final base = colorfulPreset; + @override + BeamPalette _buildPalette() { + validateColorTable(colors.length, 'BeamColors.custom colors'); return BeamPalette( - data: BeamPresetData( - border: [ - for (final (i, b) in base.border.indexed) - b.withColor(pick(i, b.color)), - ], - spike: SpikeColors( - primary: pick(0, base.spike.primary), - secondary: pick(1, base.spike.secondary), - ), - spikeLt: SpikeColors( - primary: pick(0, base.spikeLt.primary), - secondary: pick(1, base.spikeLt.secondary), - ), - smallBorder: [ - for (final (i, b) in base.smallBorder.indexed) - b.withColor(pick(i, b.color)), - ], - smallInner: [ - for (final (i, b) in base.smallInner.indexed) - b.withColor(pick(i, b.color)), - ], - lineDark: [ - for (final (i, b) in base.lineDark.indexed) - b.withColor(pick(i, b.color)), - ], - lineLight: [ - for (final (i, b) in base.lineLight.indexed) - b.withColor(pick(i, b.color)), - ], - lineInner: [ - for (final (i, b) in base.lineInner.indexed) - b.withColor(pick(i, b.color)), - ], - lineBloomDark: [ - for (final (i, s) in base.lineBloomDark.indexed) - SpikePair(pick(i, s.color1), pick(i, s.color2)), - ], - lineBloomLight: [ - for (final (i, s) in base.lineBloomLight.indexed) - SpikePair(pick(i, s.color1), pick(i, s.color2)), - ], + data: _distribute(colors, base.resolve().data), + forcesStaticColors: staticHue, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _CustomBeamColors && + listEquals(other.colors, colors) && + other.base == base && + other.staticHue == staticHue; + + @override + int get hashCode => Object.hash(Object.hashAll(colors), base, staticHue); + + @override + String toString() => 'BeamColors.custom($colors)'; +} + +class _SeedBeamColors extends BeamColors { + const _SeedBeamColors(this.seed, {this.harmony = BeamSeedHarmony.analogous}) + : super._(); + + final Color seed; + final BeamSeedHarmony harmony; + + @override + Object get _cacheKey => (#seed, seed, harmony); + + @override + BeamPalette _buildPalette() => BeamPalette( + data: _distribute(_seedColors(seed, harmony), colorfulPreset), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _SeedBeamColors && + other.seed == seed && + other.harmony == harmony; + + @override + int get hashCode => Object.hash(seed, harmony); + + @override + String toString() => 'BeamColors.fromSeed($seed, harmony: ${harmony.name})'; +} + +class _LerpBeamColors extends BeamColors { + const _LerpBeamColors(this.a, this.b, this.t) : super._(); + + final BeamColors a; + final BeamColors b; + final double t; + + @override + Object get _cacheKey => (#lerp, a._cacheKey, b._cacheKey, t); + + @override + BeamPalette _buildPalette() { + final pa = a.resolve(); + final pb = b.resolve(); + final nearer = t < 0.5 ? pa : pb; + return BeamPalette( + data: _zipColors(pa.data, pb.data, (x, y) => Color.lerp(x, y, t)!), + forcesStaticColors: nearer.forcesStaticColors, + opacityMultiplier: lerpDouble( + pa.opacityMultiplier, + pb.opacityMultiplier, + t, + )!, + monoTreatment: nearer.monoTreatment, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _LerpBeamColors && other.a == a && other.b == b && other.t == t; + + @override + int get hashCode => Object.hash(a, b, t); + + @override + String toString() => 'BeamColors.lerp($a, $b, $t)'; +} + +class _ScaledBeamColors extends BeamColors { + const _ScaledBeamColors(this.source, this.factor) : super._(); + + final BeamColors source; + final double factor; + + @override + Object get _cacheKey => (#scaled, source._cacheKey, factor); + + @override + BeamPalette _buildPalette() { + assert(factor >= 0, 'BeamColors.scaleAlpha requires a non-negative factor'); + final base = source.resolve(); + return BeamPalette( + data: _mapColors( + base.data, + (c) => c.withValues(alpha: clampDouble(c.a * factor, 0, 1)), ), + forcesStaticColors: base.forcesStaticColors, + opacityMultiplier: base.opacityMultiplier, + monoTreatment: base.monoTreatment, ); } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _ScaledBeamColors && + other.source == source && + other.factor == factor; + + @override + int get hashCode => Object.hash(source, factor); + + @override + String toString() => '$source.scaleAlpha($factor)'; } class _SpecBeamColors extends BeamColors { @@ -152,31 +674,71 @@ class _SpecBeamColors extends BeamColors { final List? lineBlobs; @override - BeamPalette resolve() { - assert(border.isNotEmpty, 'BeamColors.spec requires at least one blob'); + Object get _cacheKey => ( + #spec, + _ListKey(border), + smallBorder == null ? null : _ListKey(smallBorder!), + lineBlobs == null ? null : _ListKey(lineBlobs!), + ); + + @override + BeamPalette _buildPalette() { + validateColorTable(border.length, 'BeamColors.spec border'); + // The caller keeps their list, and a growable one they mutate later would + // otherwise change the palette this cache holds under a key that no + // longer matches it. Every table that reaches BeamPresetData is a + // snapshot of its own — the unmodifiable cache key alone does not make + // the resolved palette immutable. + final borderTable = List.unmodifiable(border); + final smallTable = smallBorder == null + ? null + : List.unmodifiable(smallBorder!); + final lineTable = lineBlobs == null + ? null + : List.unmodifiable(lineBlobs!); // Derive any missing tables by cycling the provided border colors over // the default geometry. - final derived = _CustomBeamColors([ - for (final b in border) b.color, - ]).resolve().data; + final derived = _distribute([ + for (final b in borderTable) b.color, + ], colorfulPreset); return BeamPalette( data: BeamPresetData( - border: border, + border: borderTable, spike: derived.spike, spikeLt: derived.spikeLt, - smallBorder: smallBorder ?? derived.smallBorder, - smallInner: smallBorder != null + smallBorder: smallTable ?? derived.smallBorder, + smallInner: smallTable != null ? [ - for (final b in smallBorder!) + for (final b in smallTable) b.withColor(b.color.withValues(alpha: b.color.a * 0.45)), ] : derived.smallInner, - lineDark: lineBlobs ?? derived.lineDark, - lineLight: lineBlobs ?? derived.lineLight, + lineDark: lineTable ?? derived.lineDark, + lineLight: lineTable ?? derived.lineLight, lineInner: derived.lineInner, lineBloomDark: derived.lineBloomDark, lineBloomLight: derived.lineBloomLight, ), ); } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _SpecBeamColors && + listEquals(other.border, border) && + listEquals(other.smallBorder, smallBorder) && + listEquals(other.lineBlobs, lineBlobs); + + @override + int get hashCode => Object.hash( + Object.hashAll(border), + smallBorder == null ? null : Object.hashAll(smallBorder!), + lineBlobs == null ? null : Object.hashAll(lineBlobs!), + ); + + @override + String toString() => + 'BeamColors.spec(border: ${border.length} blobs, ' + 'smallBorder: ${smallBorder?.length}, lineBlobs: ${lineBlobs?.length})'; } diff --git a/lib/src/models/beam_config.dart b/lib/src/models/beam_config.dart index 5ee88a2..01ecf78 100644 --- a/lib/src/models/beam_config.dart +++ b/lib/src/models/beam_config.dart @@ -1,9 +1,30 @@ import 'dart:ui'; -import '../constants/theme_presets.dart'; +import 'package:flutter/painting.dart'; + +import 'beam_options.dart'; import 'beam_palette.dart'; +import 'beam_segment.dart'; +import 'beam_shape.dart'; +import 'beam_style.dart'; import 'beam_theme_config.dart'; +import 'beam_timing.dart'; import 'beam_variant.dart'; +import 'model_validation.dart'; + +// The line variant's breathe/spike tracks run at multiples of the cycle. +const double _defaultBreatheFactor = 1.3; +const double _defaultSpikeFactor = 1.33; +const double _defaultSpike2Factor = 1.7; + +// The window BeamStyle.renderScale is clamped to: below a quarter the +// magnification is coarse enough to read as a blur, and above 1 the beam +// would be painted larger than the box it fills. +const double _minRenderScale = 0.25; +const double _maxRenderScale = 1; + +double _seconds(Duration d) => + d.inMicroseconds / Duration.microsecondsPerSecond; /// The fully-resolved, frame-invariant configuration handed to the painter. /// @@ -26,6 +47,13 @@ class BeamConfig { required this.hueBase, required this.staticColors, required this.cycleSeconds, + required this.hueMode, + required this.huePeriodSeconds, + required this.bloomHuePeriodSeconds, + this.gapSeconds = 0, + this.breatheFactor = _defaultBreatheFactor, + this.spikeFactor = _defaultSpikeFactor, + this.spike2Factor = _defaultSpike2Factor, this.strokeOpacityFactor = 1, this.innerOpacityFactor = 1, this.bloomOpacityFactor = 1, @@ -34,62 +62,103 @@ class BeamConfig { this.bloomBlur, this.glowBrightness, this.glowSaturation, + this.tailLength = 1, + this.glowSpread = 1, + this.comet = false, + this.sparkle = 0, + this.segments, + this.innerSizeScale = 1, + this.renderScale = 1, + this.pulseOutsideTuning = BeamPulseOutsideTuning.demo, + this.edge = BeamEdge.bottom, + this.ringOffset = 0, + this.contour, + this.segment, + this.wrapCorners = false, + this.direction = BeamDirection.forward, + this.phaseOffset = 0, + this.beamCount = 1, }); - /// Resolves user-facing parameters (nullable overrides) against the - /// variant/theme presets, mirroring the React component's computed values. + /// Resolves the beam value objects against the variant/theme presets, + /// mirroring the React component's computed values. + /// + /// [palette] is the resolved form of `style.colors` and [brightness] the + /// resolved form of `style.theme` — both are settled by the widget, which + /// owns the ambient theme. [textDirection] resolves `shape.radius`. factory BeamConfig.resolve({ required BeamVariant variant, required BeamPalette palette, required Brightness brightness, - double? borderRadius, - double? borderWidth, - bool useSuperellipse = false, - double strength = 1, - double? brightnessFactor, - double? saturation, - double hueRange = 30, - double hueBase = 0, - bool staticColors = false, - Duration? cycleDuration, - double strokeOpacityFactor = 1, - double innerOpacityFactor = 1, - double bloomOpacityFactor = 1, - double glowBoost = 1, - double? coreBlur, - double? bloomBlur, - double? glowBrightness, - double? glowSaturation, + BeamStyle style = const BeamStyle(), + BeamShape shape = const BeamShape(), + BeamTiming timing = const BeamTiming(), + TextDirection textDirection = TextDirection.ltr, }) { - final theme = themePresetFor(variant, brightness); + validateBeamTiming(timing); + final theme = + style.themeConfig ?? BeamThemeConfig.presetFor(variant, brightness); + final cycleSeconds = _seconds(timing.cycle ?? variant.defaultCycleDuration); + final hueRange = style.hueRange ?? 30; return BeamConfig( variant: variant, palette: palette, theme: theme, brightness: brightness, - borderRadius: borderRadius ?? variant.defaultBorderRadius, - borderWidth: borderWidth ?? variant.defaultBorderWidth, - useSuperellipse: useSuperellipse, - strength: strength.clamp(0.0, 1.0), - brightnessFactor: brightnessFactor ?? theme.brightness ?? 1.3, - saturation: saturation ?? theme.saturation, + borderRadius: + shape.radius?.resolve(textDirection) ?? + BorderRadius.circular(variant.defaultBorderRadius), + borderWidth: shape.borderWidth ?? variant.defaultBorderWidth, + useSuperellipse: shape.superellipse ?? false, + strength: (style.strength ?? 1).clamp(0.0, 1.0), + brightnessFactor: style.brightness ?? theme.brightness ?? 1.3, + saturation: style.saturation ?? theme.saturation, // The line variant caps the hue range at 13°, as in the source. hueRange: variant == BeamVariant.line ? (hueRange > 13 ? 13.0 : hueRange) : hueRange, - hueBase: hueBase, - staticColors: staticColors || palette.forcesStaticColors, - cycleSeconds: - (cycleDuration ?? variant.defaultCycleDuration).inMicroseconds / - Duration.microsecondsPerSecond, - strokeOpacityFactor: strokeOpacityFactor, - innerOpacityFactor: innerOpacityFactor, - bloomOpacityFactor: bloomOpacityFactor, - glowBoost: glowBoost, - coreBlur: coreBlur, - bloomBlur: bloomBlur, - glowBrightness: glowBrightness, - glowSaturation: glowSaturation, + hueMode: + style.hueMode ?? + (variant.isPulse ? BeamHueMode.continuous : BeamHueMode.pingPong), + hueBase: style.hueBase ?? 0, + staticColors: (style.staticColors ?? false) || palette.forcesStaticColors, + cycleSeconds: cycleSeconds, + gapSeconds: _seconds(timing.cycleGap ?? Duration.zero), + huePeriodSeconds: _seconds(timing.huePeriod ?? variant.defaultHuePeriod), + bloomHuePeriodSeconds: _seconds( + timing.bloomHuePeriod ?? variant.defaultBloomHuePeriod, + ), + breatheFactor: timing.breatheFactor ?? _defaultBreatheFactor, + spikeFactor: timing.spikeFactor ?? _defaultSpikeFactor, + spike2Factor: timing.spike2Factor ?? _defaultSpike2Factor, + strokeOpacityFactor: style.strokeOpacityFactor ?? 1, + innerOpacityFactor: style.innerOpacityFactor ?? 1, + bloomOpacityFactor: style.bloomOpacityFactor ?? 1, + glowBoost: style.glowBoost ?? 1, + coreBlur: style.coreBlur, + bloomBlur: style.bloomBlur, + glowBrightness: style.glowBrightness, + glowSaturation: style.glowSaturation, + tailLength: style.tailLength ?? 1, + glowSpread: style.glowSpread ?? 1, + comet: style.comet ?? false, + sparkle: (style.sparkle ?? 0).clamp(0.0, 1.0), + segments: style.segments, + innerSizeScale: style.innerSizeScale ?? 1, + renderScale: (style.renderScale ?? 1).clamp( + _minRenderScale, + _maxRenderScale, + ), + pulseOutsideTuning: + style.pulseOutsideTuning ?? BeamPulseOutsideTuning.demo, + edge: shape.edge ?? BeamEdge.bottom, + ringOffset: shape.ringOffset ?? 0, + contour: shape.contour, + segment: shape.segment, + wrapCorners: shape.wrapCorners ?? false, + direction: timing.direction ?? BeamDirection.forward, + phaseOffset: timing.phaseOffset ?? 0, + beamCount: timing.beamCount ?? 1, ); } @@ -105,8 +174,9 @@ class BeamConfig { /// Resolved brightness (dark/light) the preset was chosen for. final Brightness brightness; - /// Corner radius of the beam shape in logical px. - final double borderRadius; + /// Corner radii of the beam shape in logical px — per corner, already + /// resolved against the ambient text direction. + final BorderRadius borderRadius; /// Stroke ring thickness in logical px. final double borderWidth; @@ -126,6 +196,9 @@ class BeamConfig { /// Hue animation amplitude in degrees (rotate/small/line ping-pong). final double hueRange; + /// Whether the hue swings across ±[hueRange] or revolves continuously. + final BeamHueMode hueMode; + /// Static hue offset in degrees added to the animated hue. final double hueBase; @@ -135,6 +208,24 @@ class BeamConfig { /// Seconds per animation cycle. final double cycleSeconds; + /// Seconds the beam rests at the end of its travel between sweeps. + final double gapSeconds; + + /// Seconds for one full period of the hue track. + final double huePeriodSeconds; + + /// Seconds for one full period of the line bloom's hue track. + final double bloomHuePeriodSeconds; + + /// The line beam's height-breathe period as a multiple of [cycleSeconds]. + final double breatheFactor; + + /// The line beam's first spike period as a multiple of [cycleSeconds]. + final double spikeFactor; + + /// The line beam's second spike period as a multiple of [cycleSeconds]. + final double spike2Factor; + /// Stroke ring opacity multiplier hook (React `--beam-stroke-opacity`). final double strokeOpacityFactor; @@ -158,4 +249,223 @@ class BeamConfig { /// Pulse-outside glow saturation override (React `--beam-glow-saturate`). final double? glowSaturation; + + /// Multiplier on the angular width of the traveling window — the length of + /// the beam's tail. + final double tailLength; + + /// Multiplier on how far the bloom and halo layers reach past the ring. + final double glowSpread; + + /// Whether a soft halo trails the traveling head outside the ring. + final bool comet; + + /// Density 0–1 of the twinkles scattered at the traveling beam's head. + final double sparkle; + + /// Number of dashes the ring is broken into, or null for a solid ring. + final int? segments; + + /// Multiplier on the pulse-inside inner wash's blobs and corner accents. + final double innerSizeScale; + + /// The fraction of the box the beam is painted at before being magnified + /// back to fill it, clamped to 0.25–1. + final double renderScale; + + /// Which pulse-outside glow geometry to paint. + final BeamPulseOutsideTuning pulseOutsideTuning; + + /// Which edge the line variant's beam travels along. + final BeamEdge edge; + + /// Logical px the ring sits outside (+) or inside (−) the child's bounds. + final double ringOffset; + + /// An arbitrary contour replacing the rounded rectangle, or null to build + /// the contour from [borderRadius] and [useSuperellipse]. + final BeamContour? contour; + + /// The visible clockwise portion of the contour, or null for the full ring. + final BeamSegment? segment; + + /// Whether line blobs bend through corners in border-path space. + final bool wrapCorners; + + /// Which way the beam travels around the contour. + final BeamDirection direction; + + /// Fraction of a cycle, 0–1, the timeline starts at. + final double phaseOffset; + + /// How many beams travel the contour at once, spaced equally along the + /// cycle. + final int beamCount; + + /// This config re-authored for a box [factor] the size of the real one. + /// + /// Only the lengths measured against the box travel with it — the corner + /// radii, the ring's thickness, and its offset. Everything the palettes fix + /// in absolute px (blob sizes, blur radii, corner accents) deliberately + /// stays put: painting those at [factor] and magnifying the result back is + /// exactly what makes a palette authored for a card read on a screen-sized + /// box. [renderScale] is spent by the copy, so a painter cannot scale + /// twice. + BeamConfig scaledBy(double factor) => BeamConfig( + variant: variant, + palette: palette, + theme: theme, + brightness: brightness, + borderRadius: borderRadius * factor, + borderWidth: borderWidth * factor, + useSuperellipse: useSuperellipse, + strength: strength, + brightnessFactor: brightnessFactor, + saturation: saturation, + hueRange: hueRange, + hueBase: hueBase, + staticColors: staticColors, + cycleSeconds: cycleSeconds, + hueMode: hueMode, + huePeriodSeconds: huePeriodSeconds, + bloomHuePeriodSeconds: bloomHuePeriodSeconds, + gapSeconds: gapSeconds, + breatheFactor: breatheFactor, + spikeFactor: spikeFactor, + spike2Factor: spike2Factor, + strokeOpacityFactor: strokeOpacityFactor, + innerOpacityFactor: innerOpacityFactor, + bloomOpacityFactor: bloomOpacityFactor, + glowBoost: glowBoost, + coreBlur: coreBlur, + bloomBlur: bloomBlur, + glowBrightness: glowBrightness, + glowSaturation: glowSaturation, + tailLength: tailLength, + glowSpread: glowSpread, + comet: comet, + sparkle: sparkle, + segments: segments, + innerSizeScale: innerSizeScale, + renderScale: 1, + pulseOutsideTuning: pulseOutsideTuning, + edge: edge, + ringOffset: ringOffset * factor, + contour: contour, + segment: segment?.scaledBy(factor), + wrapCorners: wrapCorners, + direction: direction, + phaseOffset: phaseOffset, + beamCount: beamCount, + ); + + /// Two configs are equal when every painted value is, which is what lets + /// `BeamPainter.shouldRepaint` compare configs rather than identities. + /// + /// [palette] compares by identity: `BeamColors.resolve` memoizes one + /// palette per color instance, so equal color choices reaching one widget + /// resolve to the identical palette. + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamConfig && + other.variant == variant && + other.palette == palette && + other.theme == theme && + other.brightness == brightness && + other.borderRadius == borderRadius && + other.borderWidth == borderWidth && + other.useSuperellipse == useSuperellipse && + other.strength == strength && + other.brightnessFactor == brightnessFactor && + other.saturation == saturation && + other.hueRange == hueRange && + other.hueMode == hueMode && + other.hueBase == hueBase && + other.staticColors == staticColors && + other.cycleSeconds == cycleSeconds && + other.gapSeconds == gapSeconds && + other.huePeriodSeconds == huePeriodSeconds && + other.bloomHuePeriodSeconds == bloomHuePeriodSeconds && + other.breatheFactor == breatheFactor && + other.spikeFactor == spikeFactor && + other.spike2Factor == spike2Factor && + other.strokeOpacityFactor == strokeOpacityFactor && + other.innerOpacityFactor == innerOpacityFactor && + other.bloomOpacityFactor == bloomOpacityFactor && + other.glowBoost == glowBoost && + other.coreBlur == coreBlur && + other.bloomBlur == bloomBlur && + other.glowBrightness == glowBrightness && + other.glowSaturation == glowSaturation && + other.tailLength == tailLength && + other.glowSpread == glowSpread && + other.comet == comet && + other.sparkle == sparkle && + other.segments == segments && + other.innerSizeScale == innerSizeScale && + other.renderScale == renderScale && + other.pulseOutsideTuning == pulseOutsideTuning && + other.edge == edge && + other.ringOffset == ringOffset && + other.contour == contour && + other.segment == segment && + other.wrapCorners == wrapCorners && + other.direction == direction && + other.phaseOffset == phaseOffset && + other.beamCount == beamCount; + + @override + int get hashCode => Object.hashAll([ + variant, + palette, + theme, + brightness, + borderRadius, + borderWidth, + useSuperellipse, + strength, + brightnessFactor, + saturation, + hueRange, + hueMode, + hueBase, + staticColors, + cycleSeconds, + gapSeconds, + huePeriodSeconds, + bloomHuePeriodSeconds, + breatheFactor, + spikeFactor, + spike2Factor, + strokeOpacityFactor, + innerOpacityFactor, + bloomOpacityFactor, + glowBoost, + coreBlur, + bloomBlur, + glowBrightness, + glowSaturation, + tailLength, + glowSpread, + comet, + sparkle, + segments, + innerSizeScale, + renderScale, + pulseOutsideTuning, + edge, + ringOffset, + contour, + segment, + wrapCorners, + direction, + phaseOffset, + beamCount, + ]); + + @override + String toString() => + 'BeamConfig($variant, $brightness, radius: $borderRadius, ' + 'cycle: ${cycleSeconds}s, gap: ${gapSeconds}s, strength: $strength)'; } diff --git a/lib/src/models/beam_options.dart b/lib/src/models/beam_options.dart new file mode 100644 index 0000000..a6e9cc4 --- /dev/null +++ b/lib/src/models/beam_options.dart @@ -0,0 +1,190 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; + +/// The shape of a beam's hue track: a swing back and forth, or one +/// continuous revolution. +enum BeamHueMode { + /// The hue swings from `-hueRange` to `+hueRange` and back over one hue + /// period — the traveling variants' default. + pingPong, + + /// The hue advances through a full 360° revolution over one hue period, + /// never reversing — the pulse variants' default. + continuous, +} + +/// Which set of glow geometry the pulse-outside variant paints. +/// +/// The two differ only in how far the outward glow is grown past the child +/// and how heavily it is blurred; every other part of the variant is shared. +enum BeamPulseOutsideTuning { + /// The demo-hero recipe the source's demo page layers over its own + /// defaults: insets and blurs scaled by the element's size, melting the + /// separate blobs into one continuous edge-hugging glow. + /// + /// The default, because it is the pulse-outside look the library is known + /// for. + demo, + + /// The React library's own defaults: fixed insets and a per-brightness + /// blur, with no size-derived unit. + /// + /// Reach for it through `BeamStyle.pulseOutsideStock`, which also rolls + /// back the demo recipe's prominence and opacity multipliers. + stock, +} + +/// Which way a beam travels around its contour. +enum BeamDirection { + /// Clockwise for the rotate and small variants, left-to-right for the line + /// variant. + forward, + + /// The mirror of [forward]: counter-clockwise, or right-to-left. + reverse, + + /// Alternates each cycle, running [forward] on one sweep and [reverse] on + /// the next. + bounce, +} + +/// Which edge of the box the line variant's beam rides. +enum BeamEdge { + /// The top edge, travelling horizontally. + top, + + /// The right edge, travelling vertically. + right, + + /// The bottom edge, travelling horizontally — the line variant's default. + bottom, + + /// The left edge, travelling vertically. + left, +} + +/// What a beam does when the platform asks for reduced motion +/// (`MediaQuery.disableAnimationsOf`). +enum BeamReducedMotion { + /// Ignores the request and keeps animating. + animate, + + /// Paints a single static frame of the effect instead of animating — the + /// default. + staticFrame, + + /// Paints nothing at all, leaving the child bare. + hide, + + /// Keeps animating at a quarter of the configured speed. + slow, +} + +/// How many cycles a beam runs before it stops. +/// +/// A beam that reaches its last cycle fades out the way an inactive one +/// does, rather than cutting off mid-sweep. +/// +/// ```dart +/// BorderBeam.rotate( +/// playback: const BeamPlayback(repeat: BeamRepeat.count(3)), +/// child: card, +/// ) +/// ``` +@immutable +class BeamRepeat { + /// Loops forever — the default. + const BeamRepeat.forever() : cycles = null; + + /// Runs exactly one cycle, then stops. + const BeamRepeat.once() : cycles = 1; + + /// Runs [n] cycles, then stops. [n] must be at least 1. + const BeamRepeat.count(int n) + : assert(n >= 1, 'BeamRepeat.count needs at least one cycle'), + cycles = n; + + /// How many cycles to run, or null to loop forever. + final int? cycles; + + @override + bool operator ==(Object other) => + identical(this, other) || other is BeamRepeat && other.cycles == cycles; + + @override + int get hashCode => cycles.hashCode; + + @override + String toString() => + cycles == null ? 'BeamRepeat.forever()' : 'BeamRepeat.count($cycles)'; +} + +/// An arbitrary outer contour for the beam to travel, replacing the rounded +/// rectangle built from `BeamShape.radius`. +/// +/// The beam's ring, its masks, and the path the traveling head follows are +/// all built from [build], so any closed path works — a notched card, a +/// speech bubble, a hand-drawn blob. +/// +/// A contour is a value: `BeamConfig` keys its cache on it, so **every +/// implementer must override `==` and `hashCode`**. A contour that compares +/// by identity re-resolves the config on every rebuild. [BeamPathContour] +/// shows the pattern — it compares on an explicit key, because two closures +/// that draw the same path are never equal to each other. +@immutable +abstract class BeamContour { + /// Allows subclasses to be const. + const BeamContour(); + + /// Builds the contour path for a beam occupying [rect]. + /// + /// Called with the beam's own bounds, already grown or shrunk by + /// `BeamShape.ringOffset`. Return a closed path in the same coordinate + /// space. + Path build(Rect rect); +} + +/// A [BeamContour] that delegates to a builder function, comparing on an +/// explicit [key]. +/// +/// ```dart +/// BeamShape( +/// contour: BeamPathContour( +/// (rect) => Path()..addOval(rect), +/// key: 'oval', +/// ), +/// ) +/// ``` +class BeamPathContour extends BeamContour { + /// Creates a contour that calls [builder], and compares equal to another + /// [BeamPathContour] carrying an equal [key]. + const BeamPathContour(this.builder, {required this.key}); + + /// Draws the contour for the beam's bounds. + final Path Function(Rect rect) builder; + + /// The value this contour compares and hashes on. + /// + /// Two contours built from the same drawing take the same key — Dart + /// closures compare by identity, so an inline builder would otherwise make + /// every rebuild a new value. Any value-equal object works: a string, an + /// enum, or a `Record` of the parameters the builder closes over. + final Object key; + + @override + Path build(Rect rect) => builder(rect); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamPathContour && + other.runtimeType == runtimeType && + other.key == key; + + @override + int get hashCode => Object.hash(runtimeType, key); + + @override + String toString() => 'BeamPathContour($key)'; +} diff --git a/lib/src/models/beam_palette.dart b/lib/src/models/beam_palette.dart index 63cac2f..ed58946 100644 --- a/lib/src/models/beam_palette.dart +++ b/lib/src/models/beam_palette.dart @@ -27,4 +27,28 @@ class BeamPalette { /// Whether the line variant applies its grayscale spike attenuation /// (alpha rescale, wider/shorter spikes, extra bloom blur). final bool monoTreatment; + + /// Two palettes are equal when their tables and paint-time modifiers are. + /// + /// [BeamColors.resolve] hands out one palette per distinct color choice, + /// so this normally short-circuits on identity; the structural path + /// covers palettes rebuilt after a cache eviction. + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamPalette && + other.data == data && + other.forcesStaticColors == forcesStaticColors && + other.opacityMultiplier == opacityMultiplier && + other.monoTreatment == monoTreatment; + + @override + int get hashCode => + Object.hash(data, forcesStaticColors, opacityMultiplier, monoTreatment); + + @override + String toString() => + 'BeamPalette(forcesStaticColors: $forcesStaticColors, ' + 'opacityMultiplier: $opacityMultiplier, ' + 'monoTreatment: $monoTreatment)'; } diff --git a/lib/src/models/beam_playback.dart b/lib/src/models/beam_playback.dart new file mode 100644 index 0000000..01d5037 --- /dev/null +++ b/lib/src/models/beam_playback.dart @@ -0,0 +1,188 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; + +import 'beam_options.dart'; + +/// When a beam plays: whether it is on, whether it starts by itself, how +/// long it runs, how many cycles it repeats, and what it does under reduced +/// motion. +/// +/// Every field is nullable and means *inherit*. A field is resolved in this +/// order: the value set on the widget (the `active:` shorthand wins over +/// [active]), then the nearest `BorderBeamTheme`, then the default below. +/// +/// A `BorderBeamController` takes playback over entirely: with one attached, +/// [startAfter] and [duration] must be null, and [active] and [autoPlay] are +/// ignored. +/// +/// ```dart +/// BorderBeam.pulseInside( +/// playback: BeamPlayback( +/// active: isWorking, +/// startAfter: const Duration(milliseconds: 500), +/// ), +/// child: card, +/// ) +/// ``` +@immutable +class BeamPlayback { + /// Creates a playback schedule. Every omitted field is inherited. + const BeamPlayback({ + this.active, + this.autoPlay, + this.startAfter, + this.duration, + this.repeat, + this.reducedMotion, + this.pauseWhenOffscreen, + this.fadeCurve, + this.debugFrozenAt, + }); + + /// The web platform's `ease` timing function, as a [Curve]. + /// + /// The fades default to a spring, which carries a little momentum. Hand + /// this to [fadeCurve] to get the CSS transition the source library's own + /// page fades with instead. + static const Curve cssEase = Cubic(0.25, 0.1, 0.25, 1); + + /// Declarative play state: toggling fades the beam in (0.6s) and out + /// (0.5s). Default true. + final bool? active; + + /// Whether the beam starts by itself. Default true. + final bool? autoPlay; + + /// Delay before autoplay starts. Null starts immediately. + final Duration? startAfter; + + /// Total play time before the beam fades out by itself. Null plays forever. + final Duration? duration; + + /// How many cycles the beam runs before it fades out by itself. Default + /// [BeamRepeat.forever]. + final BeamRepeat? repeat; + + /// What the beam does when `MediaQuery.disableAnimationsOf` asks for + /// reduced motion: a single static frame (the default), nothing at all, + /// quarter-speed motion, or full motion regardless. + final BeamReducedMotion? reducedMotion; + + /// Whether the beam's clock stops while the beam is scrolled out of its + /// enclosing scrollable, with a 256px margin. Default true. + /// + /// A list of beams costs one ticker each; this is what keeps the ones + /// nobody can see from spending frames. The beam is only *paused* — its + /// play state, its fade, and its callbacks are untouched, so it comes back + /// on screen exactly where it left off rather than restarting. + /// + /// It watches the nearest enclosing `Scrollable` and does nothing when + /// there is none. + final bool? pauseWhenOffscreen; + + /// The easing both fade envelopes run on. Null (the default) uses the + /// spring, which overshoots slightly and settles. + /// + /// [cssEase] is the web's own `ease`, for a fade that matches the source + /// library exactly. + final Curve? fadeCurve; + + /// Pins the beam to one instant of its timeline and never starts its + /// clock. + /// + /// Every animated value is a pure function of elapsed time, so a fixed + /// time is a fixed frame: two runs a week apart paint the same pixels. + /// That is what makes a beam screenshottable — golden tests, docs + /// captures, design reviews. + /// + /// It reads the timeline from activation, so anything past the 0.6s + /// fade-in is a fully-lit frame. The frozen frame is still sampled at the + /// live strength — [BeamStyle.strength] and + /// [BorderBeam.strengthListenable] dim it as they would any other frame, + /// and a strength of 0 paints nothing. + final Duration? debugFrozenAt; + + /// Returns a copy with the given fields replaced. A null argument keeps the + /// current value; build a new [BeamPlayback] to clear a field back to + /// inherit. + BeamPlayback copyWith({ + bool? active, + bool? autoPlay, + Duration? startAfter, + Duration? duration, + BeamRepeat? repeat, + BeamReducedMotion? reducedMotion, + bool? pauseWhenOffscreen, + Curve? fadeCurve, + Duration? debugFrozenAt, + }) => BeamPlayback( + active: active ?? this.active, + autoPlay: autoPlay ?? this.autoPlay, + startAfter: startAfter ?? this.startAfter, + duration: duration ?? this.duration, + repeat: repeat ?? this.repeat, + reducedMotion: reducedMotion ?? this.reducedMotion, + pauseWhenOffscreen: pauseWhenOffscreen ?? this.pauseWhenOffscreen, + fadeCurve: fadeCurve ?? this.fadeCurve, + debugFrozenAt: debugFrozenAt ?? this.debugFrozenAt, + ); + + /// Layers [other] over this playback: every non-null field of [other] wins, + /// every null one inherits from this playback. + BeamPlayback merge(BeamPlayback? other) => other == null + ? this + : copyWith( + active: other.active, + autoPlay: other.autoPlay, + startAfter: other.startAfter, + duration: other.duration, + repeat: other.repeat, + reducedMotion: other.reducedMotion, + pauseWhenOffscreen: other.pauseWhenOffscreen, + fadeCurve: other.fadeCurve, + debugFrozenAt: other.debugFrozenAt, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamPlayback && + other.active == active && + other.autoPlay == autoPlay && + other.startAfter == startAfter && + other.duration == duration && + other.repeat == repeat && + other.reducedMotion == reducedMotion && + other.pauseWhenOffscreen == pauseWhenOffscreen && + other.fadeCurve == fadeCurve && + other.debugFrozenAt == debugFrozenAt; + + @override + int get hashCode => Object.hash( + active, + autoPlay, + startAfter, + duration, + repeat, + reducedMotion, + pauseWhenOffscreen, + fadeCurve, + debugFrozenAt, + ); + + @override + String toString() { + final fields = [ + if (active != null) 'active: $active', + if (autoPlay != null) 'autoPlay: $autoPlay', + if (startAfter != null) 'startAfter: $startAfter', + if (duration != null) 'duration: $duration', + if (repeat != null) 'repeat: $repeat', + if (reducedMotion != null) 'reducedMotion: $reducedMotion', + if (pauseWhenOffscreen != null) 'pauseWhenOffscreen: $pauseWhenOffscreen', + if (fadeCurve != null) 'fadeCurve: $fadeCurve', + if (debugFrozenAt != null) 'debugFrozenAt: $debugFrozenAt', + ]; + return 'BeamPlayback(${fields.join(', ')})'; + } +} diff --git a/lib/src/models/beam_segment.dart b/lib/src/models/beam_segment.dart new file mode 100644 index 0000000..12229ce --- /dev/null +++ b/lib/src/models/beam_segment.dart @@ -0,0 +1,224 @@ +import 'package:flutter/foundation.dart'; + +import '../painting/ring_geometry.dart'; +import 'beam_options.dart'; + +/// A corner of a beam contour, in clockwise order. +enum BeamCorner { + /// The top-left corner. + topLeft, + + /// The top-right corner. + topRight, + + /// The bottom-right corner. + bottomRight, + + /// The bottom-left corner. + bottomLeft, +} + +/// A position on a beam's perimeter, resolved against its painted box. +@immutable +sealed class BeamAnchor { + /// Creates an anchor at arc-length fraction [t], clockwise from top-center. + /// + /// [t] is taken modulo one when the anchor is resolved. + const factory BeamAnchor.fraction(double t) = _FractionAnchor; + + /// Creates an anchor [t] of the way along an edge's straight run. + /// + /// Travel is clockwise: left-to-right on top, top-to-bottom on right, + /// right-to-left on bottom, and bottom-to-top on left. + const factory BeamAnchor.edge(BeamEdge edge, [double t]) = _EdgeAnchor; + + /// Creates an anchor [t] of the way through [corner], clockwise. + const factory BeamAnchor.corner(BeamCorner corner, [double t]) = + _CornerAnchor; + + const BeamAnchor._(); + + /// The top-center point of the perimeter. + static const BeamAnchor topCenter = BeamAnchor.fraction(0); + + /// The center of the right edge's straight run. + static const BeamAnchor rightCenter = BeamAnchor.edge(BeamEdge.right); + + /// The bottom-center point of the perimeter. + static const BeamAnchor bottomCenter = BeamAnchor.edge(BeamEdge.bottom); + + /// The center of the left edge's straight run. + static const BeamAnchor leftCenter = BeamAnchor.edge(BeamEdge.left); + + /// Resolves this anchor to an arc-length fraction on [perimeter]. + double resolve(BeamPerimeter perimeter); +} + +final class _FractionAnchor extends BeamAnchor { + const _FractionAnchor(this.t) : super._(); + + final double t; + + @override + double resolve(BeamPerimeter perimeter) => t % 1 < 0 ? t % 1 + 1 : t % 1; + + @override + bool operator ==(Object other) => + identical(this, other) || other is _FractionAnchor && other.t == t; + + @override + int get hashCode => Object.hash(_FractionAnchor, t); + + @override + String toString() => 'BeamAnchor.fraction($t)'; +} + +final class _EdgeAnchor extends BeamAnchor { + const _EdgeAnchor(this.edge, [this.t = 0.5]) : super._(); + + final BeamEdge edge; + final double t; + + @override + double resolve(BeamPerimeter perimeter) => perimeter.fractionOfEdge(edge, t); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _EdgeAnchor && other.edge == edge && other.t == t; + + @override + int get hashCode => Object.hash(_EdgeAnchor, edge, t); + + @override + String toString() => 'BeamAnchor.edge($edge, $t)'; +} + +final class _CornerAnchor extends BeamAnchor { + const _CornerAnchor(this.corner, [this.t = 0.5]) : super._(); + + final BeamCorner corner; + final double t; + + @override + double resolve(BeamPerimeter perimeter) => + perimeter.fractionOfCorner(corner, t); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _CornerAnchor && other.corner == corner && other.t == t; + + @override + int get hashCode => Object.hash(_CornerAnchor, corner, t); + + @override + String toString() => 'BeamAnchor.corner($corner, $t)'; +} + +/// A clockwise portion of the perimeter over which a beam is visible. +/// +/// A segment masks an unchanged full-ring animation: constants and blob +/// positions never move. The traveling beam enters at [start] and leaves at +/// [end], the line variant travels the segment, and pulse blobs outside it +/// are hidden. Ends fade over [feather] logical pixels along the perimeter. +/// +/// A beam covering the lower half of a square runs from right-center, around +/// the bottom, to left-center: +/// +/// ```dart +/// BorderBeam.rotate( +/// shape: const BeamShape(segment: BeamSegment.bottomHalf), +/// child: card, +/// ) +/// ``` +@immutable +class BeamSegment { + /// Creates the clockwise segment from [start] to [end]. + const BeamSegment({ + required this.start, + required this.end, + this.feather = 32, + }); + + /// Where the clockwise visible portion begins. + final BeamAnchor start; + + /// Where the clockwise visible portion ends, wrapping through top-center + /// when its resolved fraction is smaller than [start]. + final BeamAnchor end; + + /// Logical pixels over which each end fades; zero makes a hard cut. + final double feather; + + /// The lower half, from right-center through bottom-center to left-center. + static const BeamSegment bottomHalf = BeamSegment( + start: BeamAnchor.rightCenter, + end: BeamAnchor.leftCenter, + ); + + /// The upper half, from left-center through top-center to right-center. + static const BeamSegment topHalf = BeamSegment( + start: BeamAnchor.leftCenter, + end: BeamAnchor.rightCenter, + ); + + /// The left half, from bottom-center through left-center to top-center. + static const BeamSegment leftHalf = BeamSegment( + start: BeamAnchor.bottomCenter, + end: BeamAnchor.topCenter, + ); + + /// The right half, from top-center through right-center to bottom-center. + static const BeamSegment rightHalf = BeamSegment( + start: BeamAnchor.topCenter, + end: BeamAnchor.bottomCenter, + ); + + /// The bottom straight run plus the complete bottom-right and bottom-left + /// corner arcs. + static const BeamSegment bottomEdge = BeamSegment( + start: BeamAnchor.corner(BeamCorner.bottomRight, 0), + end: BeamAnchor.corner(BeamCorner.bottomLeft, 1), + ); + + /// The top straight run plus the complete top-left and top-right corner + /// arcs. + static const BeamSegment topEdge = BeamSegment( + start: BeamAnchor.corner(BeamCorner.topLeft, 0), + end: BeamAnchor.corner(BeamCorner.topRight, 1), + ); + + /// The left straight run plus the complete bottom-left and top-left corner + /// arcs. + static const BeamSegment leftEdge = BeamSegment( + start: BeamAnchor.corner(BeamCorner.bottomLeft, 0), + end: BeamAnchor.corner(BeamCorner.topLeft, 1), + ); + + /// The right straight run plus the complete top-right and bottom-right + /// corner arcs. + static const BeamSegment rightEdge = BeamSegment( + start: BeamAnchor.corner(BeamCorner.topRight, 0), + end: BeamAnchor.corner(BeamCorner.bottomRight, 1), + ); + + /// Returns this segment with all logical lengths scaled by [factor]. + BeamSegment scaledBy(double factor) => + BeamSegment(start: start, end: end, feather: feather * factor); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamSegment && + other.start == start && + other.end == end && + other.feather == feather; + + @override + int get hashCode => Object.hash(start, end, feather); + + @override + String toString() => + 'BeamSegment(start: $start, end: $end, feather: $feather)'; +} diff --git a/lib/src/models/beam_shape.dart b/lib/src/models/beam_shape.dart new file mode 100644 index 0000000..feafa8c --- /dev/null +++ b/lib/src/models/beam_shape.dart @@ -0,0 +1,221 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +import 'beam_options.dart'; +import 'beam_segment.dart'; + +/// The geometry of a beam: its corner radii, ring thickness, corner family +/// (circular arcs or superellipse), how far the ring sits from the child, +/// and which edge the line variant rides. +/// +/// Every field is nullable and means *inherit*. A field is resolved in this +/// order: the value set on the widget (the `borderRadius` shorthand wins over +/// [radius]), then the nearest `BorderBeamTheme`, then the variant's preset +/// (radius 16, or 32 for `BeamVariant.small`; border width 1). +/// +/// ```dart +/// BorderBeam.rotate( +/// shape: const BeamShape.all(24, superellipse: true), +/// child: card, +/// ) +/// ``` +@immutable +class BeamShape { + /// Creates a shape with per-corner [radius]. Every omitted field is + /// inherited. + const BeamShape({ + BorderRadiusGeometry? radius, + this.borderWidth, + this.superellipse, + this.edge, + this.ringOffset, + this.contour, + this.segment, + this.wrapCorners, + }) : _radius = radius, + _uniformRadius = null; + + /// A shape whose four corners share one radius — the const path. + /// + /// The number is stored as given and grown into a [BorderRadius] where + /// [radius] is read, which is what keeps the constructor const: building a + /// [BorderRadius] from a parameter is a runtime construction. `all` and + /// [circular] compare equal for the same number. + const BeamShape.all( + double radius, { + this.borderWidth, + this.superellipse, + this.edge, + this.ringOffset, + this.contour, + this.segment, + this.wrapCorners, + }) : _uniformRadius = radius, + _radius = null; + + /// A shape whose four corners share one [radius], built as a + /// [BorderRadius]. + /// + /// Not `const` — for a const beam use [BeamShape.all], which stores the + /// number instead. Reach for this one when you already think in + /// [BorderRadius] terms. + BeamShape.circular( + double radius, { + this.borderWidth, + this.superellipse, + this.edge, + this.ringOffset, + this.contour, + this.segment, + this.wrapCorners, + }) : _radius = BorderRadius.circular(radius), + _uniformRadius = null; + + /// A pill: each corner rounds to half the shortest side of the box, so a + /// square box comes out a circle. + /// + /// The radius is infinite and the ring geometry clamps it per corner, which + /// is what makes it track the box as it resizes. + const BeamShape.stadium({ + this.borderWidth, + this.superellipse, + this.edge, + this.ringOffset, + this.contour, + this.segment, + this.wrapCorners, + }) : _radius = const BorderRadius.all(Radius.circular(double.infinity)), + _uniformRadius = null; + + final BorderRadiusGeometry? _radius; + final double? _uniformRadius; + + /// Corner radii of the beam contour, direction-aware. + /// + /// Resolved against the ambient [Directionality] (or LTR when there is + /// none), then clamped per corner the way [RRect.scaleRadii] does: when two + /// radii on one side exceed that side's length, all four scale down by the + /// smallest offending ratio. Match your child's decoration radius — the + /// beam does not read it. + /// + /// Ignored when [contour] is set. + BorderRadiusGeometry? get radius { + final uniform = _uniformRadius; + return uniform == null ? _radius : BorderRadius.circular(uniform); + } + + /// Stroke ring thickness in logical px. Default 1, as in the source. + final double? borderWidth; + + /// Whether the contour is a rounded superellipse (an Apple-style squircle) + /// instead of circular corner arcs. + /// + /// Defaults to false, matching the circular-arc corners of the source + /// library's CSS `border-radius`. Ignored when [contour] is set. + final bool? superellipse; + + /// Which edge the line variant's beam travels along. Default + /// [BeamEdge.bottom], as in the source. The other variants ignore it. + final BeamEdge? edge; + + /// Logical px the ring is pushed outward (positive) or pulled inward + /// (negative) from the child's bounds, so the beam can orbit at a distance + /// or tuck inside a padded surface. Default 0 — the ring sits on the + /// bounds. + final double? ringOffset; + + /// An arbitrary contour for the beam to travel instead of the rounded + /// rectangle built from [radius]. + /// + /// Default null. When set, [radius] and [superellipse] are ignored — the + /// path the contour builds is the whole geometry. + final BeamContour? contour; + + /// The clockwise portion of the contour on which the beam is visible. + /// + /// Null inherits, resolving to the full ring. + final BeamSegment? segment; + + /// Whether line-variant blobs use border-path space and bend around + /// corners instead of continuing straight past an edge. + /// + /// Null inherits, resolving to false. + final bool? wrapCorners; + + /// Returns a copy with the given fields replaced. A null argument keeps the + /// current value; build a new [BeamShape] to clear a field back to inherit. + BeamShape copyWith({ + BorderRadiusGeometry? radius, + double? borderWidth, + bool? superellipse, + BeamEdge? edge, + double? ringOffset, + BeamContour? contour, + BeamSegment? segment, + bool? wrapCorners, + }) => BeamShape( + radius: radius ?? this.radius, + borderWidth: borderWidth ?? this.borderWidth, + superellipse: superellipse ?? this.superellipse, + edge: edge ?? this.edge, + ringOffset: ringOffset ?? this.ringOffset, + contour: contour ?? this.contour, + segment: segment ?? this.segment, + wrapCorners: wrapCorners ?? this.wrapCorners, + ); + + /// Layers [other] over this shape: every non-null field of [other] wins, + /// every null one inherits from this shape. + BeamShape merge(BeamShape? other) => other == null + ? this + : copyWith( + radius: other.radius, + borderWidth: other.borderWidth, + superellipse: other.superellipse, + edge: other.edge, + ringOffset: other.ringOffset, + contour: other.contour, + segment: other.segment, + wrapCorners: other.wrapCorners, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamShape && + other.radius == radius && + other.borderWidth == borderWidth && + other.superellipse == superellipse && + other.edge == edge && + other.ringOffset == ringOffset && + other.contour == contour && + other.segment == segment && + other.wrapCorners == wrapCorners; + + @override + int get hashCode => Object.hash( + radius, + borderWidth, + superellipse, + edge, + ringOffset, + contour, + segment, + wrapCorners, + ); + + @override + String toString() { + final fields = [ + if (radius != null) 'radius: $radius', + if (borderWidth != null) 'borderWidth: $borderWidth', + if (superellipse != null) 'superellipse: $superellipse', + if (edge != null) 'edge: $edge', + if (ringOffset != null) 'ringOffset: $ringOffset', + if (contour != null) 'contour: $contour', + if (segment != null) 'segment: $segment', + if (wrapCorners != null) 'wrapCorners: $wrapCorners', + ]; + return 'BeamShape(${fields.join(', ')})'; + } +} diff --git a/lib/src/models/beam_style.dart b/lib/src/models/beam_style.dart new file mode 100644 index 0000000..68d68ba --- /dev/null +++ b/lib/src/models/beam_style.dart @@ -0,0 +1,395 @@ +import 'package:flutter/foundation.dart'; + +import '../constants/pulse_constants.dart'; +import 'beam_colors.dart'; +import 'beam_options.dart'; +import 'beam_theme.dart'; +import 'beam_theme_config.dart'; + +/// How a beam looks: its colors, how it adapts to the background, the shape +/// of its beam and glow, and every filter and layer-opacity hook ported from +/// the source's CSS custom properties. +/// +/// Every field is nullable and means *inherit*. A field is resolved in this +/// order: the value set on the widget (a shorthand such as `colors:` wins +/// over the same field of the widget's own style), then the nearest +/// `BorderBeamTheme`, then the variant's preset. +/// +/// ```dart +/// BorderBeam.rotate( +/// style: const BeamStyle(colors: BeamColors.ocean, strength: 0.6), +/// child: card, +/// ) +/// ``` +@immutable +class BeamStyle { + /// Creates a style. Every omitted field is inherited. + const BeamStyle({ + this.colors, + this.theme, + this.strength, + this.brightness, + this.saturation, + this.hueRange, + this.hueMode, + this.hueBase, + this.staticColors, + this.strokeOpacityFactor, + this.innerOpacityFactor, + this.bloomOpacityFactor, + this.glowBoost, + this.coreBlur, + this.bloomBlur, + this.glowBrightness, + this.glowSaturation, + this.tailLength, + this.glowSpread, + this.comet, + this.sparkle, + this.segments, + this.innerSizeScale, + this.renderScale, + this.pulseOutsideTuning, + this.themeConfig, + }); + + /// The pulse-outside look as the React library ships it, before the demo + /// page's tuning. + /// + /// `BorderBeam.pulseOutside` paints the tuned demo recipe by default — the + /// look beam.jakubantalik.com is known for. This style rolls every part of + /// that tuning back: the prominence boost, insets, blurs, and size-derived + /// unit through [pulseOutsideTuning], and the glow multiplier through the + /// three opacity factors and [glowBrightness]/[glowSaturation]. + /// + /// The result is a tighter, dimmer halo that sits closer to the child. + /// + /// ```dart + /// BorderBeam.pulseOutside( + /// style: BeamStyle.pulseOutsideStock, + /// child: card, + /// ) + /// ``` + /// + /// Layer your own fields over it with [copyWith] or [merge]; anything you + /// set wins, so `BeamStyle.pulseOutsideStock.copyWith(glowBoost: 1.4)` + /// keeps the stock geometry and enlarges both its foreground and glow + /// blobs from the same neutral baseline. + static const BeamStyle pulseOutsideStock = BeamStyle( + strokeOpacityFactor: 1 / pulseOuterTunedGlowMultiplier, + innerOpacityFactor: 1 / pulseOuterTunedGlowMultiplier, + bloomOpacityFactor: 1 / pulseOuterTunedGlowMultiplier, + glowBoost: 1, + glowBrightness: pulseOuterGlowBrightness, + glowSaturation: pulseOuterGlowSaturation, + pulseOutsideTuning: BeamPulseOutsideTuning.stock, + ); + + /// Color scheme: a preset, [BeamColors.custom], or [BeamColors.spec]. + /// Defaults to [BeamColors.colorful]. + final BeamColors? colors; + + /// Background adaptation: dark, light, or follow the ambient theme. + /// Defaults to [BeamTheme.auto]. + final BeamTheme? theme; + + /// Effect opacity 0–1 (clamped). Scales only the beam layers. Default 1. + final double? strength; + + /// Glow brightness multiplier; inherits the variant/theme preset. + final double? brightness; + + /// Glow saturation multiplier; inherits the variant/theme preset. + final double? saturation; + + /// Hue animation amplitude in degrees (default 30; the line variant caps it + /// at 13). Not used by pulse variants, whose hue cycles continuously. + final double? hueRange; + + /// Whether the hue swings back and forth across ±[hueRange] or advances + /// through one continuous 360° revolution. + /// + /// Defaults to [BeamHueMode.pingPong] for the traveling variants and + /// [BeamHueMode.continuous] for the pulse variants, matching the source. + final BeamHueMode? hueMode; + + /// Static hue offset in degrees added to the whole palette. Default 0. + final double? hueBase; + + /// Disables the hue animation. Default false; forced on by + /// [BeamColors.mono]. + final bool? staticColors; + + /// Stroke ring opacity multiplier (React `--beam-stroke-opacity`). + /// Default 1. + final double? strokeOpacityFactor; + + /// Inner glow opacity multiplier (React `--beam-inner-opacity`). Default 1. + final double? innerOpacityFactor; + + /// Bloom opacity multiplier (React `--beam-bloom-opacity`). Default 1. + final double? bloomOpacityFactor; + + /// Pulse glow prominence multiplier (React `--pulse-glow-boost`). + /// Default 1. + final double? glowBoost; + + /// pulse-outside core glow blur override in px (React `--beam-core-blur`). + final double? coreBlur; + + /// pulse-outside halo blur override in px (React `--beam-bloom-blur`). + final double? bloomBlur; + + /// pulse-outside glow brightness override + /// (React `--beam-glow-brightness`). + final double? glowBrightness; + + /// pulse-outside glow saturation override (React `--beam-glow-saturate`). + final double? glowSaturation; + + /// Multiplier on the angular width of the rotate/small traveling window: + /// above 1 the beam drags a longer tail behind its head, below 1 it + /// shortens to a point. Default 1. + final double? tailLength; + + /// Multiplier on how far the bloom and halo layers reach past the stroke + /// ring: above 1 the glow spreads wider and softer, below 1 it hugs the + /// border. Default 1. + final double? glowSpread; + + /// Whether a soft halo trails the traveling head outside the ring, giving + /// the rotate and small beams a comet tail. Default false. + final bool? comet; + + /// Density 0–1 (clamped) of the twinkles scattered at the traveling beam's + /// head. Default 0 — no sparkle. + final double? sparkle; + + /// Number of dashes the ring is broken into, spaced evenly around the + /// contour. Null (the default) keeps the ring solid. + final int? segments; + + /// Multiplier on the size of the pulse-inside inner wash — its blobs and + /// its corner accents. Default 1. + /// + /// Below 1 the wash pulls tighter to the border, leaving more of the child + /// clear; above 1 it floods further in. The perimeter ring and the bloom + /// keep their own geometry, so the border itself does not move. + /// + /// Only `BeamVariant.pulseInside` paints that layer; every other variant + /// ignores this. + final double? innerSizeScale; + + /// The fraction of the box the beam is *painted* at before being scaled + /// back up to fill it, 0.25–1 (clamped). Default 1 — no rescaling. + /// + /// The palettes are authored against a 350×140 card, so on a + /// screen-width box the blobs read as small and sparse. Painting at, say, + /// `0.5` and magnifying restores the proportions the palette was drawn + /// for: the glow, its blurs, and the corner radii all grow together, so + /// the beam reads the same at any size. + /// + /// It costs nothing — one canvas transform, no extra layer — but it is a + /// magnification, so the ring's own edge softens as the factor drops. + final double? renderScale; + + /// Which pulse-outside glow geometry to paint: the demo recipe (the + /// default) or the source's stock table. + /// + /// [BeamStyle.pulseOutsideStock] is the whole stock look; this field alone + /// changes only the insets and blurs. + final BeamPulseOutsideTuning? pulseOutsideTuning; + + /// Replaces the whole variant×brightness preset (layer opacities, inset + /// shadow, and the default brightness/saturation) with your own. + /// + /// [brightness] and [saturation] still override on top of it. Start from + /// [BeamThemeConfig.presetFor] to tweak a single field of a preset. + final BeamThemeConfig? themeConfig; + + /// Returns a copy with the given fields replaced. A null argument keeps the + /// current value; build a new [BeamStyle] to clear a field back to inherit. + BeamStyle copyWith({ + BeamColors? colors, + BeamTheme? theme, + double? strength, + double? brightness, + double? saturation, + double? hueRange, + BeamHueMode? hueMode, + double? hueBase, + bool? staticColors, + double? strokeOpacityFactor, + double? innerOpacityFactor, + double? bloomOpacityFactor, + double? glowBoost, + double? coreBlur, + double? bloomBlur, + double? glowBrightness, + double? glowSaturation, + double? tailLength, + double? glowSpread, + bool? comet, + double? sparkle, + int? segments, + double? innerSizeScale, + double? renderScale, + BeamPulseOutsideTuning? pulseOutsideTuning, + BeamThemeConfig? themeConfig, + }) => BeamStyle( + colors: colors ?? this.colors, + theme: theme ?? this.theme, + strength: strength ?? this.strength, + brightness: brightness ?? this.brightness, + saturation: saturation ?? this.saturation, + hueRange: hueRange ?? this.hueRange, + hueMode: hueMode ?? this.hueMode, + hueBase: hueBase ?? this.hueBase, + staticColors: staticColors ?? this.staticColors, + strokeOpacityFactor: strokeOpacityFactor ?? this.strokeOpacityFactor, + innerOpacityFactor: innerOpacityFactor ?? this.innerOpacityFactor, + bloomOpacityFactor: bloomOpacityFactor ?? this.bloomOpacityFactor, + glowBoost: glowBoost ?? this.glowBoost, + coreBlur: coreBlur ?? this.coreBlur, + bloomBlur: bloomBlur ?? this.bloomBlur, + glowBrightness: glowBrightness ?? this.glowBrightness, + glowSaturation: glowSaturation ?? this.glowSaturation, + tailLength: tailLength ?? this.tailLength, + glowSpread: glowSpread ?? this.glowSpread, + comet: comet ?? this.comet, + sparkle: sparkle ?? this.sparkle, + segments: segments ?? this.segments, + innerSizeScale: innerSizeScale ?? this.innerSizeScale, + renderScale: renderScale ?? this.renderScale, + pulseOutsideTuning: pulseOutsideTuning ?? this.pulseOutsideTuning, + themeConfig: themeConfig ?? this.themeConfig, + ); + + /// Layers [other] over this style: every non-null field of [other] wins, + /// every null one inherits from this style. + BeamStyle merge(BeamStyle? other) => other == null + ? this + : copyWith( + colors: other.colors, + theme: other.theme, + strength: other.strength, + brightness: other.brightness, + saturation: other.saturation, + hueRange: other.hueRange, + hueMode: other.hueMode, + hueBase: other.hueBase, + staticColors: other.staticColors, + strokeOpacityFactor: other.strokeOpacityFactor, + innerOpacityFactor: other.innerOpacityFactor, + bloomOpacityFactor: other.bloomOpacityFactor, + glowBoost: other.glowBoost, + coreBlur: other.coreBlur, + bloomBlur: other.bloomBlur, + glowBrightness: other.glowBrightness, + glowSaturation: other.glowSaturation, + tailLength: other.tailLength, + glowSpread: other.glowSpread, + comet: other.comet, + sparkle: other.sparkle, + segments: other.segments, + innerSizeScale: other.innerSizeScale, + renderScale: other.renderScale, + pulseOutsideTuning: other.pulseOutsideTuning, + themeConfig: other.themeConfig, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamStyle && + other.colors == colors && + other.theme == theme && + other.strength == strength && + other.brightness == brightness && + other.saturation == saturation && + other.hueRange == hueRange && + other.hueMode == hueMode && + other.hueBase == hueBase && + other.staticColors == staticColors && + other.strokeOpacityFactor == strokeOpacityFactor && + other.innerOpacityFactor == innerOpacityFactor && + other.bloomOpacityFactor == bloomOpacityFactor && + other.glowBoost == glowBoost && + other.coreBlur == coreBlur && + other.bloomBlur == bloomBlur && + other.glowBrightness == glowBrightness && + other.glowSaturation == glowSaturation && + other.tailLength == tailLength && + other.glowSpread == glowSpread && + other.comet == comet && + other.sparkle == sparkle && + other.segments == segments && + other.innerSizeScale == innerSizeScale && + other.renderScale == renderScale && + other.pulseOutsideTuning == pulseOutsideTuning && + other.themeConfig == themeConfig; + + @override + int get hashCode => Object.hashAll([ + colors, + theme, + strength, + brightness, + saturation, + hueRange, + hueMode, + hueBase, + staticColors, + strokeOpacityFactor, + innerOpacityFactor, + bloomOpacityFactor, + glowBoost, + coreBlur, + bloomBlur, + glowBrightness, + glowSaturation, + tailLength, + glowSpread, + comet, + sparkle, + segments, + innerSizeScale, + renderScale, + pulseOutsideTuning, + themeConfig, + ]); + + @override + String toString() { + final fields = [ + if (colors != null) 'colors: $colors', + if (theme != null) 'theme: $theme', + if (strength != null) 'strength: $strength', + if (brightness != null) 'brightness: $brightness', + if (saturation != null) 'saturation: $saturation', + if (hueRange != null) 'hueRange: $hueRange', + if (hueMode != null) 'hueMode: $hueMode', + if (hueBase != null) 'hueBase: $hueBase', + if (staticColors != null) 'staticColors: $staticColors', + if (strokeOpacityFactor != null) + 'strokeOpacityFactor: $strokeOpacityFactor', + if (innerOpacityFactor != null) 'innerOpacityFactor: $innerOpacityFactor', + if (bloomOpacityFactor != null) 'bloomOpacityFactor: $bloomOpacityFactor', + if (glowBoost != null) 'glowBoost: $glowBoost', + if (coreBlur != null) 'coreBlur: $coreBlur', + if (bloomBlur != null) 'bloomBlur: $bloomBlur', + if (glowBrightness != null) 'glowBrightness: $glowBrightness', + if (glowSaturation != null) 'glowSaturation: $glowSaturation', + if (tailLength != null) 'tailLength: $tailLength', + if (glowSpread != null) 'glowSpread: $glowSpread', + if (comet != null) 'comet: $comet', + if (sparkle != null) 'sparkle: $sparkle', + if (segments != null) 'segments: $segments', + if (innerSizeScale != null) 'innerSizeScale: $innerSizeScale', + if (renderScale != null) 'renderScale: $renderScale', + if (pulseOutsideTuning != null) 'pulseOutsideTuning: $pulseOutsideTuning', + if (themeConfig != null) 'themeConfig: $themeConfig', + ]; + return 'BeamStyle(${fields.join(', ')})'; + } +} diff --git a/lib/src/models/beam_theme_config.dart b/lib/src/models/beam_theme_config.dart index 2a177d4..19833fa 100644 --- a/lib/src/models/beam_theme_config.dart +++ b/lib/src/models/beam_theme_config.dart @@ -1,9 +1,26 @@ import 'dart:ui'; +import '../constants/theme_presets.dart'; +import 'beam_variant.dart'; + /// Theme-and-variant-tuned layer opacities and filter multipliers. /// /// Direct port of the React library's `ThemeColors` entries in -/// `sizeThemePresets`: each beam variant has one config per brightness. +/// `sizeThemePresets`: each beam variant has one config per brightness. Reach +/// for [presetFor] to start from a built-in preset, [copyWith] the one field +/// you want moved, and hand the result to `BeamStyle.themeConfig`. +/// +/// ```dart +/// BorderBeam.rotate( +/// style: BeamStyle( +/// themeConfig: BeamThemeConfig.presetFor( +/// BeamVariant.rotate, +/// Brightness.dark, +/// ).copyWith(bloomOpacity: 0.4), +/// ), +/// child: card, +/// ) +/// ``` class BeamThemeConfig { /// Creates a theme config. const BeamThemeConfig({ @@ -16,6 +33,12 @@ class BeamThemeConfig { this.hairlineOpacity, }); + /// The built-in preset for [variant] at [brightness]. + static BeamThemeConfig presetFor( + BeamVariant variant, + Brightness brightness, + ) => themePresetFor(variant, brightness); + /// Opacity of the stroke ring layer. May exceed 1 (intentional overdrive /// in the source, e.g. line/dark = 1.14); the painted product is clamped. final double strokeOpacity; @@ -39,4 +62,54 @@ class BeamThemeConfig { /// Opacity of the static 1px hairline (pulse-outside only; preset 0 so the /// wrapped child's own border provides the idle edge). final double? hairlineOpacity; + + /// Returns a copy with the given fields replaced. A null argument keeps the + /// current value. + BeamThemeConfig copyWith({ + double? strokeOpacity, + double? innerOpacity, + double? bloomOpacity, + Color? innerShadow, + double? saturation, + double? brightness, + double? hairlineOpacity, + }) => BeamThemeConfig( + strokeOpacity: strokeOpacity ?? this.strokeOpacity, + innerOpacity: innerOpacity ?? this.innerOpacity, + bloomOpacity: bloomOpacity ?? this.bloomOpacity, + innerShadow: innerShadow ?? this.innerShadow, + saturation: saturation ?? this.saturation, + brightness: brightness ?? this.brightness, + hairlineOpacity: hairlineOpacity ?? this.hairlineOpacity, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamThemeConfig && + other.strokeOpacity == strokeOpacity && + other.innerOpacity == innerOpacity && + other.bloomOpacity == bloomOpacity && + other.innerShadow == innerShadow && + other.saturation == saturation && + other.brightness == brightness && + other.hairlineOpacity == hairlineOpacity; + + @override + int get hashCode => Object.hash( + strokeOpacity, + innerOpacity, + bloomOpacity, + innerShadow, + saturation, + brightness, + hairlineOpacity, + ); + + @override + String toString() => + 'BeamThemeConfig(strokeOpacity: $strokeOpacity, ' + 'innerOpacity: $innerOpacity, bloomOpacity: $bloomOpacity, ' + 'innerShadow: $innerShadow, saturation: $saturation, ' + 'brightness: $brightness, hairlineOpacity: $hairlineOpacity)'; } diff --git a/lib/src/models/beam_timing.dart b/lib/src/models/beam_timing.dart new file mode 100644 index 0000000..75be6b5 --- /dev/null +++ b/lib/src/models/beam_timing.dart @@ -0,0 +1,243 @@ +import 'package:flutter/foundation.dart'; + +import 'beam_options.dart'; + +/// How fast a beam moves: cycle length, the rest between sweeps, playback +/// rate, travel direction, and the periods of the tracks that do not ride +/// the cycle. +/// +/// Every field is nullable and means *inherit*. A field is resolved in this +/// order: the value set on the widget, then the nearest `BorderBeamTheme`, +/// then the variant's preset. +/// +/// ```dart +/// BorderBeam.rotate( +/// timing: const BeamTiming( +/// cycle: Duration(seconds: 3), +/// cycleGap: Duration(seconds: 1), +/// ), +/// child: card, +/// ) +/// ``` +@immutable +class BeamTiming { + /// Creates a timing. Every omitted field is inherited. + const BeamTiming({ + this.cycle, + this.cycleGap, + this.speed, + this.direction, + this.phaseOffset, + this.beamCount, + this.huePeriod, + this.bloomHuePeriod, + this.breatheFactor, + this.spikeFactor, + this.spike2Factor, + }) : assert( + speed == null || (speed > 0 && speed < double.infinity), + 'speed must be finite and positive', + ), + assert( + phaseOffset == null || (phaseOffset >= 0 && phaseOffset <= 1), + 'phaseOffset must be between 0 and 1', + ), + assert( + beamCount == null || beamCount >= 1, + 'beamCount must be at least 1', + ), + assert( + breatheFactor == null || + (breatheFactor > 0 && breatheFactor < double.infinity), + 'breatheFactor must be finite and positive', + ), + assert( + spikeFactor == null || + (spikeFactor > 0 && spikeFactor < double.infinity), + 'spikeFactor must be finite and positive', + ), + assert( + spike2Factor == null || + (spike2Factor > 0 && spike2Factor < double.infinity), + 'spike2Factor must be finite and positive', + ); + + /// Length of one animation cycle. Defaults to the variant preset: 1.96s for + /// rotate and small, 3.1s for line, 2.3s for the pulse variants. + /// + /// Changing it while the beam runs retimes the animation in place: every + /// track keeps the phase it was at, so the beam speeds up or slows down + /// without a jump. + final Duration? cycle; + + /// Rest between sweeps: after each cycle the beam parks at the end of its + /// travel and fades away for this long before the next sweep starts. + /// Default [Duration.zero] — one sweep runs straight into the next. + /// + /// The fade at each end of the gap takes `min(0.25s, gap / 2)`. Hue, + /// breathe, and spike tracks are textures rather than the sweep, so they + /// keep running through the gap. + /// + /// The pulse variants ignore it: their breathing has no cycle boundary to + /// rest at. Changing it needs no retime — the sweep keeps its position and + /// the gap simply appears at the next cycle end. + final Duration? cycleGap; + + /// Playback rate multiplier; must be positive. Default 1. + /// + /// Ignored while a `BorderBeamController` is attached — the controller's + /// own `speed` owns the rate then. + final double? speed; + + /// Which way the beam travels: clockwise (or left-to-right for the line + /// variant), mirrored, or alternating each cycle. Default + /// [BeamDirection.forward]. The pulse variants have no travel to direct. + final BeamDirection? direction; + + /// Fraction of a cycle, 0–1, the timeline starts at, so two beams on the + /// same cycle can run out of step. Default 0 — the cycle starts at its + /// beginning. + final double? phaseOffset; + + /// How many beams travel the contour at once, spaced equally along the + /// cycle. Must be at least 1; default 1. + final int? beamCount; + + /// One full period of the hue track. + /// + /// Defaults to 12s for rotate, small, and line (a ping-pong across + /// ±`hueRange`), and to the pulse presets' own periods — 16s for + /// pulse-inside, 14s for pulse-outside (a continuous revolution). Setting + /// it overrides the period for every variant. + /// + /// Changing it mid-run re-phases the hue track — see [breatheFactor]. + final Duration? huePeriod; + + /// Period of the line variant's separate bloom hue track, a ping-pong + /// across ±(`hueRange` + 10)°. Default 8s. + /// + /// Changing it mid-run re-phases the bloom hue track — see [breatheFactor]. + final Duration? bloomHuePeriod; + + /// The line beam's height-breathe period, as a multiple of [cycle]. + /// Default 1.3. + /// + /// Changing a track's own period mid-run **re-phases that track**: it + /// resumes at `elapsed / newPeriod` rather than holding the fraction it + /// had, so the line's height jumps once and then continues smoothly. The + /// in-place retiming [cycle] documents is a different thing — it rescales + /// elapsed time, which every cycle-derived track (this one included) rides + /// through without a jump. There is no way to change a period *and* keep + /// its phase, because the two describe different animations; the same + /// applies to [spikeFactor], [spike2Factor], [huePeriod], and + /// [bloomHuePeriod]. Set these once, or accept the single step. + final double? breatheFactor; + + /// The line beam's first spike-scale period, as a multiple of [cycle]. + /// Default 1.33. + /// + /// Changing it mid-run re-phases the spike track — see [breatheFactor]. + final double? spikeFactor; + + /// The line beam's second spike-scale period, as a multiple of [cycle]. + /// Default 1.7. + /// + /// Changing it mid-run re-phases the spike track — see [breatheFactor]. + final double? spike2Factor; + + /// Returns a copy with the given fields replaced. A null argument keeps the + /// current value; build a new [BeamTiming] to clear a field back to + /// inherit. + BeamTiming copyWith({ + Duration? cycle, + Duration? cycleGap, + double? speed, + BeamDirection? direction, + double? phaseOffset, + int? beamCount, + Duration? huePeriod, + Duration? bloomHuePeriod, + double? breatheFactor, + double? spikeFactor, + double? spike2Factor, + }) => BeamTiming( + cycle: cycle ?? this.cycle, + cycleGap: cycleGap ?? this.cycleGap, + speed: speed ?? this.speed, + direction: direction ?? this.direction, + phaseOffset: phaseOffset ?? this.phaseOffset, + beamCount: beamCount ?? this.beamCount, + huePeriod: huePeriod ?? this.huePeriod, + bloomHuePeriod: bloomHuePeriod ?? this.bloomHuePeriod, + breatheFactor: breatheFactor ?? this.breatheFactor, + spikeFactor: spikeFactor ?? this.spikeFactor, + spike2Factor: spike2Factor ?? this.spike2Factor, + ); + + /// Layers [other] over this timing: every non-null field of [other] wins, + /// every null one inherits from this timing. + BeamTiming merge(BeamTiming? other) => other == null + ? this + : copyWith( + cycle: other.cycle, + cycleGap: other.cycleGap, + speed: other.speed, + direction: other.direction, + phaseOffset: other.phaseOffset, + beamCount: other.beamCount, + huePeriod: other.huePeriod, + bloomHuePeriod: other.bloomHuePeriod, + breatheFactor: other.breatheFactor, + spikeFactor: other.spikeFactor, + spike2Factor: other.spike2Factor, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamTiming && + other.cycle == cycle && + other.cycleGap == cycleGap && + other.speed == speed && + other.direction == direction && + other.phaseOffset == phaseOffset && + other.beamCount == beamCount && + other.huePeriod == huePeriod && + other.bloomHuePeriod == bloomHuePeriod && + other.breatheFactor == breatheFactor && + other.spikeFactor == spikeFactor && + other.spike2Factor == spike2Factor; + + @override + int get hashCode => Object.hashAll([ + cycle, + cycleGap, + speed, + direction, + phaseOffset, + beamCount, + huePeriod, + bloomHuePeriod, + breatheFactor, + spikeFactor, + spike2Factor, + ]); + + @override + String toString() { + final fields = [ + if (cycle != null) 'cycle: $cycle', + if (cycleGap != null) 'cycleGap: $cycleGap', + if (speed != null) 'speed: $speed', + if (direction != null) 'direction: $direction', + if (phaseOffset != null) 'phaseOffset: $phaseOffset', + if (beamCount != null) 'beamCount: $beamCount', + if (huePeriod != null) 'huePeriod: $huePeriod', + if (bloomHuePeriod != null) 'bloomHuePeriod: $bloomHuePeriod', + if (breatheFactor != null) 'breatheFactor: $breatheFactor', + if (spikeFactor != null) 'spikeFactor: $spikeFactor', + if (spike2Factor != null) 'spike2Factor: $spike2Factor', + ]; + return 'BeamTiming(${fields.join(', ')})'; + } +} diff --git a/lib/src/models/beam_variant.dart b/lib/src/models/beam_variant.dart index 1bb72dd..bab3ef8 100644 --- a/lib/src/models/beam_variant.dart +++ b/lib/src/models/beam_variant.dart @@ -1,3 +1,7 @@ +import 'dart:ui'; + +import '../constants/pulse_params.dart'; + /// The five beam effect variants, matching the React library's `size` prop. /// /// Rotate family (a traveling beam sweeps around the border): @@ -42,4 +46,21 @@ enum BeamVariant { /// Default border (stroke ring) width preset. All variants use 1px. double get defaultBorderWidth => 1; + + /// Default period of one full hue track pass: 12s for the traveling + /// variants, 16s for [pulseInside], 14s for [pulseOutside]. + /// + /// The pulse periods come from [PulseParams], which tunes them by neither + /// brightness nor cycle length — the arguments below only satisfy that + /// signature. + Duration get defaultHuePeriod => isPulse + ? _seconds(PulseParams.resolve(this, Brightness.dark, 2.3).huePeriod) + : const Duration(seconds: 12); + + /// Default period of the [line] variant's separate bloom hue track: 8s. + /// No other variant paints a bloom hue track of its own. + Duration get defaultBloomHuePeriod => const Duration(seconds: 8); } + +Duration _seconds(double value) => + Duration(microseconds: (value * Duration.microsecondsPerSecond).round()); diff --git a/lib/src/models/model_validation.dart b/lib/src/models/model_validation.dart new file mode 100644 index 0000000..99d850e --- /dev/null +++ b/lib/src/models/model_validation.dart @@ -0,0 +1,95 @@ +import 'beam_options.dart'; +import 'beam_timing.dart'; + +/// Runtime counterpart to [BeamTiming]'s constructor assertions. +/// +/// Assertions disappear in release builds, so resolution invokes this before +/// values reach animation arithmetic or scheduling code. +void validateBeamTiming(BeamTiming timing) { + void require(bool condition, String name, Object? value, String constraint) { + if (!condition) throw ArgumentError.value(value, name, constraint); + } + + require( + timing.cycle == null || timing.cycle! > Duration.zero, + 'cycle', + timing.cycle, + 'must be positive', + ); + require( + timing.cycleGap == null || timing.cycleGap! >= Duration.zero, + 'cycleGap', + timing.cycleGap, + 'must be non-negative', + ); + require( + timing.speed == null || (timing.speed!.isFinite && timing.speed! > 0), + 'speed', + timing.speed, + 'must be finite and positive', + ); + require( + timing.phaseOffset == null || + (timing.phaseOffset!.isFinite && + timing.phaseOffset! >= 0 && + timing.phaseOffset! <= 1), + 'phaseOffset', + timing.phaseOffset, + 'must be finite and between 0 and 1', + ); + require( + timing.beamCount == null || timing.beamCount! >= 1, + 'beamCount', + timing.beamCount, + 'must be at least 1', + ); + require( + timing.huePeriod == null || timing.huePeriod! > Duration.zero, + 'huePeriod', + timing.huePeriod, + 'must be positive', + ); + require( + timing.bloomHuePeriod == null || timing.bloomHuePeriod! > Duration.zero, + 'bloomHuePeriod', + timing.bloomHuePeriod, + 'must be positive', + ); + for (final (name, value) in [ + ('breatheFactor', timing.breatheFactor), + ('spikeFactor', timing.spikeFactor), + ('spike2Factor', timing.spike2Factor), + ]) { + require( + value == null || (value.isFinite && value > 0), + name, + value, + 'must be finite and positive', + ); + } +} + +/// Returns a valid repeat budget, including in release builds. +int? validateRepeat(BeamRepeat? repeat) { + final cycles = repeat?.cycles; + if (cycles != null && cycles < 1) { + throw ArgumentError.value( + cycles, + 'repeat', + 'must contain at least 1 cycle', + ); + } + return cycles; +} + +/// Rejects an empty color table, including in release builds. +/// +/// `BeamColors` is a const hierarchy, so its constructors cannot assert on a +/// list's length; resolution checks it instead. Every pulse layer reads its +/// color through `index % table.length`, which would otherwise fail as an +/// `IntegerDivisionByZeroException` deep inside a paint strategy. +void validateColorTable(int length, String name) { + if (length < 1) { + throw ArgumentError.value(length, name, 'must contain at least one entry'); + } +} diff --git a/lib/src/painting/beam_painter.dart b/lib/src/painting/beam_painter.dart index 6f3d09e..97a557a 100644 --- a/lib/src/painting/beam_painter.dart +++ b/lib/src/painting/beam_painter.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import '../animation/beam_clock.dart'; @@ -22,7 +23,7 @@ BeamVariantStrategy strategyFor(BeamVariant variant) => switch (variant) { /// The beam's [CustomPainter]. One instance paints either the behind-child /// pass or the above-child pass of its strategy; repaints are driven /// directly by the [BeamClock] (no widget rebuilds per frame). -class BeamPainter extends CustomPainter { +class BeamPainter extends CustomPainter with Diagnosticable { /// Creates a painter bound to [clock]. BeamPainter({ required this.clock, @@ -31,7 +32,10 @@ class BeamPainter extends CustomPainter { required this.strategy, required this.behind, required this.staticMode, - }) : super(repaint: clock); + this.progress, + this.strength, + this.frozenAt, + }) : super(repaint: Listenable.merge([clock, ?progress, ?strength])); /// The time source; also the repaint trigger. final BeamClock clock; @@ -51,19 +55,95 @@ class BeamPainter extends CustomPainter { /// Reduced-motion mode: paint one static frame, ignore the clock. final bool staticMode; + /// The externally driven sweep position (0–1), or null when nothing + /// drives it. + /// + /// Held as a listenable rather than a value so `BorderBeam.progress` and + /// `BorderBeam.follow` can move the beam without rebuilding the config. + final ValueListenable? progress; + + /// A per-frame multiplier on every layer's opacity, or null for none. + /// + /// `BorderBeam.strengthListenable`, the live twin of `BeamStyle.strength`: + /// it repaints without rebuilding, and the reduced-motion static frame + /// ignores it along with the rest of the clock. + final ValueListenable? strength; + + /// The timeline position every frame is sampled at, or null to follow the + /// clock. + /// + /// `BeamPlayback.debugFrozenAt`: the beam paints that one instant of the + /// timeline forever, which is what makes a screenshot of it reproducible. + /// The instant carries its own point on the fade envelope and is still + /// scaled by the live [strength], so a strength below 1 dims the frozen + /// frame the way it dims a running one. It lives on the painter rather + /// than on the config because it changes no painted value — only which + /// moment of the timeline is read — so a config cached across a freeze is + /// still the right config. + final Duration? frozenAt; + + // The config the strategies actually paint with: at renderScale 1 it is + // the config itself, and below that the same beam re-authored for the + // smaller box the canvas transform magnifies back up. + late final BeamConfig _paintConfig = config.renderScale >= 1 + ? config + : config.scaledBy(config.renderScale); + @override void paint(Canvas canvas, Size size) { + final driven = progress?.value; final BeamFramePhases phases; - if (staticMode) { - phases = resolver.staticFrame(); + final frozen = frozenAt; + if (frozen != null) { + phases = resolver.sample( + frozen.inMicroseconds / Duration.microsecondsPerSecond, + strength?.value ?? 1, + progress: driven, + ); + } else if (staticMode) { + phases = resolver.staticFrame(progress: driven); } else { if (!clock.isVisible) return; - phases = resolver.sample(clock.elapsedSeconds, clock.fadeOpacity); + // The boost and the live strength both scale every layer, and layer + // opacity is clamped at paint time — so they ride in on the fade + // rather than needing a channel of their own. + final amplitude = clock.boost * (strength?.value ?? 1); + phases = resolver.sample( + clock.elapsedSeconds, + clock.fadeOpacity * amplitude, + progress: driven, + ); + } + final scale = config.renderScale; + if (scale >= 1) { + _paintPass(canvas, size, config, phases); + return; } + // One transform, no layer: the beam is drawn into a box `scale` the size + // of the real one and magnified back about the origin, so it lands + // exactly on the box's bounds. Blur sigmas ride the canvas, so they grow + // with everything else. + canvas.save(); + canvas.scale(1 / scale); + _paintPass( + canvas, + Size(size.width * scale, size.height * scale), + _paintConfig, + phases, + ); + canvas.restore(); + } + + void _paintPass( + Canvas canvas, + Size size, + BeamConfig painted, + BeamFramePhases phases, + ) { if (behind) { - strategy.paintBehind(canvas, size, config, phases); + strategy.paintBehind(canvas, size, painted, phases); } else { - strategy.paintAbove(canvas, size, config, phases); + strategy.paintAbove(canvas, size, painted, phases); } } @@ -73,5 +153,27 @@ class BeamPainter extends CustomPainter { oldDelegate.strategy != strategy || oldDelegate.behind != behind || oldDelegate.staticMode != staticMode || - oldDelegate.clock != clock; + oldDelegate.clock != clock || + oldDelegate.progress != progress || + oldDelegate.strength != strength || + oldDelegate.frozenAt != frozenAt; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('config', config)) + ..add(FlagProperty('behind', value: behind, ifTrue: 'behind child')) + ..add( + FlagProperty('staticMode', value: staticMode, ifTrue: 'static frame'), + ) + ..add(DoubleProperty('elapsedSeconds', clock.elapsedSeconds)) + ..add(DoubleProperty('fadeOpacity', clock.fadeOpacity)) + ..add(DoubleProperty('boost', clock.boost, defaultValue: 1.0)) + ..add(DoubleProperty('progress', progress?.value, defaultValue: null)) + ..add(DoubleProperty('strength', strength?.value, defaultValue: null)) + ..add( + DiagnosticsProperty('frozenAt', frozenAt, defaultValue: null), + ); + } } diff --git a/lib/src/painting/gradient_builders.dart b/lib/src/painting/gradient_builders.dart index c3ce1bc..029c4e0 100644 --- a/lib/src/painting/gradient_builders.dart +++ b/lib/src/painting/gradient_builders.dart @@ -46,18 +46,14 @@ abstract final class BeamGradients { required double radiusY, required Color color, double alpha = 1, + double rotation = 0, }) { final rx = math.max(radiusX, 0.01); final ry = math.max(radiusY, 0.01); final c = alpha >= 1 ? color : color.withValues(alpha: color.a * alpha); if (c.a <= 0) return; // Circular gradient of radius rx, scaled vertically to ry about center. - final matrix = Float64List.fromList([ - 1, 0, 0, 0, // - 0, ry / rx, 0, 0, // - 0, 0, 1, 0, // - 0, center.dy - center.dy * (ry / rx), 0, 1, - ]); + final matrix = ellipseTransform(center, ry / rx, rotation); final shader = ui.Gradient.radial( center, rx, @@ -66,8 +62,13 @@ abstract final class BeamGradients { TileMode.clamp, matrix, ); + final extent = rotation == 0 ? null : math.max(rx, ry); canvas.drawRect( - Rect.fromCenter(center: center, width: rx * 2, height: ry * 2), + Rect.fromCenter( + center: center, + width: (extent ?? rx) * 2, + height: (extent ?? ry) * 2, + ), Paint()..shader = shader, ); } @@ -115,15 +116,11 @@ abstract final class BeamGradients { required double radiusY, required double midStop, required double midAlpha, + double rotation = 0, }) { final rx = math.max(radiusX, 0.01); final ry = math.max(radiusY, 0.01); - final matrix = Float64List.fromList([ - 1, 0, 0, 0, // - 0, ry / rx, 0, 0, // - 0, 0, 1, 0, // - 0, center.dy - center.dy * (ry / rx), 0, 1, - ]); + final matrix = ellipseTransform(center, ry / rx, rotation); return ui.Gradient.radial( center, rx, @@ -134,7 +131,190 @@ abstract final class BeamGradients { ); } + /// A conic dash mask: [segments] evenly spaced dashes around the ring, + /// drawn with [BlendMode.dstIn] to cut the gaps out of a layer. + /// + /// [duty] is the fraction of each dash period the dash occupies and + /// [feather] the fraction faded at each of its two edges, so the dashes end + /// in a soft taper rather than a hard chop. The mask is anchored at 12 + /// o'clock rather than at the beam angle: the dashes are a property of the + /// ring, and the beam travels over them. + static Shader segmentMask( + Rect rect, + int segments, { + double duty = 0.6, + double feather = 0.05, + }) { + final n = math.max(1, segments); + final p = 1 / n; + final stops = []; + final colors = []; + void stop(double s, double alpha) { + stops.add(s.clamp(0.0, 1.0)); + colors.add(_white.withValues(alpha: alpha)); + } + + for (var k = 0; k < n; k++) { + final base = k * p; + stop(base, 1); + stop(base + (duty - feather) * p, 1); + stop(base + (duty + feather) * p, 0); + stop(base + (1 - feather) * p, 0); + } + stop(1, 1); + return conic(rect: rect, cssFromRadians: 0, colors: colors, stops: stops); + } + static const Color _white = Color(0xFFFFFFFF); + + /// Scales a circular radial shader into an ellipse and rotates its width + /// axis by [rotation] around [center]. + /// + /// The zero-rotation matrix is intentionally the historical matrix byte + /// for byte, so callers that do not opt into path-space painting retain + /// their existing raster output. + static Float64List ellipseTransform( + Offset center, + double yScale, + double rotation, + ) { + if (rotation == 0) { + return Float64List.fromList([ + 1, 0, 0, 0, // + 0, yScale, 0, 0, // + 0, 0, 1, 0, // + 0, center.dy - center.dy * yScale, 0, 1, + ]); + } + final c = math.cos(rotation); + final s = math.sin(rotation); + final m0 = c; + final m1 = s; + final m4 = -s * yScale; + final m5 = c * yScale; + return Float64List.fromList([ + m0, m1, 0, 0, // + m4, m5, 0, 0, // + 0, 0, 1, 0, // + center.dx - m0 * center.dx - m4 * center.dy, + center.dy - m1 * center.dx - m5 * center.dy, + 0, + 1, + ]); + } +} + +/// A conic gradient's stop table: parallel stop and alpha lists, as the +/// rotate/small variants transcribe them from the source's CSS. +typedef BeamConicTable = ({List stops, List alphas}); + +/// Runtime transforms of the transcribed conic window tables. +/// +/// The tables themselves are constants and never change; travel direction, +/// tail length, and beam count reshape them per frame instead. +abstract final class BeamConicWindow { + /// Applies [tailLength], [reversed], and [beamCount] to a base table, in + /// that order. + /// + /// Returns the base lists untouched at the defaults (tail 1, forward, one + /// beam), so the common path allocates nothing. + static BeamConicTable resolve( + List stops, + List alphas, { + required bool reversed, + required double tailLength, + required int beamCount, + }) { + var table = scaleTail((stops: stops, alphas: alphas), tailLength); + if (reversed) table = mirror(table); + return repeat(table, beamCount); + } + + /// Mirrors a table for a beam traveling the other way: every stop becomes + /// `1 − stop` (which reverses their order, so the list is reversed to stay + /// ascending) and the alphas reverse with them. + /// + /// The transcribed tables are asymmetric — a short falloff on the leading + /// side, a long soft foot trailing — so a reversed beam that reused them + /// verbatim would drag its tail in front of its head. + static BeamConicTable mirror(BeamConicTable table) => ( + stops: [for (final s in table.stops.reversed) 1 - s], + alphas: table.alphas.reversed.toList(growable: false), + ); + + /// Scales the angular width of the window about its head by [factor]. + /// + /// The head is the leading edge of the bright core — the last stop holding + /// the table's maximum alpha — so scaling about it stretches the trailing + /// tail and the short leading falloff together while the beam's position + /// stays put. + /// + /// [factor] is clamped to what the table can hold: a window may not grow + /// past the full turn, or its two ends would collide at the seam and cut + /// the beam in half. The rotate window saturates at ≈1.33×, its highlight + /// and bloom bands (which start narrower) well past 2×. + static BeamConicTable scaleTail(BeamConicTable table, double factor) { + if (factor == 1 || table.stops.length < 3) return table; + final head = _head(table); + final (lo, hi) = _support(table); + final backSpan = head - lo; + final frontSpan = hi - head; + var maxFactor = double.infinity; + if (backSpan > 0) maxFactor = math.min(maxFactor, head / backSpan); + if (frontSpan > 0) maxFactor = math.min(maxFactor, (1 - head) / frontSpan); + final f = factor.clamp(0.05, math.max(0.05, maxFactor)); + return ( + stops: [ + for (final s in table.stops) + (head + (s - head) * f).clamp(0.0, 1.0).toDouble(), + ], + alphas: table.alphas, + ); + } + + /// Tiles a table [count] times around the circle, so one sweep shader + /// carries every beam: each copy's stops are scaled by `1 / count` and + /// offset into its own slot. + static BeamConicTable repeat(BeamConicTable table, int count) { + if (count <= 1) return table; + final stops = []; + final alphas = []; + for (var k = 0; k < count; k++) { + for (var i = 0; i < table.stops.length; i++) { + stops.add((table.stops[i] + k) / count); + alphas.add(table.alphas[i]); + } + } + return (stops: stops, alphas: alphas); + } + + // The last stop carrying the table's peak alpha: the leading edge of the + // bright core. + static double _head(BeamConicTable table) { + var peak = table.alphas.first; + for (final a in table.alphas) { + if (a > peak) peak = a; + } + var head = table.stops.first; + for (var i = 0; i < table.alphas.length; i++) { + if (table.alphas[i] >= peak) head = table.stops[i]; + } + return head; + } + + // The zero-alpha stops flanking the lit band — the width the window + // actually occupies, ignoring the table's 0 and 1 anchors. + static (double lo, double hi) _support(BeamConicTable table) { + var first = 0; + while (first < table.alphas.length - 1 && table.alphas[first + 1] <= 0) { + first++; + } + var last = table.alphas.length - 1; + while (last > 0 && table.alphas[last - 1] <= 0) { + last--; + } + return (table.stops[first], table.stops[last]); + } } /// Small helper building a rotation matrix (as the `Float64List` gradient diff --git a/lib/src/painting/layer_utils.dart b/lib/src/painting/layer_utils.dart index 55b29a2..fce77d8 100644 --- a/lib/src/painting/layer_utils.dart +++ b/lib/src/painting/layer_utils.dart @@ -1,9 +1,10 @@ import 'dart:math' as math; -import 'dart:typed_data'; import 'dart:ui' as ui; import 'dart:ui'; import '../models/beam_config.dart'; +import 'gradient_builders.dart'; +import 'ring_geometry.dart'; /// Shared painting helpers used by every variant strategy. abstract final class BeamLayerUtils { @@ -45,15 +46,11 @@ abstract final class BeamLayerUtils { required double radiusY, required List colors, required List stops, + double rotation = 0, }) { final rx = math.max(radiusX, 0.01); final ry = math.max(radiusY, 0.01); - final matrix = Float64List.fromList([ - 1, 0, 0, 0, // - 0, ry / rx, 0, 0, // - 0, 0, 1, 0, // - 0, center.dy - center.dy * (ry / rx), 0, 1, - ]); + final matrix = BeamGradients.ellipseTransform(center, ry / rx, rotation); final shader = ui.Gradient.radial( center, rx, @@ -62,12 +59,225 @@ abstract final class BeamLayerUtils { TileMode.clamp, matrix, ); + final extent = rotation == 0 ? null : math.max(rx, ry); canvas.drawRect( - Rect.fromCenter(center: center, width: rx * 2, height: ry * 2), + Rect.fromCenter( + center: center, + width: (extent ?? rx) * 2, + height: (extent ?? ry) * 2, + ), Paint()..shader = shader, ); } + /// Clips an existing layer scope to the configured perimeter segment. + /// + /// Call this after `canvas.save()` and before `saveLayer`. A null segment + /// deliberately performs no canvas operation, preserving the legacy paint + /// path exactly. + static void clipSegment( + Canvas canvas, + BeamRingGeometry geometry, { + required double inward, + required double outward, + }) { + if (geometry.segment == null) return; + canvas.clipPath( + segmentBandPath(geometry, inward: inward, outward: outward), + ); + } + + /// The band covering [geometry]'s segment, reaching [inward] px toward the + /// interior and [outward] px away from it. + /// + /// A contour offset inward by more than its local radius of curvature folds + /// back through the centre of that curvature: at a rounded corner the + /// offset arc reverses, sweeps across the far side of the segment, and — as + /// one polygon under the non-zero fill rule — both loses the interior it + /// was meant to cover and punches a spurious wedge where the two reversed + /// arcs cross. Each sample's inward depth is therefore capped at that + /// radius, which collapses a corner's inner boundary onto its own centre + /// instead of past it, leaving a simple polygon. + /// + /// Only the inward direction can fold — growing a convex contour outward + /// never does — so an [inward] of zero or less takes the perimeter's own + /// sampler unchanged. + static Path segmentBandPath( + BeamRingGeometry geometry, { + required double inward, + required double outward, + }) { + final range = geometry.segmentRange; + if (range == null || inward <= 0) { + return geometry.segmentBand(inward: inward, outward: outward); + } + final perimeter = geometry.perimeter; + final length = perimeter.length; + if (length <= 0) return Path(); + + final start = _fraction(range.from); + var span = _fraction(range.to - start); + if (span == 0) span = 1; + // Matches BeamPerimeter.band's sampling: ~2 logical px between samples. + final steps = math.max(8, (span * length / 2).ceil()); + final step = span * length / steps; + + final points = []; + final normals = []; + for (var i = 0; i <= steps; i++) { + final fraction = start + span * i / steps; + points.add(perimeter.pointAt(fraction)); + normals.add(perimeter.normalAt(fraction)); + } + + final result = Path() + ..moveTo( + points.first.dx + normals.first.dx * outward, + points.first.dy + normals.first.dy * outward, + ); + for (var i = 1; i < points.length; i++) { + result.lineTo( + points[i].dx + normals[i].dx * outward, + points[i].dy + normals[i].dy * outward, + ); + } + for (var i = points.length - 1; i >= 0; i--) { + final depth = math.min(inward, _curvatureRadius(normals, step, i)); + result.lineTo( + points[i].dx - normals[i].dx * depth, + points[i].dy - normals[i].dy * depth, + ); + } + return result..close(); + } + + // How far sample [i] may be offset inward before the offset curve reverses: + // an outward normal turning by phi over an arc length [step] puts the centre + // of curvature step/phi inward. A normal that turns the other way (a concave + // stretch) curves away from the interior and never folds, so it is + // unbounded. + static double _curvatureRadius(List normals, double step, int i) { + var radius = double.infinity; + for (final j in [i - 1, i + 1]) { + if (j < 0 || j >= normals.length) continue; + final (a, b) = i < j + ? (normals[i], normals[j]) + : (normals[j], normals[i]); + final turn = math.atan2( + a.dx * b.dy - a.dy * b.dx, + a.dx * b.dx + a.dy * b.dy, + ); + if (turn > 1e-9) radius = math.min(radius, step / turn); + } + return radius; + } + + static double _fraction(double value) { + final result = value % 1; + return result < 0 ? result + 1 : result; + } + + /// Multiplies the current layer by the two configured endpoint feathers. + /// + /// Both masks are radial gradients from transparent at the endpoint to + /// opaque at [BeamSegment.feather]. They are drawn into the layer that + /// already composites the variant, so segment painting adds no layer. + static void applySegmentFeather( + Canvas canvas, + Rect bounds, + BeamRingGeometry geometry, + ) { + final segment = geometry.segment; + final range = geometry.segmentRange; + if (segment == null || range == null || segment.feather <= 0) return; + final radius = math.max(segment.feather, 0.01); + for (final fraction in [range.from, range.to]) { + final center = geometry.perimeter.pointAt(fraction); + canvas.drawRect( + bounds, + Paint() + ..blendMode = BlendMode.dstIn + ..shader = ui.Gradient.radial( + center, + radius, + const [Color(0x00FFFFFF), Color(0xFFFFFFFF)], + const [0, 1], + TileMode.clamp, + ), + ); + } + } + + /// The point where a ray leaving the centre of [rect] at [angle] crosses + /// the rect's edge, in the CSS conic convention the beam angle uses: 0 is + /// 12 o'clock and the angle grows clockwise. + /// + /// An approximation of the rounded contour — a corner radius pulls the true + /// border in by a few px — which is all the sparkle scatter needs. + static Offset edgePointAt(Rect rect, double angle) { + if (rect.isEmpty) return rect.center; + final dx = math.sin(angle); + final dy = -math.cos(angle); + final tx = dx.abs() < 1e-6 ? double.infinity : (rect.width / 2) / dx.abs(); + final ty = dy.abs() < 1e-6 ? double.infinity : (rect.height / 2) / dy.abs(); + final t = math.min(tx, ty); + if (!t.isFinite) return rect.center; + return rect.center + Offset(dx * t, dy * t); + } + + /// Paints the twinkles of `BeamStyle.sparkle`: tiny radial blobs scattered + /// in a disc of radius [spread] around [center], the beam's head. + /// + /// Every position, size, and brightness comes from a hash of the sparkle's + /// index and [seed], so a frame is reproducible from its phases alone — + /// there is no particle state to carry between frames. [seed] is quantised + /// travel progress: it holds a scatter still for a fraction of a cycle and + /// then re-rolls it, which is what makes the field twinkle instead of + /// crawling along with the beam. + /// + /// The blobs are drawn straight onto the canvas with their opacity baked + /// into each colour rather than into a group layer — they need no group + /// mask, and the stroke layer they belong to is clipped to the ring, which + /// would flatten them to hairlines. Either way they cost no `saveLayer`. + static void paintSparkles( + Canvas canvas, { + required Offset center, + required double density, + required Color color, + required double opacity, + required double spread, + required int seed, + }) { + if (density <= 0 || opacity <= 0 || spread <= 0) return; + final count = (2 + 10 * density).round(); + for (var i = 0; i < count; i++) { + final angle = _hash(i, seed) * 2 * math.pi; + final distance = spread * math.sqrt(_hash(i + 91, seed)); + final radius = 0.8 + 2.4 * _hash(i + 173, seed) * (0.5 + density / 2); + final twinkle = _hash(i + 379, seed * 31 + 7); + final alpha = (opacity * density * (0.25 + 0.75 * twinkle)).clamp( + 0.0, + 1.0, + ); + if (alpha <= 0) continue; + BeamGradients.paintBlob( + canvas, + center: center + Offset(math.cos(angle), math.sin(angle)) * distance, + radiusX: radius, + radiusY: radius, + color: color.withValues(alpha: alpha), + ); + } + } + + // A cheap integer hash in 0–1: deterministic across runs and platforms, + // unlike Random(), whose stream the golden tests could not pin. + static double _hash(int a, int b) { + var h = a * 374761393 + b * 668265263; + h = (h ^ (h >> 13)) * 1274126177; + return ((h ^ (h >> 16)) & 0xFFFFFF) / 0xFFFFFF; + } + /// Approximates the CSS `box-shadow: inset 0 0 px 1px ` of /// the inner glow layers: the shape's contour stroked and blurred, clipped /// to the inside. diff --git a/lib/src/painting/ring_geometry.dart b/lib/src/painting/ring_geometry.dart index 44307fa..6f65340 100644 --- a/lib/src/painting/ring_geometry.dart +++ b/lib/src/painting/ring_geometry.dart @@ -1,28 +1,322 @@ import 'dart:math' as math; -import 'dart:ui'; +import 'dart:ui' show PathMetric, Tangent; -/// Path builders for the beam's shape: rounded rect or rounded superellipse, -/// plus the "ring" region every stroke layer is clipped to. +import 'package:flutter/painting.dart'; + +import '../models/beam_options.dart'; +import '../models/beam_segment.dart'; + +/// Arc-length geometry for a closed beam contour. +/// +/// Public fractions increase clockwise from the point nearest [Rect.topCenter] +/// regardless of the source path's winding direction. +class BeamPerimeter { + /// Measures [outer] and aligns its coordinates to [rect]. + BeamPerimeter(Path outer, this.rect, {this.radii}) { + final metrics = outer.computeMetrics().toList(growable: false); + _metric = metrics.isEmpty ? null : metrics.first; + length = _metric?.length ?? 0; + if (length <= 0) { + _s0 = 0; + _clockwiseSource = true; + return; + } + + const samples = 128; + var best = 0.0; + var bestDistance = double.infinity; + for (var i = 0; i < samples; i++) { + final offset = length * i / samples; + final distance = + (_rawTangent(offset)!.position - rect.topCenter).distanceSquared; + if (distance < bestDistance) { + bestDistance = distance; + best = offset; + } + } + var low = best - length / samples; + var high = best + length / samples; + for (var i = 0; i < 8; i++) { + final left = (2 * low + high) / 3; + final right = (low + 2 * high) / 3; + final leftDistance = + (_rawTangent(left)!.position - rect.topCenter).distanceSquared; + final rightDistance = + (_rawTangent(right)!.position - rect.topCenter).distanceSquared; + if (leftDistance <= rightDistance) { + high = right; + } else { + low = left; + } + } + _s0 = _wrapOffset((low + high) / 2); + _clockwiseSource = _rawTangent(_s0)!.vector.dx >= 0; + } + + /// The bounds used to identify top-center and rectangular edge anchors. + final Rect rect; + + /// Resolved corner radii, or null for an arbitrary contour. + final BorderRadius? radii; + + late final PathMetric? _metric; + + /// Total length of the first contour in logical pixels. + late final double length; + + late final double _s0; + late final bool _clockwiseSource; + + static double _fraction(double value) { + final result = value % 1; + return result < 0 ? result + 1 : result; + } + + double _wrapOffset(double value) { + if (length <= 0) return 0; + final result = value % length; + return result < 0 ? result + length : result; + } + + Tangent? _rawTangent(double offset) => + _metric?.getTangentForOffset(_wrapOffset(offset)); + + Tangent? _tangent(double fraction) { + if (length <= 0) return null; + final distance = _fraction(fraction) * length; + return _rawTangent(_s0 + (_clockwiseSource ? distance : -distance)); + } + + /// The point at clockwise arc-length fraction [f]. + Offset pointAt(double f) => _tangent(f)?.position ?? rect.topCenter; + + /// The clockwise unit tangent at fraction [f]. + Offset tangentAt(double f) { + final vector = _tangent(f)?.vector ?? Offset.zero; + return _clockwiseSource ? vector : -vector; + } + + /// The outward unit normal at fraction [f]. + Offset normalAt(double f) { + final tangent = tangentAt(f); + return Offset(tangent.dy, -tangent.dx); + } + + /// The perimeter point moved [inward] logical pixels toward the interior. + Offset offsetPointAt(double f, double inward) => + pointAt(f) - normalAt(f) * inward; + + /// Finds the clockwise fraction whose perimeter point is nearest [point]. + double nearestFraction(Offset point) { + if (length <= 0) return 0; + const samples = 64; + var best = 0.0; + var bestDistance = double.infinity; + for (var i = 0; i < samples; i++) { + final fraction = i / samples; + final distance = (pointAt(fraction) - point).distanceSquared; + if (distance < bestDistance) { + bestDistance = distance; + best = fraction; + } + } + var low = best - 1 / samples; + var high = best + 1 / samples; + for (var i = 0; i < 12; i++) { + final left = (2 * low + high) / 3; + final right = (low + 2 * high) / 3; + if ((pointAt(left) - point).distanceSquared <= + (pointAt(right) - point).distanceSquared) { + high = right; + } else { + low = left; + } + } + return _fraction((low + high) / 2); + } + + BorderRadius get _scaledRadii { + final source = radii; + if (source == null) return BorderRadius.zero; + final half = rect.shortestSide / 2; + Radius normalized(Radius radius) => Radius.elliptical( + radius.x.isFinite ? math.max(0, radius.x) : half, + radius.y.isFinite ? math.max(0, radius.y) : half, + ); + final topLeft = normalized(source.topLeft); + final topRight = normalized(source.topRight); + final bottomRight = normalized(source.bottomRight); + final bottomLeft = normalized(source.bottomLeft); + var scale = 1.0; + double limit(double side, double sum) => + sum <= 0 ? scale : math.min(scale, side / sum); + scale = limit(rect.width, topLeft.x + topRight.x); + scale = limit(rect.height, topRight.y + bottomRight.y); + scale = limit(rect.width, bottomLeft.x + bottomRight.x); + scale = limit(rect.height, topLeft.y + bottomLeft.y); + Radius scaled(Radius radius) => radius * scale; + return BorderRadius.only( + topLeft: scaled(topLeft), + topRight: scaled(topRight), + bottomRight: scaled(bottomRight), + bottomLeft: scaled(bottomLeft), + ); + } + + (Offset, Offset) _cornerPoints(BeamCorner corner) { + final r = _scaledRadii; + return switch (corner) { + BeamCorner.topLeft => ( + Offset(rect.left, rect.top + r.topLeft.y), + Offset(rect.left + r.topLeft.x, rect.top), + ), + BeamCorner.topRight => ( + Offset(rect.right - r.topRight.x, rect.top), + Offset(rect.right, rect.top + r.topRight.y), + ), + BeamCorner.bottomRight => ( + Offset(rect.right, rect.bottom - r.bottomRight.y), + Offset(rect.right - r.bottomRight.x, rect.bottom), + ), + BeamCorner.bottomLeft => ( + Offset(rect.left + r.bottomLeft.x, rect.bottom), + Offset(rect.left, rect.bottom - r.bottomLeft.y), + ), + }; + } + + (double, double) _cornerRange(BeamCorner corner) { + final (start, end) = _cornerPoints(corner); + return (nearestFraction(start), nearestFraction(end)); + } + + static double _clockwiseSpan(double from, double to) => _fraction(to - from); + + /// Resolves [t] along the straight part of [edge] in clockwise direction. + double fractionOfEdge(BeamEdge edge, double t) { + final (from, to) = switch (edge) { + BeamEdge.top => ( + _cornerRange(BeamCorner.topLeft).$2, + _cornerRange(BeamCorner.topRight).$1, + ), + BeamEdge.right => ( + _cornerRange(BeamCorner.topRight).$2, + _cornerRange(BeamCorner.bottomRight).$1, + ), + BeamEdge.bottom => ( + _cornerRange(BeamCorner.bottomRight).$2, + _cornerRange(BeamCorner.bottomLeft).$1, + ), + BeamEdge.left => ( + _cornerRange(BeamCorner.bottomLeft).$2, + _cornerRange(BeamCorner.topLeft).$1, + ), + }; + return _fraction(from + _clockwiseSpan(from, to) * t.clamp(0.0, 1.0)); + } + + /// Resolves [t] through [corner]'s arc in clockwise direction. + double fractionOfCorner(BeamCorner corner, double t) { + final (from, to) = _cornerRange(corner); + return _fraction(from + _clockwiseSpan(from, to) * t.clamp(0.0, 1.0)); + } + + /// Builds a closed sampled band from [from] clockwise to [to]. + /// + /// Equal endpoints cover the full perimeter. [samplesPerUnit] is the + /// approximate number of logical pixels between adjacent samples. + Path band({ + required double from, + required double to, + required double inward, + required double outward, + int samplesPerUnit = 2, + }) { + final result = Path(); + if (length <= 0) return result; + final start = _fraction(from); + var span = _clockwiseSpan(start, _fraction(to)); + if (span == 0) span = 1; + final spacing = math.max(1, samplesPerUnit); + final steps = math.max(8, (span * length / spacing).ceil()); + final outside = []; + final inside = []; + for (var i = 0; i <= steps; i++) { + final fraction = start + span * i / steps; + final point = pointAt(fraction); + final normal = normalAt(fraction); + outside.add(point + normal * outward); + inside.add(point - normal * inward); + } + result.moveTo(outside.first.dx, outside.first.dy); + for (final point in outside.skip(1)) { + result.lineTo(point.dx, point.dy); + } + for (final point in inside.reversed) { + result.lineTo(point.dx, point.dy); + } + return result..close(); + } + + /// Returns the segment mask weight at [f]. + double weightAt( + double f, { + required double from, + required double to, + required double featherFraction, + }) { + final start = _fraction(from); + var span = _clockwiseSpan(start, _fraction(to)); + if (span == 0) return 1; + final position = _clockwiseSpan(start, _fraction(f)); + if (position > span) return 0; + final feather = featherFraction.clamp(0.0, span / 2); + if (feather == 0) return 1; + double smoothstep(double value) => value * value * (3 - 2 * value); + if (position < feather) return smoothstep(position / feather); + final remaining = span - position; + if (remaining < feather) return smoothstep(remaining / feather); + return 1; + } + + /// Converts a logical-pixel feather length into a perimeter fraction. + double featherFractionFor(double featherPx) => + length <= 0 ? 0 : featherPx / length; + + /// Resolves [segment]'s anchors against this perimeter. + ({double from, double to}) resolveSegment(BeamSegment segment) => + (from: segment.start.resolve(this), to: segment.end.resolve(this)); +} + +/// Path builders for the beam's shape: rounded rect, rounded superellipse, or +/// an arbitrary [BeamContour], plus the "ring" region every stroke layer is +/// clipped to. /// /// The React library paints stroke layers into a CSS mask ring — /// padding-box minus content-box. Here that is the [ring] path: the outer -/// contour (corner radius `R` over the full rect) minus the inner contour -/// (radius `R − borderWidth` over the rect deflated by `borderWidth`). +/// contour (the shape's corner radii over the full rect) minus the inner +/// contour (each radius shrunk by `borderWidth`, over the rect deflated by +/// `borderWidth`). class BeamRingGeometry { /// Creates geometry for [rect] with the given corner [radius], /// [borderWidth], and shape family. + /// + /// A non-null [contour] replaces the rounded-rect family entirely: it + /// builds [outer], and [radius]/[useSuperellipse] go unread. BeamRingGeometry({ required this.rect, required this.radius, required this.borderWidth, required this.useSuperellipse, + this.contour, + this.segment, }); /// The layer bounds. final Rect rect; - /// Outer corner radius in logical px. - final double radius; + /// Outer corner radii in logical px, per corner. + final BorderRadius radius; /// Ring thickness in logical px. final double borderWidth; @@ -31,34 +325,239 @@ class BeamRingGeometry { /// circular-arc rounded rects. final bool useSuperellipse; + /// An arbitrary outer contour replacing the rounded-rect family, or null. + final BeamContour? contour; + + /// The visible clockwise portion of the contour, or null for the full ring. + final BeamSegment? segment; + /// Outer contour of the shape. - late final Path outer = _shapePath(rect, radius); + late final Path outer = rect.isEmpty + ? Path() + : (contour?.build(rect) ?? _shapePath(rect, radius)); - /// Inner contour (the content box: deflated by [borderWidth], radius - /// reduced accordingly). - late final Path inner = _shapePath( - rect.deflate(borderWidth), - math.max(0, radius - borderWidth), + /// Arc-length coordinates for [outer]. + late final BeamPerimeter perimeter = BeamPerimeter( + outer, + rect, + radii: contour == null ? radius : null, ); + /// Resolved segment endpoints, or null when the complete ring is visible. + late final ({double from, double to})? segmentRange = segment == null + ? null + : perimeter.resolveSegment(segment!); + + /// A sampled band covering the configured segment at the given offsets. + /// + /// With no segment, a bounds-covering path keeps masking neutral. + Path segmentBand({required double inward, required double outward}) { + final range = segmentRange; + return range == null + ? (Path()..addRect(outer.getBounds())) + : perimeter.band( + from: range.from, + to: range.to, + inward: inward, + outward: outward, + ); + } + + /// The configured segment's feathered visibility at perimeter fraction [f]. + double segmentWeightAt(double f) { + final range = segmentRange; + final configured = segment; + if (range == null || configured == null) return 1; + return perimeter.weightAt( + f, + from: range.from, + to: range.to, + featherFraction: perimeter.featherFractionFor(configured.feather), + ); + } + + /// Inner contour (the content box: deflated by [borderWidth], every corner + /// radius reduced by the same amount). + /// + /// A box thinner than twice the border width has no content box left, and + /// the contour is empty — the ring is then the whole shape. Under a custom + /// [contour] the inner path is [outer] offset inward along its own normals + /// by [borderWidth] (see [insetPath]), since an arbitrary path has no + /// corner radii to shrink. + late final Path inner = rect.isEmpty + ? Path() + : contour != null + ? insetPath(outer, borderWidth) + : _shapePath( + rect.deflate(borderWidth), + _deflateRadius(radius, borderWidth), + ); + /// The border ring: [outer] minus [inner]. - late final Path ring = Path.combine(PathOperation.difference, outer, inner); + late final Path ring = rect.isEmpty + ? Path() + : Path.combine(PathOperation.difference, outer, inner); - Path _shapePath(Rect r, double cornerRadius) { - final clamped = _clampRadius(r, cornerRadius); + Path _shapePath(Rect r, BorderRadius cornerRadii) { + // A rect with no area (or an inverted one, from deflating past the + // center) has no contour to describe. + if (r.isEmpty) return Path(); + final c = _scaleRadii(r, cornerRadii); if (useSuperellipse) { return Path()..addRSuperellipse( - RSuperellipse.fromRectAndRadius(r, Radius.circular(clamped)), + RSuperellipse.fromRectAndCorners( + r, + topLeft: c.topLeft, + topRight: c.topRight, + bottomLeft: c.bottomLeft, + bottomRight: c.bottomRight, + ), ); } - return Path() - ..addRRect(RRect.fromRectAndRadius(r, Radius.circular(clamped))); + return Path()..addRRect( + RRect.fromRectAndCorners( + r, + topLeft: c.topLeft, + topRight: c.topRight, + bottomLeft: c.bottomLeft, + bottomRight: c.bottomRight, + ), + ); } /// A standalone contour for an arbitrary rect/radius in the same shape /// family (used by pulse-outside's outward layers). - Path contour(Rect r, double cornerRadius) => _shapePath(r, cornerRadius); + Path shapeContour(Rect r, BorderRadius cornerRadii) => + _shapePath(r, cornerRadii); + + /// The halo a comet bloom fills: the shape grown by [reach], minus the + /// content box, so the glow hugs the border and spills outward instead of + /// washing across the child. + Path halo(double reach) => rect.isEmpty + ? Path() + : Path.combine(PathOperation.difference, grown(reach), inner); + + /// [outer] grown outward by [reach], in the shape's own family. + Path grown(double reach) => contour != null + ? insetPath(outer, -reach) + : _shapePath(rect.inflate(reach), _inflateRadius(radius, reach)); + + /// [source] offset inward by [inset] along its own normals — a true + /// polygonal offset, not a scale about the centre, so a lobed contour keeps + /// an even border width all the way round. + /// + /// Each closed subpath is resampled at ~1px, its orientation read from the + /// signed area (so a path drawn either way offsets inward), and every + /// sample moved along the inward normal. A concave notch tighter than + /// [inset] folds the offset over itself; the fold is a hairline at the + /// border widths this is used at, and the non-zero fill rule swallows it. + /// + /// A negative [inset] offsets outward instead, which is how the comet halo + /// grows an arbitrary contour. + static Path insetPath(Path source, double inset) { + final result = Path(); + if (inset == 0) return result..addPath(source, Offset.zero); + for (final metric in source.computeMetrics()) { + final length = metric.length; + if (length <= 0) continue; + final steps = math.max(8, length.round()); + final points = []; + final normals = []; + for (var i = 0; i < steps; i++) { + final tangent = metric.getTangentForOffset(length * i / steps); + if (tangent == null) continue; + points.add(tangent.position); + normals.add(Offset(tangent.vector.dx, tangent.vector.dy)); + } + if (points.length < 3) continue; + // Shoelace sign: positive for a path wound so that rotating the tangent + // a quarter turn one way points inward, negative for the other. + var area = 0.0; + for (var i = 0; i < points.length; i++) { + final a = points[i]; + final b = points[(i + 1) % points.length]; + area += a.dx * b.dy - b.dx * a.dy; + } + final sign = area >= 0 ? 1.0 : -1.0; + for (var i = 0; i < points.length; i++) { + final t = normals[i]; + final inward = Offset(-t.dy, t.dx) * sign; + final p = points[i] + inward * inset; + if (i == 0) { + result.moveTo(p.dx, p.dy); + } else { + result.lineTo(p.dx, p.dy); + } + } + result.close(); + } + return result; + } + + // Shrinks every corner by [amount], flooring each axis at zero — the + // content box's corners are the padding box's minus the border width. + static BorderRadius _deflateRadius(BorderRadius radii, double amount) => + BorderRadius.only( + topLeft: _shrink(radii.topLeft, amount), + topRight: _shrink(radii.topRight, amount), + bottomLeft: _shrink(radii.bottomLeft, amount), + bottomRight: _shrink(radii.bottomRight, amount), + ); + + // Grows every corner by [amount] — the mirror of [_deflateRadius], used by + // the comet halo. + static BorderRadius _inflateRadius(BorderRadius radii, double amount) => + BorderRadius.only( + topLeft: _shrink(radii.topLeft, -amount), + topRight: _shrink(radii.topRight, -amount), + bottomLeft: _shrink(radii.bottomLeft, -amount), + bottomRight: _shrink(radii.bottomRight, -amount), + ); + + static Radius _shrink(Radius r, double amount) => + Radius.elliptical(math.max(0, r.x - amount), math.max(0, r.y - amount)); + + // The clamp `RRect.scaleRadii` applies: if the two radii on any side add up + // to more than that side, all four shrink by the smallest offending ratio — + // so the corners keep their relative proportions instead of being clipped + // one at a time. + static BorderRadius _scaleRadii(Rect r, BorderRadius radii) { + final half = r.shortestSide / 2; + var tl = _normalize(radii.topLeft, half); + var tr = _normalize(radii.topRight, half); + var bl = _normalize(radii.bottomLeft, half); + var br = _normalize(radii.bottomRight, half); + + var scale = 1.0; + scale = _limit(scale, r.width, tl.x + tr.x); + scale = _limit(scale, r.height, tr.y + br.y); + scale = _limit(scale, r.width, bl.x + br.x); + scale = _limit(scale, r.height, tl.y + bl.y); + if (scale < 1) { + tl = tl * scale; + tr = tr * scale; + bl = bl * scale; + br = br * scale; + } + return BorderRadius.only( + topLeft: tl, + topRight: tr, + bottomLeft: bl, + bottomRight: br, + ); + } + + // Radii are floored at zero; an infinite one (BeamShape.stadium) becomes + // half the shortest side, the largest a corner can hold, which is what + // makes a pill track the box as it resizes. + static Radius _normalize(Radius r, double half) => + Radius.elliptical(_finite(r.x, half), _finite(r.y, half)); + + static double _finite(double value, double fallback) { + final floored = math.max(0.0, value); + return floored.isFinite ? floored : fallback; + } - static double _clampRadius(Rect r, double radius) => - math.min(radius, math.min(r.width, r.height) / 2); + static double _limit(double scale, double side, double sum) => + sum <= 0 ? scale : math.min(scale, side / sum); } diff --git a/lib/src/painting/strategies/line_strategy.dart b/lib/src/painting/strategies/line_strategy.dart index ead477e..bf9191b 100644 --- a/lib/src/painting/strategies/line_strategy.dart +++ b/lib/src/painting/strategies/line_strategy.dart @@ -1,8 +1,15 @@ +import 'dart:math' as math; import 'dart:ui'; +import 'package:flutter/painting.dart' show BorderRadius; + import '../../animation/beam_phases.dart'; +import '../../constants/line_geometry.dart'; +import '../../constants/line_keyframes.dart'; import '../../models/beam_blob.dart'; import '../../models/beam_config.dart'; +import '../../models/beam_options.dart'; +import '../../models/beam_segment.dart'; import '../color_matrix.dart'; import '../gradient_builders.dart'; import '../layer_utils.dart'; @@ -12,9 +19,49 @@ import '../variant_strategy.dart'; const Color _white = Color(0xFFFFFFFF); const Color _black = Color(0xFF000000); -/// The bottom-edge traveling beam (React `line`): all masks are radial -/// windows anchored at the traveling x position on the bottom edge, plus a -/// bloom of fixed spikes that shimmer via two counter-phased scale tracks. +// How far a traveller's band runs past the box, before and after the edge it +// rides: far enough that nothing a blob or a blur reaches is ever cut, while +// the bands still partition the edge between the travellers. +const double _bandOverhang = 10000; + +// The sparkle scatter's radius around a traveller, and how finely its +// position is quantised into the twinkle seed. +const double _sparkleSpread = 16; +const int _sparkleSeedSteps = 24; + +/// One traveller's frame: where it sits along the edge, its width factor, the +/// edge fade it carries, and the slice of the edge it owns. +/// +/// [band] is null for a lone beam — the ordinary case paints exactly as it +/// always has, with no clip and no radius clamp. +typedef _Traveller = ({ + double x, + double w, + double fade, + Rect? band, + double? fraction, + Path? pathBand, +}); + +/// The perimeter slice a path-mode frame is painted over: the geometry, the +/// clockwise range, and whether travel runs against that range. +/// +/// [mirrored] is set for the edge-derived range `wrapCorners` builds, where +/// the beam must keep the planar variant's direction (see [_paintPathAbove]). +typedef _PathContext = ({ + BeamRingGeometry geometry, + double from, + double span, + bool mirrored, +}); + +/// The edge-riding traveling beam (React `line`): all masks are radial +/// windows anchored at the traveling position on the edge, plus a bloom of +/// fixed spikes that shimmer via two counter-phased scale tracks. +/// +/// The geometry is authored for the bottom edge; every other edge is that +/// painting turned about the box's centre, so the transcribed numbers are +/// never touched. class LineStrategy extends BeamVariantStrategy { /// Const constructor. const LineStrategy(); @@ -26,15 +73,114 @@ class LineStrategy extends BeamVariantStrategy { BeamConfig config, BeamFramePhases phases, ) { - if (phases.fadeOpacity <= 0 || phases.edge <= 0) return; - final rect = Offset.zero & size; + if (phases.fadeOpacity <= 0) return; + + final pathMode = config.segment != null || config.wrapCorners; + if (pathMode) { + _paintPathAbove(canvas, size, config, phases); + return; + } + + // The rotations run counter-clockwise around the box, so a beam that + // rides the bottom edge left-to-right rides the right edge + // bottom-to-top. + canvas.save(); + final authored = _applyEdgeTransform(canvas, size, config.edge); + final rect = beamRect(authored, config); final geometry = BeamRingGeometry( rect: rect, - radius: config.borderRadius, + radius: _rotateRadii(config.borderRadius, config.edge), borderWidth: config.borderWidth, useSuperellipse: config.useSuperellipse, + contour: config.contour, + ); + final isDark = config.brightness == Brightness.dark; + final beams = _travellers(rect, phases); + // A group's opacity carries the brightest traveller's edge fade; each + // traveller then scales its own colours by the rest of it. The bands are + // disjoint, so scaling per band is exact rather than an approximation. + var fadeMax = 0.0; + for (final beam in beams) { + fadeMax = math.max(fadeMax, beam.fade); + } + if (fadeMax <= 0) { + canvas.restore(); + return; + } + + final layerMatrix = config.staticColors + ? null + : BeamColorMatrix.beamFilter( + hueDegrees: phases.hueDegrees + config.hueBase, + brightness: config.brightnessFactor, + saturation: config.saturation, + ); + Color fold(Color c) => layerMatrix?.transform(c) ?? c; + + _paintInner(canvas, rect, geometry, config, phases, fold, beams, fadeMax); + _paintStroke( + canvas, + rect, + geometry, + config, + phases, + fold, + isDark, + beams, + fadeMax, + ); + _paintBloom(canvas, rect, geometry, config, phases, isDark, beams, fadeMax); + canvas.restore(); + } + + // A configured segment owns line travel, so [BeamConfig.edge] is ignored: + // the beam runs the segment's own clockwise start → end. + // + // With wrapCorners alone the selected edge resolves to its straight run plus + // both adjacent corner arcs, and feeds the same path-space code with one + // rule attached: wrapCorners is a modifier on the ordinary line variant, so + // turning it on must not reverse the animation. The planar painting is + // authored on the bottom edge running left to right, and every other edge is + // that painting turned rigidly about the box's centre — which leaves travel + // running counter-clockwise around the box on all four edges (bottom left to + // right, top right to left, left top to bottom, right bottom to top), the + // opposite of the perimeter's clockwise parameterisation. An edge-derived + // range therefore carries `mirrored`, and every progress, spike fraction and + // blob offset goes through [_pathFraction] to flip. `BeamDirection` is + // already folded into `phases.travellers` and composes on top of this. + void _paintPathAbove( + Canvas canvas, + Size size, + BeamConfig config, + BeamFramePhases phases, + ) { + final rect = beamRect(size, config); + final segment = config.segment ?? _edgeSegment(config.edge); + final geometry = config.segment != null + ? beamGeometry(rect, config) + : BeamRingGeometry( + rect: rect, + radius: config.borderRadius, + borderWidth: config.borderWidth, + useSuperellipse: config.useSuperellipse, + contour: config.contour, + segment: segment, + ); + final range = geometry.segmentRange!; + final span = _clockwiseSpan(range.from, range.to); + final path = ( + geometry: geometry, + from: range.from, + span: span, + mirrored: config.segment == null, ); final isDark = config.brightness == Brightness.dark; + final beams = _pathTravellers(path, phases); + var fadeMax = 0.0; + for (final beam in beams) { + fadeMax = math.max(fadeMax, beam.fade); + } + if (fadeMax <= 0) return; final layerMatrix = config.staticColors ? null @@ -45,13 +191,201 @@ class LineStrategy extends BeamVariantStrategy { ); Color fold(Color c) => layerMatrix?.transform(c) ?? c; - _paintInner(canvas, rect, geometry, config, phases, fold); - _paintStroke(canvas, rect, geometry, config, phases, fold, isDark); - _paintBloom(canvas, rect, geometry, config, phases, isDark); + _paintInner( + canvas, + rect, + geometry, + config, + phases, + fold, + beams, + fadeMax, + path: path, + ); + _paintStroke( + canvas, + rect, + geometry, + config, + phases, + fold, + isDark, + beams, + fadeMax, + path: path, + ); + _paintBloom( + canvas, + rect, + geometry, + config, + phases, + isDark, + beams, + fadeMax, + path: path, + ); + } + + // ─── Travel ───────────────────────────────────────────────────────────── + + // Resolves every beam travelling the edge this frame. With one beam the + // keyframe values already on the phases are used as they are; with several, + // each traveller samples the same tracks at its own progress and takes the + // slice of the edge reaching halfway to each neighbour — disjoint bands are + // what let several radial masks union inside one layer, since a `dstIn` + // draw multiplies and would otherwise erase its neighbours. + List<_Traveller> _travellers(Rect rect, BeamFramePhases phases) { + final progress = phases.travellers; + if (progress.length <= 1) { + return [ + ( + x: phases.lineX, + w: phases.lineW, + fade: phases.edge, + band: null, + fraction: null, + pathBand: null, + ), + ]; + } + final sorted = [...progress] + ..sort( + (a, b) => sampleKeyframes( + lineTravelX, + a, + ).compareTo(sampleKeyframes(lineTravelX, b)), + ); + final xs = [for (final p in sorted) sampleKeyframes(lineTravelX, p)]; + final result = <_Traveller>[]; + for (var i = 0; i < sorted.length; i++) { + final centre = rect.left + xs[i] * rect.width; + final left = i == 0 + ? rect.left - _bandOverhang + : (centre + rect.left + xs[i - 1] * rect.width) / 2; + final right = i == sorted.length - 1 + ? rect.right + _bandOverhang + : (centre + rect.left + xs[i + 1] * rect.width) / 2; + result.add(( + x: xs[i], + w: sampleKeyframes(lineTravelW, sorted[i]), + fade: sampleKeyframes(lineEdgeFade, sorted[i]), + band: Rect.fromLTRB( + left, + rect.top - _bandOverhang, + right, + rect.bottom + _bandOverhang, + ), + fraction: null, + pathBand: null, + )); + } + return result; + } + + List<_Traveller> _pathTravellers(_PathContext path, BeamFramePhases phases) { + final sorted = [...phases.travellers]..sort(); + return [ + for (var i = 0; i < sorted.length; i++) + ( + x: sampleKeyframes(lineTravelX, sorted[i]), + w: sampleKeyframes(lineTravelW, sorted[i]), + fade: sampleKeyframes(lineEdgeFade, sorted[i]), + band: null, + fraction: _pathFraction(path, sorted[i]), + pathBand: sorted.length == 1 + ? null + : _pathBand( + path, + // The halfway points to each neighbour in travel order. A + // mirrored range reverses them on the perimeter, so the + // clockwise band runs from the later progress to the + // earlier one. + lower: i == 0 ? 0 : (sorted[i - 1] + sorted[i]) / 2, + upper: i == sorted.length - 1 + ? 1 + : (sorted[i] + sorted[i + 1]) / 2, + ), + ), + ]; + } + + Path _pathBand( + _PathContext path, { + required double lower, + required double upper, + }) => path.geometry.perimeter.band( + from: _pathFraction(path, path.mirrored ? upper : lower), + to: _pathFraction(path, path.mirrored ? lower : upper), + inward: _bandOverhang, + outward: _bandOverhang, + ); + + Offset _anchor( + Rect rect, + _Traveller beam, { + double dy = 0, + _PathContext? path, + }) => path == null + ? Offset(rect.left + beam.x * rect.width, rect.bottom + dy) + : path.geometry.perimeter.offsetPointAt(beam.fraction!, -dy); + + Offset _blobAnchor( + Rect rect, + _Traveller beam, { + required double offsetX, + required double inward, + _PathContext? path, + }) => path == null + ? Offset(rect.left + beam.x * rect.width + offsetX, rect.bottom - inward) + : path.geometry.perimeter.offsetPointAt( + // A blob's offsetX runs along the direction of travel, which is + // against the clockwise parameterisation on a mirrored range. + beam.fraction! + + (path.geometry.perimeter.length <= 0 + ? 0 + : (path.mirrored ? -offsetX : offsetX) / + path.geometry.perimeter.length), + inward, + ); + + double _rotation(_Traveller beam, _PathContext? path) { + if (path == null) return 0; + final tangent = path.geometry.perimeter.tangentAt(beam.fraction!); + return math.atan2(tangent.dy, tangent.dx); + } + + // The widest a traveller's mask may reach without spilling into a + // neighbour's band, where the clip would cut it while it is still opaque. + double _maskRadiusX(Rect rect, _Traveller beam, double wanted) { + final band = beam.band; + if (band == null) return wanted; + final centre = rect.left + beam.x * rect.width; + return math.min(wanted, math.min(centre - band.left, band.right - centre)); + } + + // Runs [paint] for one traveller, inside its own slice of the edge. + void _forEachTraveller( + Canvas canvas, + List<_Traveller> beams, + void Function(_Traveller beam) paint, + ) { + for (final beam in beams) { + final band = beam.band; + final pathBand = beam.pathBand; + if (band == null && pathBand == null) { + paint(beam); + continue; + } + canvas.save(); + if (band != null) canvas.clipRect(band); + if (pathBand != null) canvas.clipPath(pathBand); + paint(beam); + canvas.restore(); + } } - Offset _beamAnchor(Rect rect, BeamFramePhases phases, {double dy = 0}) => - Offset(rect.left + phases.lineX * rect.width, rect.bottom + dy); + // ─── Layers ───────────────────────────────────────────────────────────── void _paintInner( Canvas canvas, @@ -60,12 +394,15 @@ class LineStrategy extends BeamVariantStrategy { BeamConfig config, BeamFramePhases phases, Color Function(Color) fold, - ) { + List<_Traveller> beams, + double fadeMax, { + _PathContext? path, + }) { // The line variant does not apply the mono ×0.5 multiplier — its mono // treatment lives in the bloom spike attenuation. final opacity = (phases.fadeOpacity * - phases.edge * + fadeMax * config.theme.innerOpacity * config.innerOpacityFactor * config.strength) @@ -74,28 +411,41 @@ class LineStrategy extends BeamVariantStrategy { canvas.save(); canvas.clipPath(geometry.outer); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: rect.shortestSide / 2, + outward: 0, + ); canvas.saveLayer(rect, Paint()..color = _white.withValues(alpha: opacity)); - for (final blob in config.palette.data.lineInner) { - BeamGradients.paintBlob( - canvas, - center: Offset( - rect.left + phases.lineX * rect.width + blob.offsetX, - rect.bottom - blob.offsetY.abs(), - ), - radiusX: blob.sizeW * phases.lineW, - radiusY: blob.sizeH * phases.lineH, - color: fold(blob.color), - ); - } + _forEachTraveller(canvas, beams, (beam) { + final tint = _tint(fold, beam, fadeMax); + for (final blob in config.palette.data.lineInner) { + BeamGradients.paintBlob( + canvas, + center: _blobAnchor( + rect, + beam, + offsetX: blob.offsetX, + inward: blob.offsetY.abs(), + path: path, + ), + radiusX: blob.sizeW * beam.w, + radiusY: blob.sizeH * phases.lineH, + color: tint(blob.color), + rotation: _rotation(beam, path), + ); + } + }); BeamLayerUtils.paintInnerShadow( canvas, contour: geometry.outer, color: fold(config.theme.innerShadow), - blur: 9, + blur: lineInnerShadowBlur, ); - // Mask: (featherV ∪ featherH) ∩ radial window. + // Mask: (featherV ∪ featherH) ∩ the union of the travellers' windows. canvas.saveLayer(rect, Paint()..blendMode = BlendMode.dstIn); canvas.drawRect( rect, @@ -107,20 +457,18 @@ class LineStrategy extends BeamVariantStrategy { ..blendMode = BlendMode.screen ..shader = BeamGradients.horizontalEdgeFeather(rect), ); - canvas.drawRect( - rect, - Paint() - ..blendMode = BlendMode.dstIn - ..shader = BeamGradients.radialWindow( - center: _beamAnchor(rect, phases), - radiusX: 78 * phases.lineW, - radiusY: 60 * phases.lineH, - midStop: 0.45, - midAlpha: 0.5, - ), - ); + _forEachTraveller(canvas, beams, (beam) { + canvas.drawRect( + rect, + Paint() + ..blendMode = BlendMode.dstIn + ..shader = _window(rect, beam, phases, path: path), + ); + }); canvas.restore(); + BeamLayerUtils.applySegmentFeather(canvas, rect, geometry); + canvas.restore(); canvas.restore(); } @@ -133,10 +481,13 @@ class LineStrategy extends BeamVariantStrategy { BeamFramePhases phases, Color Function(Color) fold, bool isDark, - ) { + List<_Traveller> beams, + double fadeMax, { + _PathContext? path, + }) { final opacity = (phases.fadeOpacity * - phases.edge * + fadeMax * config.theme.strokeOpacity * config.strokeOpacityFactor * config.strength) @@ -145,66 +496,76 @@ class LineStrategy extends BeamVariantStrategy { canvas.save(); canvas.clipPath(geometry.ring); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: geometry.borderWidth, + outward: 0, + ); canvas.saveLayer(rect, Paint()..color = _white.withValues(alpha: opacity)); - // White (dark) / black (light) traveling highlight. - if (isDark) { - BeamLayerUtils.paintRadial( - canvas, - center: _beamAnchor(rect, phases, dy: 2), - radiusX: 24 * phases.lineW, - radiusY: 28 * phases.lineH, - colors: [ - fold(_white.withValues(alpha: 0.38)), - fold(_white.withValues(alpha: 0.12)), - fold(_white.withValues(alpha: 0)), - ], - stops: const [0, 0.30, 0.65], - ); - } else { + _forEachTraveller(canvas, beams, (beam) { + final tint = _tint(fold, beam, fadeMax); + // White (dark) / black (light) traveling highlight. + final base = isDark ? _white : _black; + final alphas = isDark + ? lineHighlightAlphasDark + : lineHighlightAlphasLight; BeamLayerUtils.paintRadial( canvas, - center: _beamAnchor(rect, phases, dy: 2), - radiusX: 35 * phases.lineW, - radiusY: 28 * phases.lineH, - colors: [ - fold(_black.withValues(alpha: 0.6)), - fold(_black.withValues(alpha: 0.25)), - fold(_black.withValues(alpha: 0)), - ], - stops: const [0, 0.35, 0.70], + center: _anchor(rect, beam, dy: lineHighlightOffsetY, path: path), + radiusX: + (isDark ? lineHighlightRadiusXDark : lineHighlightRadiusXLight) * + beam.w, + radiusY: + (isDark ? lineHighlightRadiusYDark : lineHighlightRadiusYLight) * + phases.lineH, + colors: [for (final a in alphas) tint(base.withValues(alpha: a))], + stops: isDark ? lineHighlightStopsDark : lineHighlightStopsLight, + rotation: _rotation(beam, path), ); - } - final blobs = isDark - ? config.palette.data.lineDark - : config.palette.data.lineLight; - for (final blob in blobs) { - BeamGradients.paintBlob( - canvas, - center: Offset( - rect.left + phases.lineX * rect.width + blob.offsetX, - rect.bottom + blob.offsetY, - ), - radiusX: blob.sizeW * phases.lineW, - radiusY: blob.sizeH * phases.lineH, - color: fold(blob.color), + final blobs = isDark + ? config.palette.data.lineDark + : config.palette.data.lineLight; + for (final blob in blobs) { + BeamGradients.paintBlob( + canvas, + center: _blobAnchor( + rect, + beam, + offsetX: blob.offsetX, + inward: -blob.offsetY, + path: path, + ), + radiusX: blob.sizeW * beam.w, + radiusY: blob.sizeH * phases.lineH, + color: tint(blob.color), + rotation: _rotation(beam, path), + ); + } + canvas.drawRect( + rect, + Paint() + ..blendMode = BlendMode.dstIn + ..shader = _window(rect, beam, phases, path: path), ); - } - canvas.drawRect( - rect, - Paint() - ..blendMode = BlendMode.dstIn - ..shader = BeamGradients.radialWindow( - center: _beamAnchor(rect, phases), - radiusX: 78 * phases.lineW, - radiusY: 60 * phases.lineH, - midStop: 0.45, - midAlpha: 0.5, - ), - ); + }); + + BeamLayerUtils.applySegmentFeather(canvas, rect, geometry); canvas.restore(); canvas.restore(); + + _paintSparkles( + canvas, + rect, + config, + beams, + opacity, + fadeMax, + isDark, + path: path, + ); } void _paintBloom( @@ -214,10 +575,13 @@ class LineStrategy extends BeamVariantStrategy { BeamConfig config, BeamFramePhases phases, bool isDark, - ) { + List<_Traveller> beams, + double fadeMax, { + _PathContext? path, + }) { final opacity = (phases.fadeOpacity * - phases.edge * + fadeMax * config.theme.bloomOpacity * config.bloomOpacityFactor * config.strength) @@ -227,7 +591,12 @@ class LineStrategy extends BeamVariantStrategy { final mono = config.palette.monoTreatment; // Bloom filter: blur(8) + hue(range+10) when animating; blur(6) for // mono; no filter when static non-mono (matching the generated CSS). - final blurSigma = mono ? 6.0 : (config.staticColors ? 0.0 : 8.0); + // glowSpread scales whichever blur applies. + final blurSigma = + (mono + ? lineBloomBlurSigmaMono + : (config.staticColors ? 0.0 : lineBloomBlurSigma)) * + config.glowSpread; final matrix = config.staticColors ? null : BeamColorMatrix.beamFilter( @@ -248,30 +617,121 @@ class LineStrategy extends BeamVariantStrategy { canvas.save(); canvas.clipPath(geometry.outer); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: rect.shortestSide / 2, + outward: 0, + ); canvas.saveLayer(rect, layerPaint); - _paintSpikes(canvas, rect, config, phases, isDark, mono, fold); + _forEachTraveller(canvas, beams, (beam) { + _paintSpikes( + canvas, + rect, + config, + phases, + isDark, + mono, + _tint(fold, beam, fadeMax), + beam, + path: path, + ); + canvas.drawRect( + rect, + Paint() + ..blendMode = BlendMode.dstIn + ..shader = BeamGradients.radialWindow( + center: _anchor(rect, beam, path: path), + radiusX: _maskRadiusX(rect, beam, lineBloomWindowRadiusX * beam.w), + radiusY: lineBloomWindowRadiusY * phases.lineH, + midStop: lineBloomWindowMidStop, + midAlpha: lineBloomWindowMidAlpha, + rotation: _rotation(beam, path), + ), + ); + }); - canvas.drawRect( - rect, - Paint() - ..blendMode = BlendMode.dstIn - ..shader = BeamGradients.radialWindow( - center: _beamAnchor(rect, phases), - radiusX: 84 * phases.lineW, - radiusY: 110 * phases.lineH, - midStop: 0.35, - midAlpha: 0.5, - ), - ); + BeamLayerUtils.applySegmentFeather(canvas, rect, geometry); canvas.restore(); canvas.restore(); } - // The fixed bloom spikes at 8/22/36/50/64/78/92% of the bottom edge, plus - // the traveling dot/ambient (dark) or shadow blob (light). Transcribed - // from getLineBloomGradients. + Shader _window( + Rect rect, + _Traveller beam, + BeamFramePhases phases, { + _PathContext? path, + }) => BeamGradients.radialWindow( + center: _anchor(rect, beam, path: path), + radiusX: _maskRadiusX(rect, beam, lineWindowRadiusX * beam.w), + radiusY: lineWindowRadiusY * phases.lineH, + midStop: lineWindowMidStop, + midAlpha: lineWindowMidAlpha, + rotation: _rotation(beam, path), + ); + + // A traveller's colour treatment: the shared fold, then the share of the + // edge fade the group opacity did not already carry. + Color Function(Color) _tint( + Color Function(Color) fold, + _Traveller beam, + double fadeMax, + ) { + final k = fadeMax <= 0 ? 0.0 : (beam.fade / fadeMax).clamp(0.0, 1.0); + if (k >= 1) return fold; + return (c) { + final folded = fold(c); + return folded.withValues(alpha: folded.a * k); + }; + } + + // Twinkles at each traveller, drawn over the stroke without a layer of + // their own. + void _paintSparkles( + Canvas canvas, + Rect rect, + BeamConfig config, + List<_Traveller> beams, + double opacity, + double fadeMax, + bool isDark, { + _PathContext? path, + }) { + if (config.sparkle <= 0 || rect.isEmpty) return; + if (path != null) { + canvas.save(); + canvas.clipPath( + BeamLayerUtils.segmentBandPath( + path.geometry, + inward: _sparkleSpread, + outward: _sparkleSpread, + ), + ); + } + for (var i = 0; i < beams.length; i++) { + final beam = beams[i]; + final weight = path == null + ? 1.0 + : path.geometry.segmentWeightAt(beam.fraction!); + if (weight <= 0) continue; + BeamLayerUtils.paintSparkles( + canvas, + center: _anchor(rect, beam, path: path), + density: config.sparkle, + color: isDark ? _white : _black, + opacity: opacity * (beam.fade / fadeMax) * weight, + spread: _sparkleSpread, + seed: (beam.x * _sparkleSeedSteps).floor() * 7 + i, + ); + } + if (path != null) canvas.restore(); + } + + // The fixed bloom spikes at 8/22/36/50/64/78/92% of the edge, plus the + // traveling dot/ambient (dark) or shadow blob (light). Transcribed from + // getLineBloomGradients. void _paintSpikes( Canvas canvas, Rect rect, @@ -280,61 +740,87 @@ class LineStrategy extends BeamVariantStrategy { bool isDark, bool mono, Color Function(Color) fold, - ) { + _Traveller beam, { + _PathContext? path, + }) { final palette = config.palette.data; final spikeColors = isDark ? palette.spike : palette.spikeLt; final table = isDark ? palette.lineBloomDark : palette.lineBloomLight; Color att(Color c, double f) => BeamLayerUtils.attenuateSpike(c, f); - final sc1 = mono ? att(spikeColors.primary, 0.14) : spikeColors.primary; + final sc1 = mono + ? att(spikeColors.primary, lineMonoSpike1) + : spikeColors.primary; final sc1MidDark = mono - ? att(spikeColors.primary, 0.09) + ? att(spikeColors.primary, lineMonoSpike1MidDark) : spikeColors.primary; final sc1MidLight = mono - ? att(spikeColors.primary, 0.11) - : BeamLayerUtils.withAlpha(spikeColors.primary, 0.85); - final sc2 = mono ? att(spikeColors.secondary, 0.12) : spikeColors.secondary; + ? att(spikeColors.primary, lineMonoSpike1MidLight) + : BeamLayerUtils.withAlpha( + spikeColors.primary, + lineSpike1MidLightAlpha, + ); + final sc2 = mono + ? att(spikeColors.secondary, lineMonoSpike2) + : spikeColors.secondary; final sc2MidDark = mono - ? BeamLayerUtils.withAlpha(spikeColors.secondary, 0.06) - : BeamLayerUtils.withAlpha(spikeColors.secondary, 0.49); + ? BeamLayerUtils.withAlpha(spikeColors.secondary, lineMonoSpike2MidDark) + : BeamLayerUtils.withAlpha( + spikeColors.secondary, + lineSpike2MidDarkAlpha, + ); final sc2MidLight = mono - ? att(spikeColors.secondary, 0.09) - : BeamLayerUtils.withAlpha(spikeColors.secondary, 0.7); + ? att(spikeColors.secondary, lineMonoSpike2MidLight) + : BeamLayerUtils.withAlpha( + spikeColors.secondary, + lineSpike2MidLightAlpha, + ); SpikePair spikeAt(int i) => mono ? SpikePair( - att(table[i].color1, 0.14), - att(table[i].color2, 0.14 * 0.7), + att(table[i].color1, lineMonoTableSpike1), + att(table[i].color2, lineMonoTableSpike2), ) : table[i]; // Mono widens and shortens the thin spikes into a soft glow. - final thinW1 = mono ? 12.0 : 0.8; - final thinW2 = mono ? 14.0 : 2.0; - final thinW3 = mono ? 12.0 : 1.2; - final thinW4 = mono ? 10.0 : 0.6; - final thinLW = mono ? 12.0 : 1.0; - final thinH1 = mono ? 42.0 : 92.0; - final thinH2 = mono ? 38.0 : 72.0; - final thinH3 = mono ? 40.0 : 85.0; - final thinH4 = mono ? 32.0 : 60.0; + final thinW = mono ? lineMonoThinSpikeWidths : lineThinSpikeWidths; + final thinH = mono ? lineMonoThinSpikeHeights : lineThinSpikeHeights; + final thinLW = mono + ? lineMonoThinSpikeWidthLight92 + : lineThinSpikeWidthLight92; void spike({ - required double fx, - required double yInset, + required int index, required double rx, required double ry, required Color c0, required Color cMid, - required double midStop, - required double endStop, }) { + final geometry = lineSpikes[index]; + // The spike table is authored from the start of the planar edge, so on + // a mirrored range its fractions flip with the travel direction and the + // seven spikes keep the screen positions the planar variant gives them. + final fraction = path == null ? 0.0 : _pathFraction(path, geometry.fx); + final center = path == null + ? Offset( + rect.left + geometry.fx * rect.width, + rect.bottom - geometry.yInset, + ) + : path.geometry.perimeter.offsetPointAt(fraction, geometry.yInset); + final rotation = path == null + ? 0.0 + : () { + final tangent = path.geometry.perimeter.tangentAt(fraction); + return math.atan2(tangent.dy, tangent.dx); + }(); BeamLayerUtils.paintRadial( canvas, - center: Offset(rect.left + fx * rect.width, rect.bottom - yInset), + center: center, radiusX: rx, radiusY: ry, colors: [fold(c0), fold(cMid), fold(cMid.withValues(alpha: 0))], - stops: [0, midStop, endStop], + stops: [0, geometry.midStop, geometry.endStop], + rotation: rotation, ); } @@ -343,125 +829,184 @@ class LineStrategy extends BeamVariantStrategy { final h = phases.lineH; spike( - fx: 0.08, - yInset: 2, - rx: thinW1 * s, - ry: thinH1 * h, + index: 0, + rx: thinW[0] * s, + ry: thinH[0] * h, c0: sc1, cMid: isDark ? sc1MidDark : sc1MidLight, - midStop: 0.30, - endStop: 0.88, ); spike( - fx: 0.22, - yInset: 4, - rx: 10 * s2, - ry: 35 * h, + index: 1, + rx: lineSpikeWideRadiusX22 * s2, + ry: lineSpikeRadiusY22 * h, c0: sc2, cMid: isDark ? sc2MidDark : sc2MidLight, - midStop: 0.50, - endStop: 0.95, ); spike( - fx: 0.36, - yInset: 3, - rx: thinW2 * (2 - s), - ry: thinH2 * h, + index: 2, + rx: thinW[1] * (2 - s), + ry: thinH[1] * h, c0: spikeAt(0).color1, cMid: spikeAt(0).color2, - midStop: 0.40, - endStop: 0.90, ); spike( - fx: 0.50, - yInset: 2, - rx: 14 * s2, - ry: 28 * h, + index: 3, + rx: lineSpikeWideRadiusX50 * s2, + ry: lineSpikeRadiusY50 * h, c0: spikeAt(1).color1, cMid: spikeAt(1).color2, - midStop: 0.55, - endStop: 0.96, ); spike( - fx: 0.64, - yInset: 4, - rx: thinW3 * (2 - s2), - ry: thinH3 * h, + index: 4, + rx: thinW[2] * (2 - s2), + ry: thinH[2] * h, c0: spikeAt(2).color1, cMid: spikeAt(2).color2, - midStop: 0.35, - endStop: 0.89, ); spike( - fx: 0.78, - yInset: 2, - rx: 7 * s, - ry: 45 * h, + index: 5, + rx: lineSpikeRadiusX78 * s, + ry: lineSpikeRadiusY78 * h, c0: spikeAt(3).color1, cMid: spikeAt(3).color2, - midStop: 0.48, - endStop: 0.94, ); spike( - fx: 0.92, - yInset: 3, - rx: (isDark ? thinW4 : thinLW) * (2 - s), - ry: thinH4 * h, + index: 6, + rx: (isDark ? thinW[3] : thinLW) * (2 - s), + ry: thinH[3] * h, c0: spikeAt(4).color1, cMid: spikeAt(4).color2, - midStop: 0.42, - endStop: 0.91, ); if (isDark) { // Traveling dot + ambient glow. - final dotC = _white.withValues(alpha: mono ? 0.5 : 1.0); - final dot20 = _white.withValues(alpha: mono ? 0.45 : 0.9); - final dot50 = _white.withValues(alpha: mono ? 0.25 : 0.5); + final dot = mono ? lineDotAlphasMono : lineDotAlphas; + final dotC = _white.withValues(alpha: dot[0]); + final dot20 = _white.withValues(alpha: dot[1]); + final dot50 = _white.withValues(alpha: dot[2]); BeamLayerUtils.paintRadial( canvas, - center: _beamAnchor(rect, phases, dy: 1), - radiusX: 21 * s, - radiusY: 15 * s2, + center: _anchor(rect, beam, dy: lineDotOffsetY, path: path), + radiusX: lineDotRadiusX * s, + radiusY: lineDotRadiusY * s2, colors: [ fold(dotC), fold(dot20), fold(dot50), fold(dot50.withValues(alpha: 0)), ], - stops: const [0, 0.20, 0.50, 1.0], + stops: lineDotStops, + rotation: _rotation(beam, path), ); - final ambC = _white.withValues(alpha: mono ? 0.15 : 0.3); - final amb25 = _white.withValues(alpha: mono ? 0.06 : 0.12); - final amb55 = _white.withValues(alpha: mono ? 0.015 : 0.03); + final ambient = mono ? lineAmbientAlphasMono : lineAmbientAlphas; + final ambC = _white.withValues(alpha: ambient[0]); + final amb25 = _white.withValues(alpha: ambient[1]); + final amb55 = _white.withValues(alpha: ambient[2]); BeamLayerUtils.paintRadial( canvas, - center: _beamAnchor(rect, phases), - radiusX: 42 * phases.lineW, - radiusY: 40 * h, + center: _anchor(rect, beam, path: path), + radiusX: lineAmbientRadiusX * beam.w, + radiusY: lineAmbientRadiusY * h, colors: [ fold(ambC), fold(amb25), fold(amb55), fold(amb55.withValues(alpha: 0)), ], - stops: const [0, 0.25, 0.55, 0.80], + stops: lineAmbientStops, + rotation: _rotation(beam, path), ); } else { // Light theme: a traveling dark shadow blob instead of the bright dot. BeamLayerUtils.paintRadial( canvas, - center: _beamAnchor(rect, phases), - radiusX: 50 * phases.lineW, - radiusY: 32 * h, + center: _anchor(rect, beam, path: path), + radiusX: lineShadowRadiusX * beam.w, + radiusY: lineShadowRadiusY * h, colors: [ - fold(_black.withValues(alpha: 0.5)), - fold(_black.withValues(alpha: 0.18)), - fold(_black.withValues(alpha: 0.03)), - fold(_black.withValues(alpha: 0)), + for (final a in lineShadowAlphas) fold(_black.withValues(alpha: a)), ], - stops: const [0, 0.30, 0.60, 0.85], + stops: lineShadowStops, + rotation: _rotation(beam, path), ); } } } + +double _fraction(double value) { + final result = value % 1; + return result < 0 ? result + 1 : result; +} + +// Where progress [t] along a path-mode run lands on the perimeter. A mirrored +// range walks its clockwise span backwards, which is what keeps wrapCorners +// travelling the way the planar variant does. +double _pathFraction(_PathContext path, double t) => + _fraction(path.from + path.span * (path.mirrored ? 1 - t : t)); + +double _clockwiseSpan(double from, double to) { + final span = _fraction(to - from); + return span == 0 ? 1 : span; +} + +BeamSegment _edgeSegment(BeamEdge edge) => switch (edge) { + BeamEdge.top => BeamSegment.topEdge, + BeamEdge.right => BeamSegment.rightEdge, + BeamEdge.bottom => BeamSegment.bottomEdge, + BeamEdge.left => BeamSegment.leftEdge, +}; + +// Turns the canvas so the authored bottom-edge painting lands on [edge], and +// returns the size it should be authored against — swapped for the two +// vertical edges, where the box's height becomes the authored width. +Size _applyEdgeTransform(Canvas canvas, Size size, BeamEdge edge) { + switch (edge) { + case BeamEdge.bottom: + return size; + case BeamEdge.top: + canvas.translate(size.width, size.height); + canvas.rotate(math.pi); + return size; + case BeamEdge.left: + canvas.translate(size.width, 0); + canvas.rotate(math.pi / 2); + return Size(size.height, size.width); + case BeamEdge.right: + canvas.translate(0, size.height); + canvas.rotate(-math.pi / 2); + return Size(size.height, size.width); + } +} + +// The corner radii to author the ring with so that, once the canvas turn is +// applied, every corner lands back on the corner of the child it belongs to. +BorderRadius _rotateRadii(BorderRadius radii, BeamEdge edge) { + switch (edge) { + case BeamEdge.bottom: + return radii; + case BeamEdge.top: + return BorderRadius.only( + topLeft: radii.bottomRight, + topRight: radii.bottomLeft, + bottomLeft: radii.topRight, + bottomRight: radii.topLeft, + ); + case BeamEdge.left: + return BorderRadius.only( + topLeft: _swapAxes(radii.topRight), + topRight: _swapAxes(radii.bottomRight), + bottomRight: _swapAxes(radii.bottomLeft), + bottomLeft: _swapAxes(radii.topLeft), + ); + case BeamEdge.right: + return BorderRadius.only( + topLeft: _swapAxes(radii.bottomLeft), + topRight: _swapAxes(radii.topLeft), + bottomRight: _swapAxes(radii.topRight), + bottomLeft: _swapAxes(radii.bottomRight), + ); + } +} + +// A quarter turn swaps an elliptical radius' two axes. +Radius _swapAxes(Radius r) => Radius.elliptical(r.y, r.x); diff --git a/lib/src/painting/strategies/pulse_common.dart b/lib/src/painting/strategies/pulse_common.dart index 267e8af..6bbcdc8 100644 --- a/lib/src/painting/strategies/pulse_common.dart +++ b/lib/src/painting/strategies/pulse_common.dart @@ -4,6 +4,14 @@ import '../../animation/oscillator.dart'; import '../../models/beam_blob.dart'; import '../gradient_builders.dart'; +/// Reads a pulse table's color source, cycling a short custom spec palette. +/// +/// [border] is never empty: every palette that reaches the painter resolves +/// through `BeamColors`, and `validateColorTable` rejects an empty table +/// there — in release builds as well as debug ones. +BeamBlob pulseBlobAt(List border, int index) => + border[index % border.length]; + /// Resolves a [PulseQuad] to its animated opacity factor. double quadOpacity(PulsePhaseSet pulse, PulseQuad quad) => switch (quad) { PulseQuad.tl => pulse.bopTl, @@ -29,7 +37,9 @@ void paintPulseBlob( required double sy, required double boost, required Color Function(Color) fold, + double alphaScale = 1, }) { + if (alphaScale <= 0) return; final r = region.index; BeamGradients.paintBlob( canvas, @@ -40,7 +50,7 @@ void paintPulseBlob( radiusX: w * pulse.bw[r] * sx * boost, radiusY: h * pulse.bh[r] * pulse.bgh * sy * boost, color: fold(color), - alpha: quadOpacity(pulse, quad), + alpha: quadOpacity(pulse, quad) * alphaScale, ); } @@ -59,7 +69,9 @@ void paintFrozenPulseBlob( required double sy, required double boost, required Color Function(Color) fold, + double alphaScale = 1, }) { + if (alphaScale <= 0) return; BeamGradients.paintBlob( canvas, center: Offset( @@ -68,6 +80,6 @@ void paintFrozenPulseBlob( ), radiusX: w * sx * boost, radiusY: h * sy * boost, - color: fold(color.withValues(alpha: frozenAlpha)), + color: fold(color.withValues(alpha: frozenAlpha * alphaScale)), ); } diff --git a/lib/src/painting/strategies/pulse_inner_strategy.dart b/lib/src/painting/strategies/pulse_inner_strategy.dart index a711721..c2e03bc 100644 --- a/lib/src/painting/strategies/pulse_inner_strategy.dart +++ b/lib/src/painting/strategies/pulse_inner_strategy.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import '../../animation/beam_phases.dart'; +import '../../constants/pulse_constants.dart'; import '../../constants/pulse_params.dart'; import '../../constants/pulse_tables.dart'; import '../../models/beam_config.dart'; @@ -8,7 +9,6 @@ import '../../models/beam_variant.dart'; import '../color_matrix.dart'; import '../gradient_builders.dart'; import '../layer_utils.dart'; -import '../ring_geometry.dart'; import '../variant_strategy.dart'; import 'pulse_common.dart'; @@ -32,13 +32,8 @@ class PulseInnerStrategy extends BeamVariantStrategy { BeamFramePhases phases, ) { if (phases.fadeOpacity <= 0) return; - final rect = Offset.zero & size; - final geometry = BeamRingGeometry( - rect: rect, - radius: config.borderRadius, - borderWidth: config.borderWidth, - useSuperellipse: config.useSuperellipse, - ); + final rect = beamRect(size, config); + final geometry = beamGeometry(rect, config); final isDark = config.brightness == Brightness.dark; final params = PulseParams.resolve( BeamVariant.pulseInside, @@ -57,6 +52,18 @@ class PulseInnerStrategy extends BeamVariantStrategy { final boost = config.glowBoost; final border = config.palette.data.border; + // The inward wash — its blobs and its corner accents — is the one layer + // innerSizeScale moves; the perimeter ring and the bloom keep their own + // geometry so the border itself stays where it is. + final innerScale = config.innerSizeScale; + double weightAt(Offset point) => + geometry.segmentWeightAt(geometry.perimeter.nearestFraction(point)); + double blobWeight(Rect blobRect, Offset fractionalPos) => weightAt( + Offset( + blobRect.left + fractionalPos.dx * blobRect.width, + blobRect.top + fractionalPos.dy * blobRect.height, + ), + ); // ── z1: inner perimeter + corner accents (::before) ── final innerOpacity = BeamLayerUtils.layerOpacity( @@ -68,6 +75,12 @@ class PulseInnerStrategy extends BeamVariantStrategy { if (innerOpacity > 0) { canvas.save(); canvas.clipPath(geometry.outer); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: rect.shortestSide / 2, + outward: 0, + ); canvas.saveLayer( rect, Paint()..color = _white.withValues(alpha: innerOpacity), @@ -80,8 +93,8 @@ class PulseInnerStrategy extends BeamVariantStrategy { rect: rect, color: blob.color, fractionalPos: blob.position, - w: pulseInnerSizes[i].width, - h: pulseInnerSizes[i].height, + w: pulseInnerSizes[i].width * innerScale, + h: pulseInnerSizes[i].height * innerScale, region: map.region, quad: map.quad, pulse: phases.pulse, @@ -89,12 +102,15 @@ class PulseInnerStrategy extends BeamVariantStrategy { sy: 1, boost: boost, fold: fold, + alphaScale: blobWeight(rect, blob.position), ); } // Corner accents: fixed 60×60 ellipses whose alpha breathes with the // corner's quadrant oscillator. final cornerBase = isDark ? _white : const Color(0xFF000000); - final cornerAlpha = isDark ? 0.18 : 0.08; + final cornerAlpha = isDark + ? pulseInnerCornerAlphaDark + : pulseInnerCornerAlphaLight; final corners = [ (pos: rect.topLeft, quad: pulseRingMap[0].quad), (pos: rect.topRight, quad: pulseRingMap[6].quad), @@ -102,17 +118,21 @@ class PulseInnerStrategy extends BeamVariantStrategy { (pos: rect.bottomRight, quad: pulseRingMap[4].quad), ]; for (final corner in corners) { - final a = cornerAlpha * quadOpacity(phases.pulse, corner.quad); + final a = + cornerAlpha * + quadOpacity(phases.pulse, corner.quad) * + weightAt(corner.pos); + if (a <= 0) continue; BeamLayerUtils.paintRadial( canvas, center: corner.pos, - radiusX: 60, - radiusY: 60, + radiusX: pulseInnerCornerRadius * innerScale, + radiusY: pulseInnerCornerRadius * innerScale, colors: [ fold(cornerBase.withValues(alpha: a)), fold(cornerBase.withValues(alpha: 0)), ], - stops: const [0, 0.70], + stops: const [0, pulseInnerCornerEndStop], ); } @@ -130,6 +150,8 @@ class PulseInnerStrategy extends BeamVariantStrategy { ); canvas.restore(); + BeamLayerUtils.applySegmentFeather(canvas, rect, geometry); + canvas.restore(); canvas.restore(); } @@ -144,6 +166,12 @@ class PulseInnerStrategy extends BeamVariantStrategy { if (strokeOpacity > 0) { canvas.save(); canvas.clipPath(geometry.ring); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: geometry.borderWidth, + outward: 0, + ); canvas.saveLayer( rect, Paint()..color = _white.withValues(alpha: strokeOpacity), @@ -164,8 +192,22 @@ class PulseInnerStrategy extends BeamVariantStrategy { sy: 1, boost: boost, fold: fold, + alphaScale: blobWeight(rect, blob.position), + ); + } + // The dashed-ring mask rides inside the ring layer, the one layer a + // pulse beam draws its border in; the inner glow and the bloom stay + // continuous, since dashing a breathing wash reads as banding. + final segments = config.segments; + if (segments != null && segments >= 2) { + canvas.drawRect( + rect, + Paint() + ..blendMode = BlendMode.dstIn + ..shader = BeamGradients.segmentMask(rect, segments), ); } + BeamLayerUtils.applySegmentFeather(canvas, rect, geometry); canvas.restore(); canvas.restore(); } @@ -181,20 +223,26 @@ class PulseInnerStrategy extends BeamVariantStrategy { final frozenAlpha = 1 - params.op * 0.5; canvas.save(); canvas.clipPath(geometry.outer); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: geometry.borderWidth, + outward: 0, + ); canvas.saveLayer( rect, Paint() ..color = _white.withValues(alpha: bloomOpacity) ..imageFilter = ImageFilter.blur( - sigmaX: 8, - sigmaY: 8, + sigmaX: pulseInnerBloomBlurSigma * config.glowSpread, + sigmaY: pulseInnerBloomBlurSigma * config.glowSpread, tileMode: TileMode.decal, ), ); canvas.save(); canvas.clipPath(geometry.ring); for (final spec in pulseInnerBloom) { - final source = border[spec.ci]; + final source = pulseBlobAt(border, spec.ci); paintFrozenPulseBlob( canvas, rect: rect, @@ -207,9 +255,11 @@ class PulseInnerStrategy extends BeamVariantStrategy { sy: 1, boost: boost, fold: fold, + alphaScale: blobWeight(rect, source.position), ); } canvas.restore(); + BeamLayerUtils.applySegmentFeather(canvas, rect, geometry); canvas.restore(); canvas.restore(); } diff --git a/lib/src/painting/strategies/pulse_outer_strategy.dart b/lib/src/painting/strategies/pulse_outer_strategy.dart index 8623dde..7fd6e24 100644 --- a/lib/src/painting/strategies/pulse_outer_strategy.dart +++ b/lib/src/painting/strategies/pulse_outer_strategy.dart @@ -1,19 +1,56 @@ import 'dart:ui'; import '../../animation/beam_phases.dart'; +import '../../constants/pulse_constants.dart'; import '../../constants/pulse_params.dart'; import '../../constants/pulse_tables.dart'; import '../../models/beam_blob.dart'; import '../../models/beam_config.dart'; +import '../../models/beam_options.dart'; import '../../models/beam_variant.dart'; import '../color_matrix.dart'; +import '../gradient_builders.dart'; import '../layer_utils.dart'; -import '../ring_geometry.dart'; import '../variant_strategy.dart'; import 'pulse_common.dart'; const Color _white = Color(0xFFFFFFFF); +// ─── Verbatim: the source's stock outside-glow table ──────────────────────── +// +// The React library's own `outsideConstants`, before the demo page layers +// its tuned recipe over them (that recipe lives in `pulse_constants.dart`). +// These are absolute px: the stock look does not scale its reach or its blur +// with the element's size, which is exactly what BeamPulseOutsideTuning.stock +// selects. + +/// Stock inset the core glow is grown past the child's bounds. +const double _stockCoreInset = 10; + +/// Stock inset the bloom halo is grown past the child's bounds. +const double _stockBloomInset = 30; + +/// Stock core-glow blur on a dark background. +const double _stockCoreBlurDark = 3; + +/// Stock core-glow blur on a light background. +const double _stockCoreBlurLight = 6; + +/// Stock bloom-halo blur on a dark background. +const double _stockBloomBlurDark = 22.5; + +/// Stock bloom-halo blur on a light background. +const double _stockBloomBlurLight = 15; + +/// How far the two outward glow layers reach past the child, and how heavily +/// each is blurred, in the beam's own px. +typedef _GlowGeometry = ({ + double coreInset, + double bloomInset, + double coreBlur, + double bloomBlur, +}); + /// The outward-blooming breathing halo (React `pulse-outside`): a crisp /// stroke ring above the child, and a colorful core plus soft halo painted /// BEHIND and OUTSIDE the child (which must be opaque so only the outward @@ -22,51 +59,44 @@ class PulseOuterStrategy extends BeamVariantStrategy { /// Const constructor. const PulseOuterStrategy(); - /// Reference child dimensions the glow geometry was authored for. - static const double referenceWidth = 350; - - /// Reference child height. - static const double referenceHeight = 140; - - /// Glow scale clamp bounds. - static const double minScale = 0.35; - - /// Upper clamp bound. - static const double maxScale = 4; - - // The source's constant outward-glow transform (scale(0.95, 0.9)). - static const double _sw = 0.95; - static const double _sh = 0.9; - - // The demo-hero tuning recipe (`.beam-host--pulse-outside-tuned` in the - // source's demo). The library's RAW defaults render a sparse, dim halo of - // separate blobs; the pulse-outside look everyone knows from - // beam.jakubantalik.com layers this recipe on top: unit-scaled insets and - // blurs (the ~20px core blur is what melts the blobs into one continuous - // edge-hugging glow), ×1.71 layer opacities, brightness 1.3×1.71 and - // saturation 1.2×1.71, and a 1.05 prominence boost. Baked in as the - // Flutter defaults; every value stays overridable through the widget's - // coreBlur/bloomBlur/glowBrightness/glowSaturation/glowBoost/opacity - // hooks. - static const double _tunedBoost = 1.05; - static const double _tunedGlowMul = 1.71; - static const double _tunedCoreInset = 6; - static const double _tunedBloomInset = 14; - static const double _tunedCoreBlur = 10; - static const double _tunedBloomBlur = 19; - @override double? get preferredFps => 30; - double _sx(Size size) => - (size.width / referenceWidth).clamp(minScale, maxScale); - double _sy(Size size) => - (size.height / referenceHeight).clamp(minScale, maxScale); + double _sx(Size size) => (size.width / pulseOuterReferenceWidth).clamp( + pulseOuterMinScale, + pulseOuterMaxScale, + ); + double _sy(Size size) => (size.height / pulseOuterReferenceHeight).clamp( + pulseOuterMinScale, + pulseOuterMaxScale, + ); // Halo reach/blur scale with element size relative to the demo's Subscribe // button baseline (measured glow scale 0.35), damped ×0.7 // (`--sub-glow-unit`). - double _unit(Size size) => _sx(size) / minScale * 0.7; + double _unit(Size size) => + _sx(size) / pulseOuterMinScale * pulseOuterGlowUnitDamping; + + // The demo recipe scales every length by the size-derived unit; the stock + // table is fixed px and picks its blurs by brightness. + _GlowGeometry _geometry(BeamConfig config, Size size) { + if (config.pulseOutsideTuning == BeamPulseOutsideTuning.stock) { + final isDark = config.brightness == Brightness.dark; + return ( + coreInset: _stockCoreInset, + bloomInset: _stockBloomInset, + coreBlur: isDark ? _stockCoreBlurDark : _stockCoreBlurLight, + bloomBlur: isDark ? _stockBloomBlurDark : _stockBloomBlurLight, + ); + } + final unit = _unit(size); + return ( + coreInset: pulseOuterTunedCoreInset * unit, + bloomInset: pulseOuterTunedBloomInset * unit, + coreBlur: pulseOuterTunedCoreBlur * unit, + bloomBlur: pulseOuterTunedBloomBlur * unit, + ); + } BeamColorMatrix _matrix( BeamConfig config, @@ -87,7 +117,8 @@ class PulseOuterStrategy extends BeamVariantStrategy { BeamFramePhases phases, ) { if (phases.fadeOpacity <= 0) return; - final rect = Offset.zero & size; + final rect = beamRect(size, config); + final geometry = beamGeometry(rect, config); final params = PulseParams.resolve( BeamVariant.pulseOutside, config.brightness, @@ -95,9 +126,22 @@ class PulseOuterStrategy extends BeamVariantStrategy { ); final sx = _sx(size); final sy = _sy(size); - final unit = _unit(size); - final boost = config.glowBoost * _tunedBoost; + final glow = _geometry(config, size); + final boost = + config.glowBoost * + (config.pulseOutsideTuning == BeamPulseOutsideTuning.demo + ? pulseOuterTunedBoost + : 1); final border = config.palette.data.border; + double blobWeight(Rect blobRect, Offset fractionalPos) { + final center = Offset( + blobRect.left + fractionalPos.dx * blobRect.width, + blobRect.top + fractionalPos.dy * blobRect.height, + ); + return geometry.segmentWeightAt( + geometry.perimeter.nearestFraction(center), + ); + } // Filter ordering: CSS runs `blur()` before the color terms, and Skia // runs a paint's colorFilter BEFORE its imageFilter, so the color matrix @@ -106,8 +150,12 @@ class PulseOuterStrategy extends BeamVariantStrategy { final glowMatrix = _matrix( config, phases, - brightness: config.glowBrightness ?? 1.3 * _tunedGlowMul, - saturation: config.glowSaturation ?? 1.2 * _tunedGlowMul, + brightness: + config.glowBrightness ?? + pulseOuterGlowBrightness * pulseOuterTunedGlowMultiplier, + saturation: + config.glowSaturation ?? + pulseOuterGlowSaturation * pulseOuterTunedGlowMultiplier, ); ImageFilter glowBlur(double sigma) => ImageFilter.compose( outer: glowMatrix.toColorFilter(), @@ -125,13 +173,24 @@ class PulseOuterStrategy extends BeamVariantStrategy { fade: phases.fadeOpacity, preset: config.theme.innerOpacity, hookFactor: config.innerOpacityFactor, - extra: _tunedGlowMul, + extra: pulseOuterTunedGlowMultiplier, ); - final coreBlur = config.coreBlur ?? _tunedCoreBlur * unit; + // glowSpread multiplies how far the halo reaches and how soft it is — + // its insets and both blurs. + final spread = config.glowSpread; + final coreBlur = (config.coreBlur ?? glow.coreBlur) * spread; if (coreOpacity > 0) { - final coreRect = rect.inflate(_tunedCoreInset * unit); + final coreRect = rect.inflate(glow.coreInset * spread); + final layerBounds = coreRect.inflate(coreBlur * 3); + canvas.save(); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: 0, + outward: glow.coreInset * spread + coreBlur * 3, + ); canvas.saveLayer( - coreRect.inflate(coreBlur * 3), + layerBounds, Paint() ..color = _white.withValues(alpha: coreOpacity) ..imageFilter = glowBlur(coreBlur), @@ -141,7 +200,7 @@ class PulseOuterStrategy extends BeamVariantStrategy { paintPulseBlob( canvas, rect: coreRect, - color: border[spec.ci].color, + color: pulseBlobAt(border, spec.ci).color, fractionalPos: _specPos(spec, border), w: spec.w, h: spec.h, @@ -152,9 +211,12 @@ class PulseOuterStrategy extends BeamVariantStrategy { sy: sy, boost: boost, fold: fold, + alphaScale: blobWeight(coreRect, _specPos(spec, border)), ); } }); + BeamLayerUtils.applySegmentFeather(canvas, layerBounds, geometry); + canvas.restore(); canvas.restore(); } @@ -164,14 +226,22 @@ class PulseOuterStrategy extends BeamVariantStrategy { fade: phases.fadeOpacity, preset: config.theme.bloomOpacity, hookFactor: config.bloomOpacityFactor, - extra: _tunedGlowMul, + extra: pulseOuterTunedGlowMultiplier, ); - final bloomBlur = config.bloomBlur ?? _tunedBloomBlur * unit; + final bloomBlur = (config.bloomBlur ?? glow.bloomBlur) * spread; if (bloomOpacity > 0) { - final bloomRect = rect.inflate(_tunedBloomInset * unit); + final bloomRect = rect.inflate(glow.bloomInset * spread); final frozenAlpha = 1 - params.op * 0.5; + final layerBounds = bloomRect.inflate(bloomBlur * 3); + canvas.save(); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: 0, + outward: glow.bloomInset * spread + bloomBlur * 3, + ); canvas.saveLayer( - bloomRect.inflate(bloomBlur * 3), + layerBounds, Paint() ..color = _white.withValues(alpha: bloomOpacity) ..imageFilter = glowBlur(bloomBlur), @@ -181,7 +251,7 @@ class PulseOuterStrategy extends BeamVariantStrategy { paintFrozenPulseBlob( canvas, rect: bloomRect, - color: border[spec.ci].color, + color: pulseBlobAt(border, spec.ci).color, fractionalPos: _specPos(spec, border), w: spec.w, h: spec.h, @@ -190,9 +260,12 @@ class PulseOuterStrategy extends BeamVariantStrategy { sy: sy, boost: boost, fold: fold, + alphaScale: blobWeight(bloomRect, _specPos(spec, border)), ); } }); + BeamLayerUtils.applySegmentFeather(canvas, layerBounds, geometry); + canvas.restore(); canvas.restore(); } } @@ -205,15 +278,10 @@ class PulseOuterStrategy extends BeamVariantStrategy { BeamFramePhases phases, ) { if (phases.fadeOpacity <= 0) return; - final rect = Offset.zero & size; + final rect = beamRect(size, config); final isDark = config.brightness == Brightness.dark; - final geometry = BeamRingGeometry( - rect: rect, - radius: config.borderRadius, - // The stroke ring is always 1px in the source (padding: 1px). - borderWidth: config.borderWidth, - useSuperellipse: config.useSuperellipse, - ); + // The stroke ring is always 1px in the source (padding: 1px). + final geometry = beamGeometry(rect, config); final sx = _sx(size); final sy = _sy(size); // Filter applied at layer composite time (post-gradient), matching CSS — @@ -221,18 +289,33 @@ class PulseOuterStrategy extends BeamVariantStrategy { final matrix = _matrix(config, phases); Color fold(Color c) => c; final border = config.palette.data.border; + double blobWeight(Offset fractionalPos) { + final center = Offset( + rect.left + fractionalPos.dx * rect.width, + rect.top + fractionalPos.dy * rect.height, + ); + return geometry.segmentWeightAt( + geometry.perimeter.nearestFraction(center), + ); + } final strokeOpacity = BeamLayerUtils.layerOpacity( config, fade: phases.fadeOpacity, preset: config.theme.strokeOpacity, hookFactor: config.strokeOpacityFactor, - extra: _tunedGlowMul, + extra: pulseOuterTunedGlowMultiplier, ); if (strokeOpacity <= 0) return; canvas.save(); canvas.clipPath(geometry.ring); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: geometry.borderWidth, + outward: 0, + ); canvas.saveLayer( rect, Paint() @@ -255,7 +338,7 @@ class PulseOuterStrategy extends BeamVariantStrategy { paintPulseBlob( canvas, rect: rect, - color: border[spec.ci].color, + color: pulseBlobAt(border, spec.ci).color, fractionalPos: _specPos(spec, border), w: spec.w, h: spec.h, @@ -266,8 +349,21 @@ class PulseOuterStrategy extends BeamVariantStrategy { sy: sy, boost: config.glowBoost, fold: fold, + alphaScale: blobWeight(_specPos(spec, border)), + ); + } + // The dashed-ring mask rides inside the ring layer; the outward glows are + // left continuous, since dashing a soft halo reads as banding. + final segments = config.segments; + if (segments != null && segments >= 2) { + canvas.drawRect( + rect, + Paint() + ..blendMode = BlendMode.dstIn + ..shader = BeamGradients.segmentMask(rect, segments), ); } + BeamLayerUtils.applySegmentFeather(canvas, rect, geometry); canvas.restore(); canvas.restore(); } @@ -275,12 +371,12 @@ class PulseOuterStrategy extends BeamVariantStrategy { Offset _specPos(PulseBlobSpec spec, List border) => spec.x != null && spec.y != null ? Offset(spec.x!, spec.y!) - : border[spec.ci].position; + : pulseBlobAt(border, spec.ci).position; void _scaled(Canvas canvas, Offset center, void Function() paint) { canvas.save(); canvas.translate(center.dx, center.dy); - canvas.scale(_sw, _sh); + canvas.scale(pulseOuterScaleX, pulseOuterScaleY); canvas.translate(-center.dx, -center.dy); paint(); canvas.restore(); diff --git a/lib/src/painting/strategies/rotate_strategy.dart b/lib/src/painting/strategies/rotate_strategy.dart index df1fad5..529d96b 100644 --- a/lib/src/painting/strategies/rotate_strategy.dart +++ b/lib/src/painting/strategies/rotate_strategy.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'dart:ui'; import '../../animation/beam_phases.dart'; +import '../../constants/rotate_stops.dart'; import '../../models/beam_config.dart'; import '../color_matrix.dart'; import '../gradient_builders.dart'; @@ -12,42 +13,22 @@ import '../variant_strategy.dart'; const Color _white = Color(0xFFFFFFFF); const Color _black = Color(0xFF000000); -// Conic stop tables transcribed from the source's generated CSS -// (generateBorderVariantCSS / generateSmallVariantCSS — identical for both). - -// The rotating soft window revealing the stroke/inner layers. -const _windowStops = [0.0, 0.30, 0.36, 0.44, 0.52, 0.80, 0.86, 0.92, 0.95, 1.0]; -const _windowAlphas = [0.0, 0.0, 0.1, 0.35, 1.0, 1.0, 0.35, 0.1, 0.0, 0.0]; - -// The wider window used by the small variant's inner layer (`smallMask`). -const _smallWindowStops = [ - 0.0, 0.22, 0.28, 0.36, 0.46, 0.82, 0.88, 0.94, 0.97, 1.0, // -]; -const _smallWindowAlphas = [0.0, 0.0, 0.12, 0.4, 1.0, 1.0, 0.4, 0.12, 0.0, 0.0]; - -// The white (dark theme) / black (light theme) highlight sweep of the stroke. -const _highlightStops = [ - 0.0, 0.54, 0.57, 0.60, 0.63, 0.66, 0.69, 0.72, 0.75, 0.78, 1.0, // -]; -const _highlightAlphasDark = [ - 0.0, 0.0, 0.1, 0.3, 0.6, 0.75, 0.6, 0.3, 0.1, 0.0, 0.0, // -]; -const _highlightAlphasLight = [ - 0.0, 0.0, 0.08, 0.2, 0.4, 0.55, 0.4, 0.2, 0.08, 0.0, 0.0, // -]; - -// The sharp bloom band, blurred 8px. -const _bloomStops = [ - 0.0, 0.58, 0.62, 0.65, 0.67, 0.69, 0.70, 0.705, 0.715, 0.73, 0.75, 0.78, - 0.82, 1.0, // -]; -const _bloomAlphasDark = [ - 0.0, 0.0, 0.03, 0.08, 0.2, 0.45, 0.85, 0.85, 0.45, 0.2, 0.08, 0.03, 0.0, - 0.0, // -]; -const _bloomAlphasLight = [ - 0.0, 0.0, 0.02, 0.08, 0.2, 0.4, 0.6, 0.6, 0.4, 0.2, 0.08, 0.02, 0.0, 0.0, // -]; +// How far past the ring the comet halo reaches at glowSpread 1, and how much +// wider its band runs than the plain bloom's — the halo is the same layer, +// re-clipped and stretched, so it has to read as a trail rather than as an +// edge highlight. +const double _cometReach = 12; +const double _cometBandFactor = 1.6; + +// The stop the rotate window's bright core ends at: the beam's head, and so +// where the sparkles gather. +const double _windowHeadStop = 0.80; + +// The sparkle scatter's radius around the head, and how finely travel +// progress is quantised into its seed (higher re-rolls the twinkles more +// often). +const double _sparkleSpread = 14; +const int _sparkleSeedSteps = 16; /// The traveling-beam strategy shared by [BeamVariant.rotate] (React `md`) /// and [BeamVariant.small] (React `sm`) — a conic highlight and color blobs @@ -68,13 +49,8 @@ class RotateStrategy extends BeamVariantStrategy { BeamFramePhases phases, ) { if (phases.fadeOpacity <= 0) return; - final rect = Offset.zero & size; - final geometry = BeamRingGeometry( - rect: rect, - radius: config.borderRadius, - borderWidth: config.borderWidth, - useSuperellipse: config.useSuperellipse, - ); + final rect = beamRect(size, config); + final geometry = beamGeometry(rect, config); final isDark = config.brightness == Brightness.dark; // CPU color folding: when hue animation runs, the whole @@ -114,6 +90,12 @@ class RotateStrategy extends BeamVariantStrategy { canvas.save(); canvas.clipPath(geometry.outer); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: rect.shortestSide / 2, + outward: 0, + ); canvas.saveLayer(rect, Paint()..color = _white.withValues(alpha: opacity)); if (compact) { @@ -129,13 +111,15 @@ class RotateStrategy extends BeamVariantStrategy { } else { // md derives its inner blobs from the border table: 0.9× size and a // fixed alpha (0.45, or 0.225 for mono). - final alpha = config.palette.monoTreatment ? 0.225 : 0.45; + final alpha = config.palette.monoTreatment + ? rotateInnerBlobAlphaMono + : rotateInnerBlobAlpha; for (final blob in config.palette.data.border) { BeamGradients.paintBlob( canvas, center: _blobCenter(rect, blob.position), - radiusX: (blob.size.width * 0.9).roundToDouble(), - radiusY: (blob.size.height * 0.9).roundToDouble(), + radiusX: (blob.size.width * rotateInnerBlobScale).roundToDouble(), + radiusY: (blob.size.height * rotateInnerBlobScale).roundToDouble(), color: fold(blob.color.withValues(alpha: alpha)), ); } @@ -144,7 +128,7 @@ class RotateStrategy extends BeamVariantStrategy { canvas, contour: geometry.outer, color: fold(config.theme.innerShadow), - blur: compact ? 5 : 9, + blur: compact ? smallInnerShadowBlur : rotateInnerShadowBlur, ); // Mask pass. @@ -156,9 +140,10 @@ class RotateStrategy extends BeamVariantStrategy { ..blendMode = BlendMode.dstIn ..shader = _window( rect, - phases.angleRadians, - stops: _smallWindowStops, - alphas: _smallWindowAlphas, + config, + phases, + stops: smallWindowStops, + alphas: smallWindowAlphas, ), ); } else { @@ -180,14 +165,17 @@ class RotateStrategy extends BeamVariantStrategy { ..blendMode = BlendMode.dstIn ..shader = _window( rect, - phases.angleRadians, - stops: _windowStops, - alphas: _windowAlphas, + config, + phases, + stops: rotateWindowStops, + alphas: rotateWindowAlphas, ), ); canvas.restore(); } + BeamLayerUtils.applySegmentFeather(canvas, rect, geometry); + canvas.restore(); canvas.restore(); } @@ -208,13 +196,26 @@ class RotateStrategy extends BeamVariantStrategy { hookFactor: config.strokeOpacityFactor, ); if (opacity <= 0) return; + final highlightBase = isDark ? _white : _black; canvas.save(); canvas.clipPath(geometry.ring); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: geometry.borderWidth, + outward: 0, + ); canvas.saveLayer(rect, Paint()..color = _white.withValues(alpha: opacity)); // Highlight sweep (white on dark, black on light). - final highlightBase = isDark ? _white : _black; + final highlight = BeamConicWindow.resolve( + rotateHighlightStops, + isDark ? rotateHighlightAlphasDark : rotateHighlightAlphasLight, + reversed: phases.reversedNow, + tailLength: config.tailLength, + beamCount: config.beamCount, + ); canvas.drawRect( rect, Paint() @@ -222,11 +223,10 @@ class RotateStrategy extends BeamVariantStrategy { rect: rect, cssFromRadians: phases.angleRadians, colors: [ - for (final a - in isDark ? _highlightAlphasDark : _highlightAlphasLight) + for (final a in highlight.alphas) fold(highlightBase.withValues(alpha: a)), ], - stops: _highlightStops, + stops: highlight.stops, ), ); final blobs = compact @@ -247,14 +247,27 @@ class RotateStrategy extends BeamVariantStrategy { ..blendMode = BlendMode.dstIn ..shader = _window( rect, - phases.angleRadians, - stops: _windowStops, - alphas: _windowAlphas, + config, + phases, + stops: rotateWindowStops, + alphas: rotateWindowAlphas, ), ); + _applySegments(canvas, rect, config); + BeamLayerUtils.applySegmentFeather(canvas, rect, geometry); canvas.restore(); canvas.restore(); + + _paintSparkles( + canvas, + rect, + geometry, + config, + phases, + opacity, + highlightBase, + ); } void _paintBloom( @@ -273,7 +286,8 @@ class RotateStrategy extends BeamVariantStrategy { ); if (opacity <= 0) return; - // Bloom filter chain: blur(8px) brightness saturate (no hue). + // Bloom filter chain: blur(8px · glowSpread) brightness saturate (no + // hue). final matrix = BeamColorMatrix.beamFilter( hueDegrees: 0, brightness: config.brightnessFactor, @@ -281,49 +295,152 @@ class RotateStrategy extends BeamVariantStrategy { ); final base = isDark ? _white : _black; + // The comet is this same layer, re-aimed: instead of the thin ring it + // fills a halo reaching past the border, and its band runs wider so the + // glow trails the head. One clip swapped and one band stretched — no + // second layer. + final reach = _cometReach * config.glowSpread; + final region = config.comet ? geometry.halo(reach) : geometry.outer; + final band = config.comet ? region : geometry.ring; + final bounds = config.comet ? rect.inflate(reach) : rect; + final table = BeamConicWindow.resolve( + rotateBloomStops, + isDark ? rotateBloomAlphasDark : rotateBloomAlphasLight, + reversed: phases.reversedNow, + tailLength: config.tailLength * (config.comet ? _cometBandFactor : 1.0), + beamCount: config.beamCount, + ); + canvas.save(); - canvas.clipPath(geometry.outer); + canvas.clipPath(region); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: 0, + outward: config.comet ? reach : 0, + ); canvas.saveLayer( - rect, + bounds, Paint() ..color = _white.withValues(alpha: opacity) ..imageFilter = ImageFilter.blur( - sigmaX: 8, - sigmaY: 8, + sigmaX: rotateBloomBlurSigma * config.glowSpread, + sigmaY: rotateBloomBlurSigma * config.glowSpread, tileMode: TileMode.decal, ), ); canvas.save(); - canvas.clipPath(geometry.ring); + canvas.clipPath(band); canvas.drawRect( - rect, + bounds, Paint() ..shader = BeamGradients.conic( rect: rect, cssFromRadians: phases.angleRadians, colors: [ - for (final a in isDark ? _bloomAlphasDark : _bloomAlphasLight) + for (final a in table.alphas) matrix.transform(base.withValues(alpha: a)), ], - stops: _bloomStops, + stops: table.stops, ), ); canvas.restore(); + _applySegments(canvas, bounds, config); + BeamLayerUtils.applySegmentFeather(canvas, bounds, geometry); canvas.restore(); canvas.restore(); } + // The dashed-ring mask: one repeating conic multiplied into a layer that + // exists anyway, so a dashed ring costs no more than a solid one. + void _applySegments(Canvas canvas, Rect rect, BeamConfig config) { + final segments = config.segments; + if (segments == null || segments < 2) return; + canvas.drawRect( + rect, + Paint() + ..blendMode = BlendMode.dstIn + ..shader = BeamGradients.segmentMask(rect, segments), + ); + } + + // Twinkles at each beam head, drawn over the stroke without a layer of + // their own. They live in the foreground pass, so nothing about them + // touches the child. + void _paintSparkles( + Canvas canvas, + Rect rect, + BeamRingGeometry geometry, + BeamConfig config, + BeamFramePhases phases, + double opacity, + Color color, + ) { + if (config.sparkle <= 0 || rect.isEmpty) return; + final turn = phases.angleRadians / (2 * math.pi); + final seed = (turn * _sparkleSeedSteps).floor(); + final head = phases.reversedNow ? 1 - _windowHeadStop : _windowHeadStop; + canvas.save(); + canvas.clipPath(_sparkleBand(rect, config)); + BeamLayerUtils.clipSegment( + canvas, + geometry, + inward: _sparkleSpread, + outward: _sparkleSpread, + ); + for (var k = 0; k < config.beamCount; k++) { + final stop = (head + k) / config.beamCount; + final edgePoint = BeamLayerUtils.edgePointAt( + rect, + phases.angleRadians + stop * 2 * math.pi, + ); + final headFraction = geometry.perimeter.nearestFraction(edgePoint); + final weight = geometry.segmentWeightAt(headFraction); + if (weight == 0) continue; + BeamLayerUtils.paintSparkles( + canvas, + center: edgePoint, + density: config.sparkle, + color: color, + opacity: opacity * weight, + spread: _sparkleSpread, + seed: seed * 7 + k, + ); + } + canvas.restore(); + } + + // Sparkles scatter around the border, so they are held to a band straddling + // it rather than allowed to drift into the middle of the child. + Path _sparkleBand(Rect rect, BeamConfig config) { + final outside = beamGeometry(rect.inflate(_sparkleSpread), config).outer; + final inside = rect.deflate(_sparkleSpread).isEmpty + ? Path() + : beamGeometry(rect.deflate(_sparkleSpread), config).outer; + return Path.combine(PathOperation.difference, outside, inside); + } + Shader _window( Rect rect, - double angle, { + BeamConfig config, + BeamFramePhases phases, { required List stops, required List alphas, - }) => BeamGradients.conic( - rect: rect, - cssFromRadians: angle, - colors: [for (final a in alphas) _white.withValues(alpha: a)], - stops: stops, - ); + }) { + final table = BeamConicWindow.resolve( + stops, + alphas, + reversed: phases.reversedNow, + tailLength: config.tailLength, + beamCount: config.beamCount, + ); + return BeamGradients.conic( + rect: rect, + cssFromRadians: phases.angleRadians, + colors: [for (final a in table.alphas) _white.withValues(alpha: a)], + stops: table.stops, + ); + } static Offset _blobCenter(Rect rect, Offset fractional) => Offset( rect.left + fractional.dx * rect.width, diff --git a/lib/src/painting/variant_strategy.dart b/lib/src/painting/variant_strategy.dart index 4edd964..82f0d8a 100644 --- a/lib/src/painting/variant_strategy.dart +++ b/lib/src/painting/variant_strategy.dart @@ -2,6 +2,50 @@ import 'dart:ui'; import '../animation/beam_phases.dart'; import '../models/beam_config.dart'; +import 'ring_geometry.dart'; + +/// The rect the beam's geometry occupies: the child's bounds, pushed outward +/// or pulled inward by `BeamShape.ringOffset`. +/// +/// A positive offset paints outside [size]; nothing in the beam's own render +/// tree clips that away (the `CustomPaint` clips neither painter), so the +/// ring can orbit at a distance as long as the surrounding layout leaves it +/// room. +Rect beamRect(Size size, BeamConfig config) => + (Offset.zero & size).inflate(config.ringOffset); + +const int _geometryCacheCapacity = 16; +final List<({Rect rect, BeamConfig config, BeamRingGeometry geometry})> +_geometryCache = []; + +/// The ring geometry [config] describes over [rect]. +/// +/// Strategies request geometry on every paint. A tiny LRU retains the recent +/// frame boundaries so path metrics and perimeter alignment are measured once +/// for equal layout and resolved-config values. +BeamRingGeometry beamGeometry(Rect rect, BeamConfig config) { + for (var i = 0; i < _geometryCache.length; i++) { + final entry = _geometryCache[i]; + if (entry.rect == rect && entry.config == config) { + _geometryCache.removeAt(i); + _geometryCache.add(entry); + return entry.geometry; + } + } + final geometry = BeamRingGeometry( + rect: rect, + radius: config.borderRadius, + borderWidth: config.borderWidth, + useSuperellipse: config.useSuperellipse, + contour: config.contour, + segment: config.segment, + ); + _geometryCache.add((rect: rect, config: config, geometry: geometry)); + if (_geometryCache.length > _geometryCacheCapacity) { + _geometryCache.removeAt(0); + } + return geometry; +} /// Paints one beam variant. /// diff --git a/lib/src/widgets/beam_decoration.dart b/lib/src/widgets/beam_decoration.dart new file mode 100644 index 0000000..ab95766 --- /dev/null +++ b/lib/src/widgets/beam_decoration.dart @@ -0,0 +1,355 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter/scheduler.dart'; + +import '../animation/beam_clock.dart'; +import '../animation/beam_phases.dart'; +import '../border_beam_theme.dart'; +import '../models/beam_colors.dart'; +import '../models/beam_config.dart'; +import '../models/beam_playback.dart'; +import '../models/beam_shape.dart'; +import '../models/beam_style.dart'; +import '../models/beam_theme.dart'; +import '../models/beam_timing.dart'; +import '../models/beam_variant.dart'; +import '../models/model_validation.dart'; +import '../painting/beam_painter.dart'; +import '../painting/variant_strategy.dart'; + +/// A beam as a [Decoration], for dropping into an existing +/// `Container`/`DecoratedBox` instead of wrapping the subtree in a +/// `BorderBeam`. +/// +/// ```dart +/// Container( +/// foregroundDecoration: BeamDecoration( +/// variant: BeamVariant.rotate, +/// brightness: Theme.of(context).brightness, +/// colors: BeamColors.ocean, +/// borderRadius: 16, +/// ), +/// decoration: BoxDecoration( +/// color: surface, +/// borderRadius: BorderRadius.circular(16), +/// ), +/// child: content, +/// ) +/// ``` +/// +/// It takes the same value objects and shorthands as `BorderBeam` — [style], +/// [shape], [timing], [playback], plus the [colors]/[active]/[borderRadius] +/// shorthands — and paints the identical frames. +/// +/// ## Which slot +/// +/// A decoration paints in exactly one slot, so the variant decides which: +/// +/// | Variant | Slot | +/// | --- | --- | +/// | [BeamVariant.rotate], [BeamVariant.small], [BeamVariant.line], [BeamVariant.pulseInside] | `foregroundDecoration:` | +/// | [BeamVariant.pulseOutside] | `decoration:` | +/// +/// `BorderBeam` paints every layer of the first four *over* its child, which +/// is what `foregroundDecoration:` does — put them in `decoration:` only when +/// the beam is meant to sit under opaque content. +/// [BeamVariant.pulseOutside] is the opposite case: its halo blooms behind +/// and outside the child, so it belongs in `decoration:`, and the child needs +/// the same clip-free room around it that the widget form requires. +/// +/// ## Limits of the decoration form +/// +/// A [BoxPainter] gets a canvas and nothing else — no `BuildContext`, no +/// [TickerProvider]. Prefer `BorderBeam` when any of these matter; it is the +/// same engine with the context the decoration cannot reach: +/// +/// - **Ambient theming is passed in, not read.** [brightness] is required and +/// [theme] takes the `BorderBeamThemeData` that `BorderBeamTheme.of` would +/// have returned; a decoration inherits from no enclosing theme by itself. +/// - **The ticker is unmanaged.** It is created directly rather than through +/// a [TickerProvider], so it is not muted by `TickerMode` — a beam inside a +/// scrollable's off-screen cache extent, or under an inactive route, keeps +/// ticking. It stops only when the decoration is replaced or its render +/// object is disposed. +/// - **Reduced motion is not observed.** `MediaQuery.disableAnimationsOf` is +/// unreachable, so [BeamPlayback.reducedMotion] has no effect here. Nor is +/// the enclosing scrollable: there is no `Scrollable.of` to ask, so +/// [BeamPlayback.pauseWhenOffscreen] is inert here too. +/// - **[active] is a starting state, not a toggle.** Changing any field +/// replaces the decoration, which discards the running painter and builds a +/// fresh one — so flipping [active] cuts rather than fading. Animated +/// activation is `BorderBeam`'s. +/// - **It does not interpolate.** `lerpFrom`/`lerpTo` return null (the +/// inherited behavior), so an `AnimatedContainer` snaps between two beam +/// decorations at the halfway point. +/// +/// The decoration never absorbs pointer events: `hitTest` returns false, so +/// hits fall through to the child exactly as they do under `BorderBeam`. +@immutable +class BeamDecoration extends Decoration { + /// Creates a beam decoration for [variant]. + /// + /// [brightness] is the ambient brightness the beam adapts to — normally + /// `Theme.of(context).brightness`; `style.theme` still overrides it. + /// [theme] stands in for the enclosing `BorderBeamTheme`: pass + /// `BorderBeamTheme.of(context)` to inherit app-wide defaults. + const BeamDecoration({ + required this.variant, + required this.brightness, + this.theme, + this.colors, + this.active, + this.borderRadius, + this.style, + this.shape, + this.timing, + this.playback, + }); + + /// Which effect this decoration paints. + final BeamVariant variant; + + /// The ambient brightness the beam adapts to, since a painter cannot read + /// one. `style.theme` (a [BeamTheme] other than [BeamTheme.auto]) wins. + final Brightness brightness; + + /// The defaults an enclosing `BorderBeamTheme` would have supplied. Null + /// inherits nothing. + final BorderBeamThemeData? theme; + + /// Shorthand for `style.colors`; a non-null value here wins over it. + final BeamColors? colors; + + /// Shorthand for `playback.active`; a non-null value here wins over it. + /// A starting state rather than a toggle — see the class docs. + final bool? active; + + /// Shorthand for a uniform `shape.radius` in logical px; a non-null value + /// here wins over it. Match the decorated box's own radius. + final double? borderRadius; + + /// Colors, theme adaptation, and every filter/opacity tuning hook. + final BeamStyle? style; + + /// Corner radii, ring width, and corner family. + final BeamShape? shape; + + /// Cycle length, rest between sweeps, playback rate, and hue periods. + final BeamTiming? timing; + + /// Play state and scheduling. [BeamPlayback.reducedMotion] is inert here. + final BeamPlayback? playback; + + // The decoration's own value objects, with the shorthands folded in and + // [theme] merged underneath — the decoration's analogue of BorderBeam's + // build-time resolution. + BeamStyle get _style { + final own = colors == null + ? style + : (style ?? const BeamStyle()).copyWith(colors: colors); + return (theme?.style ?? const BeamStyle()).merge(own); + } + + BeamShape get _shape { + final own = borderRadius == null + ? shape + : (shape ?? const BeamShape()).copyWith( + radius: BorderRadius.circular(borderRadius!), + ); + return (theme?.shape ?? const BeamShape()).merge(own); + } + + BeamTiming get _timing => (theme?.timing ?? const BeamTiming()).merge(timing); + + BeamPlayback get _playback { + final own = active == null + ? playback + : (playback ?? const BeamPlayback()).copyWith(active: active); + return (theme?.playback ?? const BeamPlayback()).merge(own); + } + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) => + _BeamBoxPainter(this, onChanged); + + /// Always false: the beam is decorative and never absorbs a pointer. + @override + bool hitTest(Size size, Offset position, {TextDirection? textDirection}) => + false; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BeamDecoration && + other.variant == variant && + other.brightness == brightness && + other.theme == theme && + other.colors == colors && + other.active == active && + other.borderRadius == borderRadius && + other.style == style && + other.shape == shape && + other.timing == timing && + other.playback == playback; + + @override + int get hashCode => Object.hash( + variant, + brightness, + theme, + colors, + active, + borderRadius, + style, + shape, + timing, + playback, + ); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('variant', variant)) + ..add(EnumProperty('brightness', brightness)) + ..add( + DiagnosticsProperty( + 'theme', + theme, + defaultValue: null, + ), + ) + ..add(DiagnosticsProperty('style', _style)) + ..add(DiagnosticsProperty('shape', _shape)) + ..add(DiagnosticsProperty('timing', _timing)) + ..add(DiagnosticsProperty('playback', _playback)); + } +} + +// Owns one BeamClock for the lifetime of the decoration it was created for. +// +// The clock's ticker is built with `Ticker.new` rather than a TickerProvider: +// a BoxPainter has no provider to ask, and none of the tree-driven muting +// that comes with one (see the class docs on BeamDecoration). +class _BeamBoxPainter extends BoxPainter { + _BeamBoxPainter(this._decoration, super.onChanged) + : _strategy = strategyFor(_decoration.variant) { + validateBeamTiming(_decoration._timing); + validateRepeat(_decoration._playback.repeat); + _clock = BeamClock( + createTicker: Ticker.new, + maxFps: _strategy.preferredFps, + fadeCurve: _decoration._playback.fadeCurve, + )..speed = _decoration._timing.speed ?? 1; + _schedule(); + // Subscribed last, on purpose: createBoxPainter runs *inside* the render + // object's paint, where onChanged (markNeedsPaint) is illegal, and + // _schedule may activate the clock synchronously. + _clock.addListener(_handleTick); + } + + final BeamDecoration _decoration; + final BeamVariantStrategy _strategy; + late final BeamClock _clock; + + Timer? _startTimer; + Timer? _durationTimer; + + BeamConfig? _config; + // _config re-authored for renderScale, or _config itself at scale 1 — + // built with the config so a frame never allocates one. + BeamConfig? _painted; + BeamPhaseResolver? _resolver; + TextDirection? _configDirection; + bool _disposed = false; + + // Mirrors BorderBeam's autoplay: `active`/`autoPlay` gate the start, + // `startAfter` delays it, `duration` ends it. There is no controller here — + // a controller attaches to a widget's clock, not a painter's. + void _schedule() { + final playback = _decoration._playback; + // A frozen beam paints one instant forever; its clock never starts. + if (playback.debugFrozenAt != null) return; + if (!(playback.autoPlay ?? true) || !(playback.active ?? true)) return; + final startAfter = playback.startAfter; + if (startAfter == null) { + _start(); + } else { + _startTimer = Timer(startAfter, _start); + } + } + + void _start() { + if (_disposed) return; + _clock.activate(); + final duration = _decoration._playback.duration; + if (duration != null) _durationTimer = Timer(duration, _clock.deactivate); + } + + void _handleTick() => onChanged?.call(); + + BeamConfig _configFor(TextDirection textDirection) { + if (_config != null && _configDirection == textDirection) return _config!; + final style = _decoration._style; + _configDirection = textDirection; + _config = BeamConfig.resolve( + variant: _decoration.variant, + palette: (style.colors ?? BeamColors.colorful).resolve(), + brightness: (style.theme ?? BeamTheme.auto).resolve( + _decoration.brightness, + ), + style: style, + shape: _decoration._shape, + timing: _decoration._timing, + textDirection: textDirection, + ); + _resolver = BeamPhaseResolver(_config!); + final scale = _config!.renderScale; + _painted = scale >= 1 ? _config : _config!.scaledBy(scale); + return _config!; + } + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final size = configuration.size; + if (size == null || size.isEmpty) return; + final frozen = _decoration._playback.debugFrozenAt; + if (frozen == null && !_clock.isVisible) return; + final config = _configFor(configuration.textDirection ?? TextDirection.ltr); + final phases = frozen != null + ? _resolver!.sample( + frozen.inMicroseconds / Duration.microsecondsPerSecond, + 1, + ) + : _resolver!.sample(_clock.elapsedSeconds, _clock.fadeOpacity); + // renderScale paints the beam into a smaller box and magnifies it back, + // so a palette authored for a card reads on a screen-sized one. + final scale = config.renderScale; + final painted = _painted!; + final paintedSize = scale >= 1 + ? size + : Size(size.width * scale, size.height * scale); + canvas.save(); + canvas.translate(offset.dx, offset.dy); + if (scale < 1) canvas.scale(1 / scale); + // Both passes land in this one slot; BeamDecoration's docs say which slot + // to hand the variant so they land on the right side of the child. + _strategy + ..paintBehind(canvas, paintedSize, painted, phases) + ..paintAbove(canvas, paintedSize, painted, phases); + canvas.restore(); + } + + @override + void dispose() { + _disposed = true; + _startTimer?.cancel(); + _durationTimer?.cancel(); + _clock + ..removeListener(_handleTick) + ..dispose(); + super.dispose(); + } +} diff --git a/lib/src/widgets/beam_focus_ring.dart b/lib/src/widgets/beam_focus_ring.dart new file mode 100644 index 0000000..39834d5 --- /dev/null +++ b/lib/src/widgets/beam_focus_ring.dart @@ -0,0 +1,192 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../border_beam.dart'; +import '../models/beam_colors.dart'; +import '../models/beam_shape.dart'; +import '../models/beam_style.dart'; +import '../models/beam_timing.dart'; +import '../models/beam_variant.dart'; + +/// Lights a beam around [child] while it holds keyboard focus. +/// +/// ```dart +/// BeamFocusRing( +/// borderRadius: 12, +/// child: TextField(decoration: decoration), +/// ) +/// ``` +/// +/// With no [focusNode] the ring inserts a non-focusable [Focus] around +/// [child] and follows *its subtree*: the beam lights while [child] or any +/// descendant of it holds focus, which is what a field, a button, or a whole +/// form wrapped in one ring should do. Pass [focusNode] to follow one +/// specific node instead — including a node that lives outside this subtree. +/// +/// Focus comes and goes with the beam's own fades (0.6s in, 0.5s out) rather +/// than a cut, because the ring only flips `BorderBeam`'s `active`. +/// +/// ## Highlight mode +/// +/// The ring follows [FocusManager.highlightMode], the same rule +/// [FocusableActionDetector] uses: it shows under +/// [FocusHighlightMode.traditional] (focus moved by keyboard or mouse) and +/// stays dark under [FocusHighlightMode.touch], where a focus ring around a +/// tapped field is noise. Set [alwaysShow] to light it in either mode. +/// +/// ## Accessibility +/// +/// This is a *visual* focus indicator and complements the semantics a focused +/// widget already reports — it adds none of its own. Assistive technology +/// announces focus through the focused widget, not through this ring, so keep +/// whatever labels and semantics [child] carries. +class BeamFocusRing extends StatefulWidget { + /// Creates a focus ring around [child]. + const BeamFocusRing({ + super.key, + required this.child, + this.focusNode, + this.variant = BeamVariant.small, + this.colors = BeamColors.ocean, + this.borderRadius, + this.style, + this.shape, + this.timing, + this.alwaysShow = false, + }); + + /// The focused content. Laid out and hit-tested normally. + final Widget child; + + /// The node whose focus the ring follows. Null follows [child]'s own + /// subtree through an inserted non-focusable [Focus]. + final FocusNode? focusNode; + + /// Which effect the ring paints. Defaults to [BeamVariant.small], the + /// compact traveling beam. + final BeamVariant variant; + + /// The ring's palette. Defaults to [BeamColors.ocean]. + final BeamColors colors; + + /// Shorthand for a uniform corner radius in logical px. Match the focused + /// widget's own radius. + final double? borderRadius; + + /// Colors, theme adaptation, and filter hooks, passed straight through. + final BeamStyle? style; + + /// Corner radii, ring width, and corner family, passed straight through. + final BeamShape? shape; + + /// Cycle length, rest, rate, and hue periods, passed straight through. + final BeamTiming? timing; + + /// Whether to light the ring even under [FocusHighlightMode.touch]. + final bool alwaysShow; + + @override + State createState() => _BeamFocusRingState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('variant', variant)) + ..add(DiagnosticsProperty('colors', colors)) + ..add(DoubleProperty('borderRadius', borderRadius, defaultValue: null)) + ..add( + DiagnosticsProperty( + 'focusNode', + focusNode, + defaultValue: null, + ), + ) + ..add(FlagProperty('alwaysShow', value: alwaysShow, ifTrue: 'alwaysShow')) + ..add(DiagnosticsProperty('style', style, defaultValue: null)) + ..add(DiagnosticsProperty('shape', shape, defaultValue: null)) + ..add( + DiagnosticsProperty('timing', timing, defaultValue: null), + ); + } +} + +class _BeamFocusRingState extends State { + // Created only when no node was supplied. Non-focusable and skipped by + // traversal, so it changes nothing about where focus can land — it exists + // to report whether focus is somewhere inside [child]. + FocusNode? _internalNode; + bool _focused = false; + late FocusHighlightMode _highlightMode; + + FocusNode get _node => widget.focusNode ?? (_internalNode ??= FocusNode()); + + @override + void initState() { + super.initState(); + _highlightMode = FocusManager.instance.highlightMode; + FocusManager.instance.addHighlightModeListener(_handleHighlightModeChange); + _node.addListener(_handleFocusChange); + _focused = _node.hasFocus; + } + + @override + void didUpdateWidget(BeamFocusRing oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode == widget.focusNode) return; + (oldWidget.focusNode ?? _internalNode)?.removeListener(_handleFocusChange); + if (widget.focusNode != null) { + _internalNode?.dispose(); + _internalNode = null; + } + _node.addListener(_handleFocusChange); + _focused = _node.hasFocus; + } + + @override + void dispose() { + FocusManager.instance.removeHighlightModeListener( + _handleHighlightModeChange, + ); + _node.removeListener(_handleFocusChange); + _internalNode?.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + final focused = _node.hasFocus; + if (focused == _focused) return; + setState(() => _focused = focused); + } + + void _handleHighlightModeChange(FocusHighlightMode mode) { + if (!mounted || mode == _highlightMode) return; + setState(() => _highlightMode = mode); + } + + // Mirrors FocusableActionDetector: a focus highlight belongs to keyboard + // and mouse interaction, not to a tap that put focus in a field. + bool get _showHighlight => + widget.alwaysShow || _highlightMode == FocusHighlightMode.traditional; + + @override + Widget build(BuildContext context) { + final beam = BorderBeam( + variant: widget.variant, + colors: widget.colors, + active: _focused && _showHighlight, + borderRadius: widget.borderRadius, + style: widget.style, + shape: widget.shape, + timing: widget.timing, + child: widget.child, + ); + if (widget.focusNode != null) return beam; + return Focus( + focusNode: _node, + canRequestFocus: false, + skipTraversal: true, + child: beam, + ); + } +} diff --git a/lib/src/widgets/beam_hover.dart b/lib/src/widgets/beam_hover.dart new file mode 100644 index 0000000..c78cce2 --- /dev/null +++ b/lib/src/widgets/beam_hover.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import '../border_beam.dart'; +import '../models/beam_colors.dart'; +import '../models/beam_shape.dart'; +import '../models/beam_style.dart'; +import '../models/beam_timing.dart'; +import '../models/beam_variant.dart'; + +/// Lights a beam around [child] while the cursor is over it, and pulls the +/// sweep toward the cursor — the "spotlight border" effect. +/// +/// ```dart +/// BeamHover( +/// borderRadius: 20, +/// child: pricingCard, +/// ) +/// ``` +/// +/// Hover is a pointer-device idea, so this is a desktop and web wrapper: on a +/// touch device no mouse ever enters and the beam simply stays dark. `BeamPress` +/// is the touch counterpart. +/// +/// The cursor position is fed to `BorderBeam.follow` as normalized box +/// coordinates, which eases the beam to the perimeter point nearest the +/// cursor. Leaving releases the follow immediately — the sweep resumes its +/// own schedule from where it is, without a snap — and the beam fades out +/// [holdAfterExit] later, so crossing a gap between two hoverable cards does +/// not strobe. Set [followPointer] to false to light the beam on hover +/// without steering it. +class BeamHover extends StatefulWidget { + /// Creates a hover-driven beam around [child]. + const BeamHover({ + super.key, + required this.child, + this.variant = BeamVariant.rotate, + this.colors, + this.borderRadius, + this.style, + this.shape, + this.timing, + this.followPointer = true, + this.holdAfterExit = const Duration(milliseconds: 300), + }); + + /// The hovered content. Laid out and hit-tested normally. + final Widget child; + + /// Which effect the hover paints. Defaults to [BeamVariant.rotate]; only + /// the traveling variants can follow a pointer. + final BeamVariant variant; + + /// Shorthand for `style.colors`; a non-null value here wins over it. + final BeamColors? colors; + + /// Shorthand for a uniform corner radius in logical px. Match [child]'s own + /// radius. + final double? borderRadius; + + /// Colors, theme adaptation, and filter hooks, passed straight through. + final BeamStyle? style; + + /// Corner radii, ring width, and corner family, passed straight through. + final BeamShape? shape; + + /// Cycle length, rest, rate, and hue periods, passed straight through. + final BeamTiming? timing; + + /// Whether the sweep gravitates to the cursor. False lights the beam on + /// hover and leaves it on its own schedule. + final bool followPointer; + + /// How long the beam stays lit after the cursor leaves. + final Duration holdAfterExit; + + @override + State createState() => _BeamHoverState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('variant', variant)) + ..add( + DiagnosticsProperty('colors', colors, defaultValue: null), + ) + ..add(DoubleProperty('borderRadius', borderRadius, defaultValue: null)) + ..add( + FlagProperty( + 'followPointer', + value: followPointer, + ifFalse: 'does not follow the pointer', + ), + ) + ..add(DiagnosticsProperty('holdAfterExit', holdAfterExit)) + ..add(DiagnosticsProperty('style', style, defaultValue: null)) + ..add(DiagnosticsProperty('shape', shape, defaultValue: null)) + ..add( + DiagnosticsProperty('timing', timing, defaultValue: null), + ); + } +} + +class _BeamHoverState extends State { + bool _active = false; + Offset? _follow; + Timer? _exitTimer; + + @override + void didUpdateWidget(BeamHover oldWidget) { + super.didUpdateWidget(oldWidget); + if (!widget.followPointer && _follow != null) _follow = null; + } + + @override + void dispose() { + _exitTimer?.cancel(); + super.dispose(); + } + + void _handleEnter(PointerEnterEvent event) { + _exitTimer?.cancel(); + _exitTimer = null; + _track(event.localPosition, active: true); + } + + void _handleHover(PointerHoverEvent event) => + _track(event.localPosition, active: true); + + // The beam releases the pointer the moment it leaves, then holds its light + // for [holdAfterExit]: a cursor crossing the gap between two cards should + // not make either one strobe. + void _handleExit(PointerExitEvent event) { + _exitTimer?.cancel(); + _exitTimer = null; + if (widget.holdAfterExit <= Duration.zero) { + setState(() { + _follow = null; + _active = false; + }); + return; + } + _exitTimer = Timer(widget.holdAfterExit, _fadeOut); + if (_follow != null) setState(() => _follow = null); + } + + void _fadeOut() { + _exitTimer = null; + if (!mounted) return; + setState(() => _active = false); + } + + void _track(Offset localPosition, {required bool active}) { + final follow = widget.followPointer ? _normalize(localPosition) : null; + if (active == _active && follow == _follow) return; + setState(() { + _active = active; + _follow = follow; + }); + } + + // Normalized box coordinates, which is what BorderBeam.follow takes. The + // position is already local to the MouseRegion's box. + Offset? _normalize(Offset localPosition) { + final box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize || box.size.isEmpty) return null; + return Offset( + (localPosition.dx / box.size.width).clamp(0.0, 1.0), + (localPosition.dy / box.size.height).clamp(0.0, 1.0), + ); + } + + @override + Widget build(BuildContext context) => MouseRegion( + onEnter: _handleEnter, + onHover: _handleHover, + onExit: _handleExit, + child: BorderBeam( + variant: widget.variant, + colors: widget.colors, + active: _active, + follow: _follow, + borderRadius: widget.borderRadius, + style: widget.style, + shape: widget.shape, + timing: widget.timing, + child: widget.child, + ), + ); +} diff --git a/lib/src/widgets/beam_press.dart b/lib/src/widgets/beam_press.dart new file mode 100644 index 0000000..9ac8348 --- /dev/null +++ b/lib/src/widgets/beam_press.dart @@ -0,0 +1,193 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../border_beam.dart'; +import '../models/beam_colors.dart'; +import '../models/beam_shape.dart'; +import '../models/beam_style.dart'; +import '../models/beam_timing.dart'; +import '../models/beam_variant.dart'; + +/// Lights a beam around [child] while a finger is down on it. +/// +/// The touch counterpart of `BeamHover`: pointer-down activates the beam, +/// pointer-up releases it. A tap is far shorter than a pulse, so a release is +/// held until [minimumDuration] has passed since the press began — otherwise +/// every tap would be a flicker. +/// +/// ```dart +/// BeamPress( +/// borderRadius: 20, +/// onTap: () => submit(), +/// child: card, +/// ) +/// ``` +/// +/// ## Gestures +/// +/// The listener is a plain [Listener] with [HitTestBehavior.translucent]: it +/// observes raw pointer events without entering the gesture arena, so it can +/// never win a gesture away from [child]. A button or a `GestureDetector` +/// inside keeps receiving its own taps, and a scrollable above keeps its +/// drags. +/// +/// [onTap] is a convenience for the common "the whole thing is tappable" +/// case, fired when the pointer lifts inside the widget's bounds. Because it +/// bypasses the arena it does not wait to see whether another recognizer +/// claims the gesture; a child that has its own tap handling should keep it +/// and leave [onTap] null. +class BeamPress extends StatefulWidget { + /// Creates a press-driven beam around [child]. + const BeamPress({ + super.key, + required this.child, + this.variant = BeamVariant.pulseInside, + this.colors, + this.borderRadius, + this.style, + this.shape, + this.timing, + this.minimumDuration = const Duration(milliseconds: 600), + this.onTap, + }); + + /// The pressed content. Laid out and hit-tested normally. + final Widget child; + + /// Which effect the press paints. Defaults to [BeamVariant.pulseInside], + /// whose breathing glow reads as "held". + final BeamVariant variant; + + /// Shorthand for `style.colors`; a non-null value here wins over it. + final BeamColors? colors; + + /// Shorthand for a uniform corner radius in logical px. Match [child]'s own + /// radius. + final double? borderRadius; + + /// Colors, theme adaptation, and filter hooks, passed straight through. + final BeamStyle? style; + + /// Corner radii, ring width, and corner family, passed straight through. + final BeamShape? shape; + + /// Cycle length, rest, rate, and hue periods, passed straight through. + final BeamTiming? timing; + + /// How long the beam stays lit from pointer-down, however early the pointer + /// lifts. A quick tap still gets a full pulse. [Duration.zero] releases on + /// pointer-up. + final Duration minimumDuration; + + /// Called when the pointer lifts inside the widget's bounds. + final VoidCallback? onTap; + + @override + State createState() => _BeamPressState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('variant', variant)) + ..add( + DiagnosticsProperty('colors', colors, defaultValue: null), + ) + ..add(DoubleProperty('borderRadius', borderRadius, defaultValue: null)) + ..add(DiagnosticsProperty('minimumDuration', minimumDuration)) + ..add(FlagProperty('onTap', value: onTap != null, ifTrue: 'tappable')) + ..add(DiagnosticsProperty('style', style, defaultValue: null)) + ..add(DiagnosticsProperty('shape', shape, defaultValue: null)) + ..add( + DiagnosticsProperty('timing', timing, defaultValue: null), + ); + } +} + +class _BeamPressState extends State { + bool _active = false; + bool _down = false; + bool _minimumMet = true; + int? _pointer; + Timer? _minimumTimer; + + @override + void dispose() { + _minimumTimer?.cancel(); + super.dispose(); + } + + void _handleDown(PointerDownEvent event) { + // A second finger while one is already down changes nothing: the beam is + // already lit and its minimum runs from the first press. + if (_pointer != null) return; + _pointer = event.pointer; + _down = true; + _minimumTimer?.cancel(); + _minimumMet = widget.minimumDuration <= Duration.zero; + if (!_minimumMet) { + _minimumTimer = Timer(widget.minimumDuration, _handleMinimumElapsed); + } + if (!_active) setState(() => _active = true); + } + + void _handleMinimumElapsed() { + _minimumTimer = null; + _minimumMet = true; + if (!_down) _release(); + } + + void _handleUp(PointerUpEvent event) { + if (event.pointer != _pointer) return; + _pointer = null; + _down = false; + if (_minimumMet) _release(); + if (widget.onTap != null && _containsLocal(event.localPosition)) { + widget.onTap!(); + } + } + + // A cancel means another recognizer took the gesture over (a scroll, most + // often). Nothing was pressed, so the beam drops immediately rather than + // holding out the minimum a real press earns. + void _handleCancel(PointerCancelEvent event) { + if (event.pointer != _pointer) return; + _pointer = null; + _down = false; + _minimumTimer?.cancel(); + _minimumTimer = null; + _minimumMet = true; + _release(); + } + + bool _containsLocal(Offset position) { + final box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return false; + return (Offset.zero & box.size).contains(position); + } + + void _release() { + if (!mounted || !_active) return; + setState(() => _active = false); + } + + @override + Widget build(BuildContext context) => Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _handleDown, + onPointerUp: _handleUp, + onPointerCancel: _handleCancel, + child: BorderBeam( + variant: widget.variant, + colors: widget.colors, + active: _active, + borderRadius: widget.borderRadius, + style: widget.style, + shape: widget.shape, + timing: widget.timing, + child: widget.child, + ), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index dd3c902..e6f394e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,17 +1,28 @@ -name: border_beam +name: flutter_border_beam description: >- - Animated border beam effects for Flutter. Traveling and breathing glow - animations around any widget — cards, buttons, inputs — with rotate, line, - and pulse variants, custom palettes, superellipse borders, and an optional - playback controller. A faithful port of the border-beam React library. + A traveling or breathing glow around any widget: five beam variants, eleven + palettes, squircle and custom contours, theming, playback control, and + hover/press/focus wrappers. version: 0.1.0 -repository: https://github.com/rehmatsg/border-beam +repository: https://github.com/SimplifyJobs/flutter_border_beam +issue_tracker: https://github.com/SimplifyJobs/flutter_border_beam/issues +homepage: https://github.com/SimplifyJobs/flutter_border_beam +documentation: https://github.com/SimplifyJobs/flutter_border_beam/tree/main/doc topics: - animation - border - glow - effect - ui +screenshots: + - path: screenshots/rotate.png + description: Rotate variant — a beam travels around the full border of a card. + - path: screenshots/pulse_outside.png + description: Pulse-outside variant — a breathing halo blooms behind the child. + - path: screenshots/line.png + description: Line variant — the beam rides the bottom edge of an input. + - path: screenshots/squircle.png + description: Superellipse borders in the ocean palette — Apple-style squircle contour. environment: sdk: ^3.9.0 @@ -20,7 +31,6 @@ environment: dependencies: flutter: sdk: flutter - sprung: ^3.0.1 dev_dependencies: flutter_test: diff --git a/assets/showcase/line.png b/screenshots/line.png similarity index 100% rename from assets/showcase/line.png rename to screenshots/line.png diff --git a/assets/showcase/pulse_outside.png b/screenshots/pulse_outside.png similarity index 100% rename from assets/showcase/pulse_outside.png rename to screenshots/pulse_outside.png diff --git a/assets/showcase/rotate.png b/screenshots/rotate.png similarity index 100% rename from assets/showcase/rotate.png rename to screenshots/rotate.png diff --git a/assets/showcase/squircle.png b/screenshots/squircle.png similarity index 100% rename from assets/showcase/squircle.png rename to screenshots/squircle.png diff --git a/test/animation/beam_clock_test.dart b/test/animation/beam_clock_test.dart index 643e8ae..5f740be 100644 --- a/test/animation/beam_clock_test.dart +++ b/test/animation/beam_clock_test.dart @@ -1,6 +1,6 @@ -import 'package:border_beam/src/animation/beam_clock.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter_border_beam/src/animation/beam_clock.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/test/animation/beam_periods_test.dart b/test/animation/beam_periods_test.dart new file mode 100644 index 0000000..c9ace27 --- /dev/null +++ b/test/animation/beam_periods_test.dart @@ -0,0 +1,267 @@ +import 'dart:ui'; + +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/models/beam_colors.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/models/beam_style.dart'; +import 'package:flutter_border_beam/src/models/beam_timing.dart'; +import 'package:flutter_border_beam/src/models/beam_variant.dart'; +import 'package:flutter_test/flutter_test.dart'; + +BeamConfig _config( + BeamVariant variant, { + BeamTiming timing = const BeamTiming(), + double hueRange = 30, +}) => BeamConfig.resolve( + variant: variant, + palette: BeamColors.colorful.resolve(), + brightness: Brightness.dark, + style: BeamStyle(hueRange: hueRange), + timing: timing, +); + +BeamPhaseResolver _resolver( + BeamVariant variant, { + BeamTiming timing = const BeamTiming(), + double hueRange = 30, +}) => BeamPhaseResolver(_config(variant, timing: timing, hueRange: hueRange)); + +/// `BeamTiming` exposes the tracks that do not ride the cycle: the hue +/// ping-pong (or revolution), the line bloom's own hue, and the line's +/// breathe/spike periods expressed as multiples of the cycle. Each was a +/// fixed constant in the phase resolver before; each must now follow what +/// the timing asks for, and only that track. +void main() { + group('huePeriod', () { + test('the traveling default is a 12s ping-pong', () { + final config = _config(BeamVariant.rotate); + expect(config.huePeriodSeconds, 12); + final r = BeamPhaseResolver(config); + expect(r.sample(0, 1).hueDegrees, closeTo(-30, 1e-9)); + expect(r.sample(6, 1).hueDegrees, closeTo(30, 1e-6)); + }); + + test('a custom period puts +hueRange at half of it', () { + const timing = BeamTiming(huePeriod: Duration(seconds: 4)); + final config = _config(BeamVariant.rotate, timing: timing); + expect(config.huePeriodSeconds, closeTo(4, 1e-9)); + final r = BeamPhaseResolver(config); + expect(r.sample(0, 1).hueDegrees, closeTo(-30, 1e-9)); + expect(r.sample(2, 1).hueDegrees, closeTo(30, 1e-6)); + expect(r.sample(4, 1).hueDegrees, closeTo(-30, 1e-6)); + expect( + r.sample(1, 1).hueDegrees, + closeTo(0, 1e-6), + reason: 'the ease-in-out curve crosses zero at a quarter period', + ); + }); + + test('it does not move the cycle-driven tracks', () { + final fast = _resolver( + BeamVariant.rotate, + timing: const BeamTiming(huePeriod: Duration(seconds: 3)), + ); + final slow = _resolver( + BeamVariant.rotate, + timing: const BeamTiming(huePeriod: Duration(seconds: 30)), + ); + expect( + fast.sample(1.2, 1).angleRadians, + closeTo(slow.sample(1.2, 1).angleRadians, 1e-9), + ); + expect( + fast.sample(1.2, 1).hueDegrees, + isNot(closeTo(slow.sample(1.2, 1).hueDegrees, 1e-3)), + ); + }); + + test('the pulse defaults are the preset periods', () { + expect(_config(BeamVariant.pulseInside).huePeriodSeconds, 16); + expect(_config(BeamVariant.pulseOutside).huePeriodSeconds, 14); + // A continuous revolution, not a ping-pong. + expect( + _resolver(BeamVariant.pulseInside).sample(8, 1).hueDegrees, + closeTo(180, 1e-6), + ); + expect( + _resolver(BeamVariant.pulseOutside).sample(7, 1).hueDegrees, + closeTo(180, 1e-6), + ); + }); + + test('a custom period replaces the pulse preset period', () { + const timing = BeamTiming(huePeriod: Duration(seconds: 5)); + for (final variant in [ + BeamVariant.pulseInside, + BeamVariant.pulseOutside, + ]) { + final config = _config(variant, timing: timing); + expect(config.huePeriodSeconds, closeTo(5, 1e-9), reason: '$variant'); + final r = BeamPhaseResolver(config); + expect(r.sample(1.25, 1).hueDegrees, closeTo(90, 1e-6)); + expect(r.sample(2.5, 1).hueDegrees, closeTo(180, 1e-6)); + expect( + r.sample(5, 1).hueDegrees, + closeTo(0, 1e-6), + reason: 'it wraps at the custom period, not at 14s or 16s', + ); + } + }); + + test('a sub-second period is still a full ping-pong', () { + final r = _resolver( + BeamVariant.small, + timing: const BeamTiming(huePeriod: Duration(milliseconds: 200)), + ); + expect(r.sample(0.1, 1).hueDegrees, closeTo(30, 1e-6)); + expect(r.sample(0.2, 1).hueDegrees, closeTo(-30, 1e-6)); + }); + }); + + group('bloomHuePeriod', () { + // The line variant caps hueRange at 13°, so its bloom track spans ±23°. + const bloomRange = 23.0; + + test('the default is an 8s ping-pong over ±(hueRange + 10)', () { + final config = _config(BeamVariant.line); + expect(config.bloomHuePeriodSeconds, 8); + final r = BeamPhaseResolver(config); + expect(r.sample(0, 1).bloomHueDegrees, closeTo(-bloomRange, 1e-9)); + expect(r.sample(4, 1).bloomHueDegrees, closeTo(bloomRange, 1e-6)); + }); + + test('a custom period retimes only the bloom hue', () { + const timing = BeamTiming(bloomHuePeriod: Duration(seconds: 4)); + final config = _config(BeamVariant.line, timing: timing); + expect(config.bloomHuePeriodSeconds, closeTo(4, 1e-9)); + expect(config.huePeriodSeconds, 12, reason: 'the main hue is untouched'); + + final r = BeamPhaseResolver(config); + final reference = _resolver(BeamVariant.line); + expect(r.sample(2, 1).bloomHueDegrees, closeTo(bloomRange, 1e-6)); + expect( + r.sample(1.3, 1).hueDegrees, + closeTo(reference.sample(1.3, 1).hueDegrees, 1e-9), + ); + expect( + r.sample(1.3, 1).lineX, + closeTo(reference.sample(1.3, 1).lineX, 1e-9), + ); + }); + + test('a wider hueRange is capped before the bloom range is derived', () { + final r = _resolver(BeamVariant.line, hueRange: 90); + expect(r.config.hueRange, 13); + expect(r.sample(4, 1).bloomHueDegrees, closeTo(bloomRange, 1e-6)); + }); + }); + + group('line breathe and spike factors', () { + const cycle = Duration(seconds: 2); + const cycleSeconds = 2.0; + + test('the defaults are 1.3, 1.33 and 1.7 cycles', () { + final config = _config( + BeamVariant.line, + timing: const BeamTiming(cycle: cycle), + ); + expect(config.breatheFactor, 1.3); + expect(config.spikeFactor, 1.33); + expect(config.spike2Factor, 1.7); + }); + + test('lineH completes one breathe cycle at cycle × breatheFactor', () { + const factor = 2.0; + final r = _resolver( + BeamVariant.line, + timing: const BeamTiming(cycle: cycle, breatheFactor: factor), + ); + const period = cycleSeconds * factor; + for (final t in [0.0, 0.7, 1.9, 3.4]) { + expect( + r.sample(t + period, 1).lineH, + closeTo(r.sample(t, 1).lineH, 1e-9), + reason: 't=$t repeats after $period s', + ); + } + expect( + r.sample(period / 2, 1).lineH, + isNot(closeTo(r.sample(0, 1).lineH, 1e-3)), + reason: 'the track actually moves inside the period', + ); + }); + + test('halving the factor halves the time to the same breathe phase', () { + final slow = _resolver( + BeamVariant.line, + timing: const BeamTiming(cycle: cycle, breatheFactor: 2), + ); + final fast = _resolver( + BeamVariant.line, + timing: const BeamTiming(cycle: cycle, breatheFactor: 1), + ); + expect(slow.sample(2, 1).lineH, closeTo(fast.sample(1, 1).lineH, 1e-9)); + }); + + test('spikeFactor and spike2Factor drive their own tracks only', () { + final base = _resolver( + BeamVariant.line, + timing: const BeamTiming(cycle: cycle), + ); + final spiked = _resolver( + BeamVariant.line, + timing: const BeamTiming(cycle: cycle, spikeFactor: 3), + ); + final spiked2 = _resolver( + BeamVariant.line, + timing: const BeamTiming(cycle: cycle, spike2Factor: 3), + ); + + const t = 1.1; + expect( + spiked.sample(t, 1).spike, + isNot(closeTo(base.sample(t, 1).spike, 1e-3)), + ); + expect( + spiked.sample(t, 1).spike2, + closeTo(base.sample(t, 1).spike2, 1e-9), + ); + expect(spiked.sample(t, 1).lineH, closeTo(base.sample(t, 1).lineH, 1e-9)); + + expect( + spiked2.sample(t, 1).spike2, + isNot(closeTo(base.sample(t, 1).spike2, 1e-3)), + ); + expect( + spiked2.sample(t, 1).spike, + closeTo(base.sample(t, 1).spike, 1e-9), + ); + + const period = cycleSeconds * 3; + expect( + spiked.sample(0.4 + period, 1).spike, + closeTo(spiked.sample(0.4, 1).spike, 1e-9), + ); + expect( + spiked2.sample(0.4 + period, 1).spike2, + closeTo(spiked2.sample(0.4, 1).spike2, 1e-9), + ); + }); + + test('the factors scale with the cycle they multiply', () { + final short = _resolver( + BeamVariant.line, + timing: const BeamTiming(cycle: Duration(seconds: 1), breatheFactor: 2), + ); + final long = _resolver( + BeamVariant.line, + timing: const BeamTiming(cycle: Duration(seconds: 2), breatheFactor: 2), + ); + expect( + long.sample(2.4, 1).lineH, + closeTo(short.sample(1.2, 1).lineH, 1e-9), + reason: 'twice the cycle is twice the breathe period', + ); + }); + }); +} diff --git a/test/animation/beam_phases_test.dart b/test/animation/beam_phases_test.dart index b8c928a..4f2ec2c 100644 --- a/test/animation/beam_phases_test.dart +++ b/test/animation/beam_phases_test.dart @@ -1,25 +1,44 @@ import 'dart:math' as math; import 'dart:ui'; -import 'package:border_beam/src/animation/beam_phases.dart'; -import 'package:border_beam/src/constants/line_keyframes.dart'; -import 'package:border_beam/src/models/beam_colors.dart'; -import 'package:border_beam/src/models/beam_config.dart'; -import 'package:border_beam/src/models/beam_variant.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/constants/line_keyframes.dart'; +import 'package:flutter_border_beam/src/models/beam_colors.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/models/beam_style.dart'; +import 'package:flutter_border_beam/src/models/beam_timing.dart'; +import 'package:flutter_border_beam/src/models/beam_variant.dart'; import 'package:flutter_test/flutter_test.dart'; BeamConfig _config( BeamVariant v, { bool staticColors = false, double hueRange = 30, + Duration? cycleDuration, }) => BeamConfig.resolve( variant: v, palette: BeamColors.colorful.resolve(), brightness: Brightness.dark, - staticColors: staticColors, - hueRange: hueRange, + style: BeamStyle(staticColors: staticColors, hueRange: hueRange), + timing: BeamTiming(cycle: cycleDuration), ); +// The retime a widget performs when its cycle duration changes mid-run: +// elapsed time is rescaled by the cycle ratio and the hue clock is shifted +// back by the same amount. +({double t, BeamPhaseResolver resolver}) _retimed( + BeamPhaseResolver from, + double t, + Duration newCycle, +) { + final next = BeamPhaseResolver( + _config(from.config.variant, hueRange: 30, cycleDuration: newCycle), + ); + final scaled = t * next.config.cycleSeconds / from.config.cycleSeconds; + next.hueTimeOffset = from.hueTimeOffset + t - scaled; + return (t: scaled, resolver: next); +} + void main() { group('sampleKeyframes', () { test('linear tables hit every stop exactly', () { @@ -96,6 +115,100 @@ void main() { expect(BeamPhaseResolver(config).sample(3, 1).hueDegrees, 0); }); + test('hueTimeOffset shifts only the fixed-period hue tracks', () { + final r = BeamPhaseResolver(_config(BeamVariant.rotate)); + final unshifted = BeamPhaseResolver(_config(BeamVariant.rotate)); + r.hueTimeOffset = 2; + expect( + r.sample(3, 1).hueDegrees, + closeTo(unshifted.sample(5, 1).hueDegrees, 1e-9), + ); + expect( + r.sample(3, 1).angleRadians, + closeTo(unshifted.sample(3, 1).angleRadians, 1e-9), + reason: 'the cycle-derived tracks ignore the hue offset', + ); + + final line = BeamPhaseResolver(_config(BeamVariant.line)) + ..hueTimeOffset = -1.5; + final lineRef = BeamPhaseResolver(_config(BeamVariant.line)); + expect( + line.sample(4, 1).bloomHueDegrees, + closeTo(lineRef.sample(2.5, 1).bloomHueDegrees, 1e-9), + ); + expect( + line.sample(4, 1).lineX, + closeTo(lineRef.sample(4, 1).lineX, 1e-9), + ); + + final pulse = BeamPhaseResolver(_config(BeamVariant.pulseInside)) + ..hueTimeOffset = 4; + // Sawtooth over 16s: a 4s shift is a quarter revolution. + expect(pulse.sample(0, 1).hueDegrees, closeTo(90, 1e-6)); + }); + + test('a static palette ignores the hue offset', () { + final r = BeamPhaseResolver( + _config(BeamVariant.rotate, staticColors: true), + )..hueTimeOffset = 3.7; + expect(r.sample(2, 1).hueDegrees, 0); + }); + + test('retiming a cycle change holds every rotate track', () { + final before = BeamPhaseResolver( + _config(BeamVariant.rotate, cycleDuration: const Duration(seconds: 2)), + ); + const t = 0.9; + final ref = before.sample(t, 1); + final next = _retimed(before, t, const Duration(seconds: 4)); + final after = next.resolver.sample(next.t, 1); + expect(after.angleRadians, closeTo(ref.angleRadians, 1e-9)); + expect(after.hueDegrees, closeTo(ref.hueDegrees, 1e-9)); + }); + + test('retiming a cycle change holds every line track', () { + final before = BeamPhaseResolver( + _config(BeamVariant.line, cycleDuration: const Duration(seconds: 3)), + ); + const t = 2.4; + final ref = before.sample(t, 1); + final next = _retimed(before, t, const Duration(milliseconds: 1500)); + final after = next.resolver.sample(next.t, 1); + expect(after.lineX, closeTo(ref.lineX, 1e-9)); + expect(after.lineW, closeTo(ref.lineW, 1e-9)); + expect(after.lineH, closeTo(ref.lineH, 1e-9)); + expect(after.spike, closeTo(ref.spike, 1e-9)); + expect(after.spike2, closeTo(ref.spike2, 1e-9)); + expect(after.edge, closeTo(ref.edge, 1e-9)); + expect(after.hueDegrees, closeTo(ref.hueDegrees, 1e-9)); + expect(after.bloomHueDegrees, closeTo(ref.bloomHueDegrees, 1e-9)); + }); + + test('retiming a cycle change holds every pulse oscillator', () { + final before = BeamPhaseResolver( + _config( + BeamVariant.pulseInside, + cycleDuration: const Duration(milliseconds: 2300), + ), + ); + const t = 1.7; + final ref = before.sample(t, 1); + final next = _retimed(before, t, const Duration(seconds: 5)); + final after = next.resolver.sample(next.t, 1); + for (var i = 0; i < 3; i++) { + expect(after.pulse.bw[i], closeTo(ref.pulse.bw[i], 1e-9)); + expect(after.pulse.bh[i], closeTo(ref.pulse.bh[i], 1e-9)); + expect(after.pulse.bx[i], closeTo(ref.pulse.bx[i], 1e-9)); + expect(after.pulse.by[i], closeTo(ref.pulse.by[i], 1e-9)); + } + expect(after.pulse.bgh, closeTo(ref.pulse.bgh, 1e-9)); + expect(after.pulse.bopTl, closeTo(ref.pulse.bopTl, 1e-9)); + expect(after.pulse.bopTr, closeTo(ref.pulse.bopTr, 1e-9)); + expect(after.pulse.bopBl, closeTo(ref.pulse.bopBl, 1e-9)); + expect(after.pulse.bopBr, closeTo(ref.pulse.bopBr, 1e-9)); + expect(after.hueDegrees, closeTo(ref.hueDegrees, 1e-9)); + }); + test('static frame is full-opacity and hue-neutral', () { final pulse = BeamPhaseResolver(_config(BeamVariant.pulseOutside)); final frame = pulse.staticFrame(); @@ -111,10 +224,10 @@ void main() { variant: BeamVariant.small, palette: BeamColors.colorful.resolve(), brightness: Brightness.dark, - strength: 1.7, + style: const BeamStyle(strength: 1.7), ); expect(c.strength, 1); - expect(c.borderRadius, 32); + expect(c.borderRadius.topLeft.x, 32); expect(c.cycleSeconds, closeTo(1.96, 1e-9)); expect(c.brightnessFactor, 1.3); expect(c.saturation, 1.2); diff --git a/test/animation/beam_travel_test.dart b/test/animation/beam_travel_test.dart new file mode 100644 index 0000000..0ce24c2 --- /dev/null +++ b/test/animation/beam_travel_test.dart @@ -0,0 +1,393 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The travel half of the phase resolver: which way the beam runs, where its +/// timeline starts, how many beams share the contour, which shape the hue +/// track takes, and when the repeat budget is spent. +/// +/// Every one of these is a pure function of elapsed time, so they are tested +/// against the resolver directly rather than through a mounted widget. +void main() { + const cycle = Duration(milliseconds: 1960); + const cycleSeconds = 1.96; + + BeamPhaseResolver resolverFor({ + BeamVariant variant = BeamVariant.rotate, + BeamDirection? direction, + double? phaseOffset, + int? beamCount, + Duration? cycleGap, + BeamHueMode? hueMode, + double? hueRange, + BeamEdge? edge, + }) => BeamPhaseResolver( + BeamConfig.resolve( + variant: variant, + palette: BeamColors.colorful.resolve(), + brightness: Brightness.dark, + style: BeamStyle(hueMode: hueMode, hueRange: hueRange), + shape: BeamShape(edge: edge), + timing: BeamTiming( + cycle: cycle, + cycleGap: cycleGap, + direction: direction, + phaseOffset: phaseOffset, + beamCount: beamCount, + ), + ), + ); + + group('direction', () { + test('forward runs the sweep from 0 to 1 across the cycle', () { + final resolver = resolverFor(); + expect(resolver.sample(0, 1).travelProgress, closeTo(0, 1e-9)); + expect( + resolver.sample(cycleSeconds * 0.25, 1).travelProgress, + closeTo(0.25, 1e-9), + ); + expect( + resolver.sample(cycleSeconds * 0.25, 1).angleRadians, + closeTo(math.pi / 2, 1e-9), + ); + expect(resolver.sample(cycleSeconds * 0.25, 1).reversedNow, isFalse); + }); + + test('reverse mirrors every traveller and flags the frame', () { + final forward = resolverFor(); + final reverse = resolverFor(direction: BeamDirection.reverse); + for (final t in [0.0, 0.4, 1.1, cycleSeconds * 0.75, 5.3]) { + expect( + reverse.sample(t, 1).travelProgress, + closeTo(1 - forward.sample(t, 1).travelProgress, 1e-9), + reason: 'the mirror of the forward sweep at ${t}s', + ); + } + expect(reverse.sample(1.3, 1).reversedNow, isTrue); + }); + + test('reverse runs the angle counter-clockwise', () { + final resolver = resolverFor(direction: BeamDirection.reverse); + final early = resolver.sample(cycleSeconds * 0.1, 1).angleRadians; + final later = resolver.sample(cycleSeconds * 0.2, 1).angleRadians; + expect(later, lessThan(early)); + }); + + test('bounce alternates per cycle, starting forward', () { + final resolver = resolverFor(direction: BeamDirection.bounce); + // Quarter of the way into cycles 0, 1, and 2. + expect( + resolver.sample(cycleSeconds * 0.25, 1).travelProgress, + closeTo(0.25, 1e-9), + ); + expect(resolver.sample(cycleSeconds * 0.25, 1).reversedNow, isFalse); + expect( + resolver.sample(cycleSeconds * 1.25, 1).travelProgress, + closeTo(0.75, 1e-9), + ); + expect(resolver.sample(cycleSeconds * 1.25, 1).reversedNow, isTrue); + expect( + resolver.sample(cycleSeconds * 2.25, 1).travelProgress, + closeTo(0.25, 1e-9), + ); + expect(resolver.sample(cycleSeconds * 2.25, 1).reversedNow, isFalse); + }); + + test('bounce counts cycles including the gap', () { + final resolver = resolverFor( + direction: BeamDirection.bounce, + cycleGap: const Duration(seconds: 1), + ); + // The sweep ends at 1.96s but the cycle only does at 2.96s, so the + // rest between them still belongs to the forward cycle. + expect(resolver.sample(2.5, 1).reversedNow, isFalse); + expect( + resolver.sample(2.96 + cycleSeconds * 0.25, 1).reversedNow, + isTrue, + ); + expect( + resolver.sample(2 * 2.96 + cycleSeconds * 0.25, 1).reversedNow, + isFalse, + ); + }); + + test('reverse parks at the mirrored end through a cycle gap', () { + final resolver = resolverFor( + direction: BeamDirection.reverse, + cycleGap: const Duration(seconds: 1), + ); + final resting = resolver.sample(cycleSeconds + 0.5, 1); + expect(resting.travelProgress, closeTo(0, 1e-9)); + expect(resting.fadeOpacity, 0, reason: 'the gap still rests the beam'); + }); + + test('the line variant reverses its travel table', () { + final forward = resolverFor(variant: BeamVariant.line); + final reverse = resolverFor( + variant: BeamVariant.line, + direction: BeamDirection.reverse, + ); + final at = cycleSeconds * 0.3; + expect( + reverse.sample(at, 1).lineX, + closeTo(forward.sample(cycleSeconds * 0.7, 1).lineX, 1e-9), + ); + }); + }); + + group('phaseOffset', () { + test('starts the timeline that fraction of a cycle in', () { + final resolver = resolverFor(phaseOffset: 0.25); + expect(resolver.sample(0, 1).travelProgress, closeTo(0.25, 1e-9)); + expect( + resolver.sample(cycleSeconds * 0.5, 1).travelProgress, + closeTo(0.75, 1e-9), + ); + }); + + test('leaves the hue track where it was', () { + final plain = resolverFor(); + final shifted = resolverFor(phaseOffset: 0.5); + expect( + shifted.sample(1.3, 1).hueDegrees, + closeTo(plain.sample(1.3, 1).hueDegrees, 1e-9), + ); + }); + + test('a whole-cycle offset is the identity', () { + final plain = resolverFor(); + final shifted = resolverFor(phaseOffset: 1); + expect( + shifted.sample(0.7, 1).travelProgress, + closeTo(plain.sample(0.7, 1).travelProgress, 1e-9), + ); + }); + }); + + group('beamCount', () { + test('one beam is a single-entry list holding the head', () { + final phases = resolverFor().sample(0.5, 1); + expect(phases.travellers, hasLength(1)); + expect(phases.travellers.single, phases.travelProgress); + }); + + test('spaces the beams evenly and keeps the head first', () { + final phases = resolverFor(beamCount: 3).sample(cycleSeconds * 0.1, 1); + expect(phases.travellers, hasLength(3)); + expect(phases.travellers.first, closeTo(0.1, 1e-9)); + expect(phases.travellers[1], closeTo(0.1 + 1 / 3, 1e-9)); + expect(phases.travellers[2], closeTo(0.1 + 2 / 3, 1e-9)); + expect(phases.travelProgress, phases.travellers.first); + }); + + test('wraps the trailing beams into 0–1', () { + final phases = resolverFor(beamCount: 4).sample(cycleSeconds * 0.9, 1); + expect(phases.travellers, hasLength(4)); + for (final p in phases.travellers) { + expect(p, inInclusiveRange(0, 1)); + } + expect(phases.travellers[1], closeTo(0.15, 1e-9)); + }); + + test('reverse spaces the beams the other way round', () { + final phases = resolverFor( + beamCount: 2, + direction: BeamDirection.reverse, + ).sample(cycleSeconds * 0.1, 1); + expect(phases.travellers.first, closeTo(0.9, 1e-9)); + expect(phases.travellers[1], closeTo(0.4, 1e-9)); + }); + + test('the spacing holds all the way round the cycle', () { + final resolver = resolverFor(beamCount: 5); + for (final t in [0.0, 0.3, 1.1, 1.9, 4.4]) { + final travellers = resolver.sample(t, 1).travellers; + for (var i = 1; i < travellers.length; i++) { + final gap = (travellers[i] - travellers[i - 1]) % 1.0; + expect(gap, closeTo(0.2, 1e-9), reason: 'beams $i and ${i - 1}'); + } + } + }); + }); + + group('hueMode', () { + test('the traveling variants ping-pong by default', () { + final resolver = resolverFor(hueRange: 30); + // 12s period: a quarter in is the middle of the upward swing. + expect(resolver.sample(0, 1).hueDegrees, closeTo(-30, 1e-9)); + expect(resolver.sample(6, 1).hueDegrees, closeTo(30, 1e-9)); + expect(resolver.sample(12, 1).hueDegrees, closeTo(-30, 1e-9)); + }); + + test('the pulse variants revolve by default', () { + final resolver = resolverFor(variant: BeamVariant.pulseInside); + // 16s period for pulse-inside. + expect(resolver.sample(0, 1).hueDegrees, closeTo(0, 1e-9)); + expect(resolver.sample(8, 1).hueDegrees, closeTo(180, 1e-9)); + expect(resolver.sample(16, 1).hueDegrees, closeTo(0, 1e-9)); + }); + + for (final variant in BeamVariant.values) { + test('$variant: continuous revolves through 360°', () { + final resolver = resolverFor( + variant: variant, + hueMode: BeamHueMode.continuous, + ); + final period = resolver.config.huePeriodSeconds; + expect(resolver.sample(0, 1).hueDegrees, closeTo(0, 1e-9)); + expect(resolver.sample(period / 4, 1).hueDegrees, closeTo(90, 1e-9)); + expect(resolver.sample(period * 0.999, 1).hueDegrees, greaterThan(359)); + }); + + test('$variant: pingPong stays inside ±hueRange', () { + final resolver = resolverFor( + variant: variant, + hueMode: BeamHueMode.pingPong, + hueRange: 20, + ); + final range = resolver.config.hueRange; + for (var i = 0; i <= 40; i++) { + final hue = resolver.sample(i * 0.7, 1).hueDegrees; + expect(hue, inInclusiveRange(-range - 1e-9, range + 1e-9)); + } + }); + } + + test('static colors beat both modes', () { + final resolver = BeamPhaseResolver( + BeamConfig.resolve( + variant: BeamVariant.rotate, + palette: BeamColors.colorful.resolve(), + brightness: Brightness.dark, + style: const BeamStyle( + staticColors: true, + hueMode: BeamHueMode.continuous, + ), + ), + ); + expect(resolver.sample(5, 1).hueDegrees, 0); + }); + }); + + group('repeat budget', () { + test('no budget never finishes', () { + final resolver = resolverFor(); + expect(resolver.finishedAt(1000), isFalse); + expect(resolver.sample(1000, 1).finished, isFalse); + }); + + test('one cycle finishes at the cycle boundary', () { + final resolver = resolverFor()..repeatCycles = 1; + expect(resolver.finishedAt(cycleSeconds - 0.001), isFalse); + expect(resolver.finishedAt(cycleSeconds + 0.001), isTrue); + expect(resolver.sample(cycleSeconds + 0.001, 1).finished, isTrue); + }); + + test('a count finishes after that many cycles', () { + final resolver = resolverFor()..repeatCycles = 3; + expect(resolver.finishedAt(3 * cycleSeconds - 0.001), isFalse); + expect(resolver.finishedAt(3 * cycleSeconds + 0.001), isTrue); + }); + + test('the gap counts toward a cycle', () { + final resolver = resolverFor(cycleGap: const Duration(seconds: 1)) + ..repeatCycles = 1; + expect(resolver.finishedAt(cycleSeconds + 0.5), isFalse); + expect(resolver.finishedAt(cycleSeconds + 1.1), isTrue); + }); + + test('a phase offset moves the finish with the timeline', () { + final resolver = resolverFor(phaseOffset: 0.5)..repeatCycles = 1; + expect(resolver.finishedAt(cycleSeconds * 0.4), isFalse); + expect(resolver.finishedAt(cycleSeconds * 0.6), isTrue); + }); + + test('pulse repeat ignores a configured cycle gap', () { + final resolver = resolverFor( + variant: BeamVariant.pulseInside, + cycleGap: const Duration(seconds: 1), + )..repeatCycles = 2; + final period = resolver.config.cycleSeconds; + expect(resolver.sample(2 * period - 0.01, 1).finished, isFalse); + expect(resolver.sample(2 * period + 0.01, 1).finished, isTrue); + }); + }); + + group('driven progress', () { + test('replaces the sweep with the given value', () { + final resolver = resolverFor(); + final phases = resolver.sample(0.9, 1, progress: 0.25); + expect(phases.travelProgress, closeTo(0.25, 1e-9)); + expect(phases.angleRadians, closeTo(math.pi / 2, 1e-9)); + }); + + test('the same value paints the same frame at any time', () { + final resolver = resolverFor(); + expect( + resolver.sample(0.3, 1, progress: 0.6).angleRadians, + closeTo(resolver.sample(7.4, 1, progress: 0.6).angleRadians, 1e-9), + ); + }); + + test('clamps out-of-range values', () { + final resolver = resolverFor(); + expect(resolver.sample(0, 1, progress: 1.4).travelProgress, 1); + expect(resolver.sample(0, 1, progress: -0.3).travelProgress, 0); + }); + + test('ignores the cycle gap — a readout never rests', () { + final resolver = resolverFor(cycleGap: const Duration(seconds: 1)); + final resting = resolver.sample(cycleSeconds + 0.5, 1, progress: 0.4); + expect(resting.fadeOpacity, 1); + expect(resting.travelProgress, closeTo(0.4, 1e-9)); + }); + + test('keeps the hue and the line texture tracks running', () { + final resolver = resolverFor(variant: BeamVariant.line); + final early = resolver.sample(0.4, 1, progress: 0.5); + final late = resolver.sample(3.4, 1, progress: 0.5); + expect(early.hueDegrees, isNot(closeTo(late.hueDegrees, 1e-6))); + expect(early.lineH, isNot(closeTo(late.lineH, 1e-6))); + expect(early.lineX, closeTo(late.lineX, 1e-9)); + }); + + test('spreads multiple beams around the driven position', () { + final phases = resolverFor(beamCount: 2).sample(0, 1, progress: 0.25); + expect(phases.travellers, hasLength(2)); + expect(phases.travellers[1], closeTo(0.75, 1e-9)); + }); + + test('the static frame honors it', () { + final resolver = resolverFor(); + expect( + resolver.staticFrame(progress: 0.25).angleRadians, + closeTo(math.pi / 2, 1e-9), + ); + expect(resolver.staticFrame().angleRadians, closeTo(math.pi, 1e-9)); + }); + }); + + group('travelTimeOffset', () { + test('shifts the sweep without touching the hue', () { + final plain = resolverFor(); + final shifted = resolverFor()..travelTimeOffset = cycleSeconds * 0.25; + expect(shifted.sample(0, 1).travelProgress, closeTo(0.25, 1e-9)); + expect( + shifted.sample(1.3, 1).hueDegrees, + closeTo(plain.sample(1.3, 1).hueDegrees, 1e-9), + ); + }); + + test('a negative offset runs the sweep behind', () { + final resolver = resolverFor()..travelTimeOffset = -cycleSeconds * 0.25; + expect( + resolver.sample(cycleSeconds * 0.5, 1).travelProgress, + closeTo(0.25, 1e-9), + ); + }); + }); +} diff --git a/test/animation/oscillator_test.dart b/test/animation/oscillator_test.dart index 5da0d13..8615aff 100644 --- a/test/animation/oscillator_test.dart +++ b/test/animation/oscillator_test.dart @@ -1,8 +1,8 @@ import 'dart:ui'; -import 'package:border_beam/src/animation/oscillator.dart'; -import 'package:border_beam/src/constants/pulse_params.dart'; -import 'package:border_beam/src/models/beam_variant.dart'; +import 'package:flutter_border_beam/src/animation/oscillator.dart'; +import 'package:flutter_border_beam/src/constants/pulse_params.dart'; +import 'package:flutter_border_beam/src/models/beam_variant.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/test/animation/phase_property_test.dart b/test/animation/phase_property_test.dart new file mode 100644 index 0000000..5c6fd17 --- /dev/null +++ b/test/animation/phase_property_test.dart @@ -0,0 +1,225 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/constants/pulse_params.dart'; +import 'package:flutter_border_beam/src/models/beam_colors.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/models/beam_variant.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Property tests over [BeamPhaseResolver.sample]. +/// +/// Every animated value in this package is a pure function of elapsed time, +/// which is what lets the painter re-derive a frame from the clock alone. +/// These tests assert that purity, the periodicity the cycle duration +/// promises, and the range each phase field is allowed to occupy — over a +/// seeded random sweep rather than a handful of hand-picked instants. +void main() { + const sampleCount = 500; + + BeamConfig configFor(BeamVariant variant, {ui.Brightness? brightness}) => + BeamConfig.resolve( + variant: variant, + palette: BeamColors.colorful.resolve(), + brightness: brightness ?? ui.Brightness.dark, + ); + + void expectPhasesEqual( + BeamFramePhases a, + BeamFramePhases b, { + required String at, + }) { + expect(a.fadeOpacity, b.fadeOpacity, reason: 'fadeOpacity $at'); + expect(a.hueDegrees, b.hueDegrees, reason: 'hueDegrees $at'); + expect(a.bloomHueDegrees, b.bloomHueDegrees, reason: 'bloomHue $at'); + expect(a.angleRadians, b.angleRadians, reason: 'angleRadians $at'); + expect(a.lineX, b.lineX, reason: 'lineX $at'); + expect(a.lineW, b.lineW, reason: 'lineW $at'); + expect(a.lineH, b.lineH, reason: 'lineH $at'); + expect(a.spike, b.spike, reason: 'spike $at'); + expect(a.spike2, b.spike2, reason: 'spike2 $at'); + expect(a.edge, b.edge, reason: 'edge $at'); + expect(a.pulse.bw, b.pulse.bw, reason: 'pulse.bw $at'); + expect(a.pulse.bh, b.pulse.bh, reason: 'pulse.bh $at'); + expect(a.pulse.bx, b.pulse.bx, reason: 'pulse.bx $at'); + expect(a.pulse.by, b.pulse.by, reason: 'pulse.by $at'); + expect(a.pulse.bgh, b.pulse.bgh, reason: 'pulse.bgh $at'); + expect(a.pulse.bopTl, b.pulse.bopTl, reason: 'pulse.bopTl $at'); + expect(a.pulse.bopTr, b.pulse.bopTr, reason: 'pulse.bopTr $at'); + expect(a.pulse.bopBl, b.pulse.bopBl, reason: 'pulse.bopBl $at'); + expect(a.pulse.bopBr, b.pulse.bopBr, reason: 'pulse.bopBr $at'); + } + + group('purity', () { + for (final variant in BeamVariant.values) { + test('$variant: the same t yields the same frame', () { + final config = configFor(variant); + final resolver = BeamPhaseResolver(config); + // A second resolver over the same config must agree too: the + // oscillator bank carries no per-frame state. + final twin = BeamPhaseResolver(config); + final random = math.Random(0x5EED); + for (var i = 0; i < sampleCount; i++) { + final t = random.nextDouble() * 1000; + final fade = random.nextDouble(); + expectPhasesEqual( + resolver.sample(t, fade), + resolver.sample(t, fade), + at: 'at t=$t', + ); + expectPhasesEqual( + resolver.sample(t, fade), + twin.sample(t, fade), + at: 'across resolvers at t=$t', + ); + } + }); + + test('$variant: fadeOpacity passes straight through', () { + final resolver = BeamPhaseResolver(configFor(variant)); + final random = math.Random(0xFADE); + for (var i = 0; i < sampleCount; i++) { + final fade = random.nextDouble(); + expect( + resolver.sample(random.nextDouble() * 1000, fade).fadeOpacity, + fade, + ); + } + }); + } + }); + + group('cycle periodicity', () { + // Only the tracks driven by the cycle duration itself repeat every + // cycle. The line variant's breathe/spike tracks run at ×1.3/×1.33/×1.7 + // of it, and the hue ping-pong has its own 12s period, so neither is + // expected to line up here. + // + // Samples stay clear of the wrap (fraction 0.02–0.98) so a float + // rounding of the modulo cannot land the two reads on opposite sides of + // a keyframe seam. + ({double a, double b}) pairFor(math.Random random, double cycleSeconds) { + final fraction = 0.02 + random.nextDouble() * 0.96; + final t = fraction * cycleSeconds; + final k = 1 + random.nextInt(20); + return (a: t, b: t + k * cycleSeconds); + } + + for (final variant in [BeamVariant.rotate, BeamVariant.small]) { + test('$variant: the conic angle repeats every cycle', () { + final config = configFor(variant); + final resolver = BeamPhaseResolver(config); + final random = math.Random(0xC0FFEE); + for (var i = 0; i < sampleCount; i++) { + final (a: t, b: later) = pairFor(random, config.cycleSeconds); + expect( + resolver.sample(later, 1).angleRadians, + closeTo(resolver.sample(t, 1).angleRadians, 1e-6), + reason: 'angleRadians at t=$t vs $later', + ); + } + }); + } + + test('line: travel, width and edge fade repeat every cycle', () { + final config = configFor(BeamVariant.line); + final resolver = BeamPhaseResolver(config); + final random = math.Random(0xBEEF); + for (var i = 0; i < sampleCount; i++) { + final (a: t, b: later) = pairFor(random, config.cycleSeconds); + final first = resolver.sample(t, 1); + final second = resolver.sample(later, 1); + expect(second.lineX, closeTo(first.lineX, 1e-6), reason: 'lineX @$t'); + expect(second.lineW, closeTo(first.lineW, 1e-6), reason: 'lineW @$t'); + expect(second.edge, closeTo(first.edge, 1e-6), reason: 'edge @$t'); + } + }); + }); + + group('ranges', () { + for (final variant in BeamVariant.values) { + test('$variant: the conic angle stays in [0, 2pi)', () { + final resolver = BeamPhaseResolver(configFor(variant)); + final random = math.Random(0xA11CE); + for (var i = 0; i < sampleCount; i++) { + final angle = resolver + .sample(random.nextDouble() * 5000, 1) + .angleRadians; + expect(angle, greaterThanOrEqualTo(0)); + expect(angle, lessThan(2 * math.pi)); + } + }); + } + + test('line: the edge fade stays in [0, 1]', () { + final resolver = BeamPhaseResolver(configFor(BeamVariant.line)); + final random = math.Random(0xED6E); + for (var i = 0; i < sampleCount; i++) { + final edge = resolver.sample(random.nextDouble() * 5000, 1).edge; + expect(edge, inInclusiveRange(0, 1)); + } + }); + + for (final variant in [BeamVariant.pulseInside, BeamVariant.pulseOutside]) { + for (final brightness in ui.Brightness.values) { + test('$variant/$brightness: breathing stays inside its params', () { + final config = configFor(variant, brightness: brightness); + final params = PulseParams.resolve( + variant, + brightness, + config.cycleSeconds, + ); + final resolver = BeamPhaseResolver(config); + final random = math.Random(0xDEC0DE); + // The widest oscillator in the size bank swings to 1 + sp × 1.15. + final sizeSpan = params.sp * 1.15; + for (var i = 0; i < sampleCount; i++) { + final pulse = resolver.sample(random.nextDouble() * 5000, 1).pulse; + for (final v in pulse.bw) { + expect(v, inInclusiveRange(1 - sizeSpan, 1 + sizeSpan)); + } + for (final v in pulse.bh) { + expect(v, inInclusiveRange(1 - sizeSpan, 1 + sizeSpan)); + } + for (final v in [...pulse.bx, ...pulse.by]) { + expect(v, inInclusiveRange(-params.dr, params.dr)); + } + expect(pulse.bgh, inInclusiveRange(1 - params.gh, 1 + params.gh)); + for (final v in [ + pulse.bopTl, + pulse.bopTr, + pulse.bopBl, + pulse.bopBr, + ]) { + expect(v, inInclusiveRange(1 - params.op, 1)); + } + } + }); + } + } + }); + + group('staticFrame', () { + for (final variant in BeamVariant.values) { + test('$variant: is deterministic', () { + final resolver = BeamPhaseResolver(configFor(variant)); + expectPhasesEqual( + resolver.staticFrame(), + resolver.staticFrame(), + at: 'in staticFrame', + ); + expect(resolver.staticFrame().fadeOpacity, 1); + }); + + test('$variant: is hue-neutral', () { + // Reduced motion paints one frame forever, so it must show the + // palette's own colors rather than an arbitrary point of the hue + // ping-pong frozen in place. + final resolver = BeamPhaseResolver(configFor(variant)); + expect(resolver.staticFrame().hueDegrees, 0); + expect(resolver.staticFrame().bloomHueDegrees, 0); + }); + } + }); +} diff --git a/test/animation/spring_curve_test.dart b/test/animation/spring_curve_test.dart new file mode 100644 index 0000000..97d3a26 --- /dev/null +++ b/test/animation/spring_curve_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_border_beam/src/animation/spring_curve.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// `FadeSpringCurve.transform(i * 0.05)` for i = 0..20, at full double +/// precision. The fade envelope's shape is visual API — goldens and the +/// documented ~3% overshoot both depend on these exact values. +const List _oracle = [ + 0.0, + 0.15986030326692727, + 0.44896452311296503, + 0.7063666247249973, + 0.8815479259123428, + 0.9784616720631045, + 1.020016563364826, + 1.029814982804687, + 1.0252447161959004, + 1.016641057993826, + 1.0089117673752073, + 1.003622629972982, + 1.0006807812985246, + 0.999408629217683, + 0.9990992111048517, + 0.9992285492416089, + 0.9994836129267871, + 0.9997147812271953, + 0.9998735617828104, + 0.9999619578842425, + 1.0, +]; + +void main() { + const curve = FadeSpringCurve.instance; + + test('matches the reference samples at 0.05 increments', () { + for (var i = 0; i < _oracle.length; i++) { + final t = i * 0.05; + expect( + curve.transform(t), + closeTo(_oracle[i], 1e-9), + reason: 'sample $i (t = $t)', + ); + } + }); + + test('pins both endpoints exactly', () { + expect(curve.transform(0), 0.0); + expect(curve.transform(1), 1.0); + }); + + test('rises monotonically to its peak, then settles', () { + const samples = 1000; + var peakIndex = 0; + var peak = 0.0; + final values = List.generate( + samples + 1, + (i) => curve.transform(i / samples), + ); + for (var i = 0; i <= samples; i++) { + if (values[i] > peak) { + peak = values[i]; + peakIndex = i; + } + } + + // Under-damped: it overshoots once, near t = 0.35, by ~3%. + expect(peak, closeTo(1.0298, 1e-3)); + expect(peakIndex / samples, closeTo(0.35, 0.02)); + + for (var i = 1; i <= peakIndex; i++) { + expect( + values[i], + greaterThanOrEqualTo(values[i - 1]), + reason: 'dip before the peak at sample $i', + ); + } + // Past the peak the spring rings down and never climbs back to it. + for (var i = peakIndex + 1; i <= samples; i++) { + expect(values[i], lessThan(peak), reason: 'second peak at sample $i'); + } + }); + + test('overshoot stays within the documented bound', () { + const samples = 100000; + var max = 0.0; + var min = 0.0; + for (var i = 0; i <= samples; i++) { + final v = curve.transform(i / samples); + if (v > max) max = v; + if (v < min) min = v; + } + expect(max, lessThan(1.03)); + expect(min, greaterThanOrEqualTo(0.0)); + }); +} diff --git a/test/constants/palette_data_test.dart b/test/constants/palette_data_test.dart index 9d129a3..a39ab6d 100644 --- a/test/constants/palette_data_test.dart +++ b/test/constants/palette_data_test.dart @@ -1,9 +1,10 @@ -import 'package:border_beam/src/constants/palettes.dart'; -import 'package:border_beam/src/constants/pulse_tables.dart'; -import 'package:border_beam/src/constants/theme_presets.dart'; -import 'package:border_beam/src/models/beam_colors.dart'; -import 'package:border_beam/src/models/beam_variant.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_border_beam/src/constants/extra_palettes.dart'; +import 'package:flutter_border_beam/src/constants/palettes.dart'; +import 'package:flutter_border_beam/src/constants/pulse_tables.dart'; +import 'package:flutter_border_beam/src/constants/theme_presets.dart'; +import 'package:flutter_border_beam/src/models/beam_colors.dart'; +import 'package:flutter_border_beam/src/models/beam_variant.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -55,6 +56,76 @@ void main() { }); }); + group('Flutter-only palette additions', () { + // These are not transcriptions: each is a short source-color list that + // `BeamColors.custom` distributes over the colorful geometry. + const sources = { + 'aurora': (BeamColors.aurora, auroraColors), + 'neon': (BeamColors.neon, neonColors), + 'candy': (BeamColors.candy, candyColors), + 'ember': (BeamColors.ember, emberColors), + 'ice': (BeamColors.ice, iceColors), + 'gold': (BeamColors.gold, goldColors), + 'holographic': (BeamColors.holographic, holographicColors), + }; + + for (final MapEntry(key: name, value: (colors, source)) + in sources.entries) { + test('$name resolves to the source table cardinalities', () { + final p = colors.resolve().data; + expect(p.border, hasLength(9)); + expect(p.smallBorder, hasLength(8)); + expect(p.smallInner, hasLength(8)); + expect(p.lineDark, hasLength(9)); + expect(p.lineLight, hasLength(9)); + expect(p.lineInner, hasLength(9)); + expect(p.lineBloomDark, hasLength(5)); + expect(p.lineBloomLight, hasLength(5)); + }); + + test('$name keeps the colorful geometry and alpha structure', () { + final p = colors.resolve().data; + for (final (i, blob) in p.border.indexed) { + expect(blob.position, colorfulPreset.border[i].position); + expect(blob.size, colorfulPreset.border[i].size); + expect(blob.color.a, closeTo(colorfulPreset.border[i].color.a, 1e-6)); + } + for (final (i, blob) in p.smallInner.indexed) { + expect( + blob.color.a, + closeTo(colorfulPreset.smallInner[i].color.a, 1e-6), + ); + } + for (final (i, pair) in p.lineBloomDark.indexed) { + expect( + pair.color1.a, + closeTo(colorfulPreset.lineBloomDark[i].color1.a, 1e-6), + ); + } + }); + + test('$name uses 3-5 source colors, cycled over the border table', () { + expect(source.length, inInclusiveRange(3, 5)); + expect(source.toSet(), hasLength(source.length), reason: 'duplicates'); + final p = colors.resolve().data; + for (final (i, blob) in p.border.indexed) { + final expected = source[i % source.length]; + expect(blob.color.r, closeTo(expected.r, 1e-6), reason: 'border $i'); + expect(blob.color.g, closeTo(expected.g, 1e-6), reason: 'border $i'); + expect(blob.color.b, closeTo(expected.b, 1e-6), reason: 'border $i'); + } + }); + } + + test('the source lists carry opaque colors', () { + for (final MapEntry(key: name, value: (_, source)) in sources.entries) { + for (final c in source) { + expect(c.a, 1.0, reason: '$name has a translucent source color'); + } + } + }); + }); + group('pulse tables', () { test('cardinalities and index bounds', () { expect(pulseRingMap, hasLength(9)); diff --git a/test/constants/spec_parity_test.dart b/test/constants/spec_parity_test.dart new file mode 100644 index 0000000..be6d608 --- /dev/null +++ b/test/constants/spec_parity_test.dart @@ -0,0 +1,1375 @@ +// Asserts every table in `lib/src/constants/` against the upstream React +// library's machine-readable spec, vendored at `test/fixtures/beam-spec.json`. +// +// Our constants are hand transcriptions of `src/styles.ts`; this test is the +// only thing that can prove the transcription is still faithful. A failure +// here means the SPEC and OUR CONSTANTS disagree — re-audit against +// `src/styles.ts` and report the divergence. Never "fix" a constant to make +// this test pass (CLAUDE.md hard rule 1). +// +// Spec conventions the parsers below handle: +// colors CSS `rgb(r, g, b)` / `rgba(r, g, b, a)` / `transparent` +// positions `"33% -7.4%"` → fractional Offset(0.33, -0.074) +// sizes `"70px 40px"` → Size(70, 40), and CSS ellipse sizes are RADII +// stops percent (0–100) where our tables carry fractions (0–1) + +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter_border_beam/src/animation/beam_clock.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/animation/oscillator.dart'; +import 'package:flutter_border_beam/src/constants/line_geometry.dart'; +import 'package:flutter_border_beam/src/constants/line_keyframes.dart'; +import 'package:flutter_border_beam/src/constants/palettes.dart'; +import 'package:flutter_border_beam/src/constants/pulse_constants.dart'; +import 'package:flutter_border_beam/src/constants/pulse_params.dart'; +import 'package:flutter_border_beam/src/constants/pulse_tables.dart'; +import 'package:flutter_border_beam/src/constants/rotate_stops.dart'; +import 'package:flutter_border_beam/src/constants/theme_presets.dart'; +import 'package:flutter_border_beam/src/constants/upstream.dart'; +import 'package:flutter_border_beam/src/models/beam_blob.dart'; +import 'package:flutter_border_beam/src/models/beam_colors.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/models/beam_palette.dart'; +import 'package:flutter_border_beam/src/models/beam_style.dart'; +import 'package:flutter_border_beam/src/models/beam_variant.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// ─── Fixture ──────────────────────────────────────────────────────────────── + +const String _fixturePath = 'test/fixtures/beam-spec.json'; +const String _upstreamPath = 'test/fixtures/UPSTREAM'; + +Map _loadSpec() => + jsonDecode(File(_fixturePath).readAsStringSync()) as Map; + +Map _loadUpstream() { + final out = {}; + for (final line in File(_upstreamPath).readAsLinesSync()) { + if (line.trim().isEmpty) continue; + final i = line.indexOf('='); + out[line.substring(0, i)] = line.substring(i + 1); + } + return out; +} + +// ─── CSS value parsers ────────────────────────────────────────────────────── + +/// Parses a spec color string: `rgb(r, g, b)`, `rgba(r, g, b, a)`, or the +/// keyword `transparent`. +Color cssColor(Object? raw) { + final s = (raw! as String).trim(); + if (s == 'transparent') return const Color(0x00000000); + final m = RegExp(r'^rgba?\(([^)]*)\)$').firstMatch(s); + if (m == null) throw FormatException('not a CSS rgb/rgba color: "$s"'); + final parts = m.group(1)!.split(',').map((p) => p.trim()).toList(); + if (parts.length != 3 && parts.length != 4) { + throw FormatException('CSS color needs 3 or 4 components: "$s"'); + } + return Color.fromRGBO( + int.parse(parts[0]), + int.parse(parts[1]), + int.parse(parts[2]), + parts.length == 4 ? double.parse(parts[3]) : 1.0, + ); +} + +/// Parses a spec position string — `"33% -7.4%"` → `Offset(0.33, -0.074)`. +Offset cssPosition(Object? raw) { + final parts = (raw! as String).trim().split(RegExp(r'\s+')); + if (parts.length != 2) { + throw FormatException('CSS position needs two components: "$raw"'); + } + double pct(String p) { + if (!p.endsWith('%')) throw FormatException('not a percentage: "$p"'); + return double.parse(p.substring(0, p.length - 1)) / 100; + } + + return Offset(pct(parts[0]), pct(parts[1])); +} + +/// Parses a spec size string — `"70px 40px"` → `Size(70, 40)`. CSS +/// `radial-gradient(ellipse W H …)` sizes are radii, and so is [Size] here. +Size cssSize(Object? raw) { + final parts = (raw! as String).trim().split(RegExp(r'\s+')); + if (parts.length != 2) { + throw FormatException('CSS size needs two components: "$raw"'); + } + double px(String p) { + if (!p.endsWith('px')) throw FormatException('not a px length: "$p"'); + return double.parse(p.substring(0, p.length - 2)); + } + + return Size(px(parts[0]), px(parts[1])); +} + +/// Parses a single spec percentage — `"27%"` → `0.27`. +double cssPercent(Object? raw) { + final s = (raw! as String).trim(); + if (!s.endsWith('%')) throw FormatException('not a percentage: "$s"'); + return double.parse(s.substring(0, s.length - 1)) / 100; +} + +// ─── Matchers ─────────────────────────────────────────────────────────────── + +/// One 8-bit step: CSS integer channels round-trip through Flutter's doubles. +const double _channelTolerance = 1 / 255; + +/// Half an ulp of the spec's 4-decimal rounding. Flutter stores alpha as an +/// exact double, so the only slack needed is the spec generator's rounding of +/// the mono-attenuated alphas (0.82 × 0.098 = 0.08036, recorded as 0.0804). +const double _alphaTolerance = 1e-4; + +const double _epsilon = 1e-9; + +void expectColor(Color actual, Object? specColor, String reason) { + final want = cssColor(specColor); + expect(actual.r, closeTo(want.r, _channelTolerance), reason: '$reason (r)'); + expect(actual.g, closeTo(want.g, _channelTolerance), reason: '$reason (g)'); + expect(actual.b, closeTo(want.b, _channelTolerance), reason: '$reason (b)'); + expect(actual.a, closeTo(want.a, _channelTolerance), reason: '$reason (a)'); +} + +/// Compares component-wise: `"-7.4%" / 100` and `-0.074` are the same +/// position but not the same double, so `Offset ==` is too strict here. +void expectOffset(Offset actual, Object? specPos, String reason) { + final want = cssPosition(specPos); + expect(actual.dx, closeTo(want.dx, _epsilon), reason: '$reason (dx)'); + expect(actual.dy, closeTo(want.dy, _epsilon), reason: '$reason (dy)'); +} + +void expectSize(Size actual, Object? specSize, String reason) { + final want = cssSize(specSize); + expect(actual.width, closeTo(want.width, _epsilon), reason: '$reason (w)'); + expect(actual.height, closeTo(want.height, _epsilon), reason: '$reason (h)'); +} + +/// Compares a color against loose spec channels (the expanded +/// `line.bloomGradients` stops carry r/g/b as ints and a as a double). +void expectChannels( + Color actual, { + required Map stop, + required String reason, + double alphaScale = 1, +}) { + expect( + actual.r, + closeTo((stop['r'] as num) / 255, _channelTolerance), + reason: '$reason (r)', + ); + expect( + actual.g, + closeTo((stop['g'] as num) / 255, _channelTolerance), + reason: '$reason (g)', + ); + expect( + actual.b, + closeTo((stop['b'] as num) / 255, _channelTolerance), + reason: '$reason (b)', + ); + expect( + actual.a * alphaScale, + closeTo((stop['a'] as num).toDouble(), _alphaTolerance), + reason: '$reason (a)', + ); +} + +void expectNum(num actual, Object? spec, String reason) => expect( + actual.toDouble(), + closeTo((spec! as num).toDouble(), _epsilon), + reason: reason, +); + +/// Asserts a `[[posPercent, value], …]` spec table against parallel +/// fraction/value lists. +/// [terminatedAt1] covers the tables whose CSS gradient stops short of 100% +/// and lets the last stop's (already transparent) color extend to the end. +/// Flutter's `SweepGradient` needs that extension written out, so our list +/// carries one extra `(1.0, 0.0)` entry the spec has no row for. +void expectStopTable( + Object? spec, + List stops, + List values, + String reason, { + bool terminatedAt1 = false, +}) { + final rows = (spec! as List).cast>(); + final extra = terminatedAt1 ? 1 : 0; + expect(stops, hasLength(rows.length + extra), reason: '$reason (stop count)'); + expect( + values, + hasLength(rows.length + extra), + reason: '$reason (value count)', + ); + for (var i = 0; i < rows.length; i++) { + expectNum(stops[i] * 100, rows[i][0], '$reason stop $i'); + expectNum(values[i], rows[i][1], '$reason value $i'); + } + if (terminatedAt1) { + expect(stops.last, 1.0, reason: '$reason terminal stop'); + expect(values.last, 0.0, reason: '$reason terminal value'); + expect( + values[values.length - 2], + 0.0, + reason: '$reason is already transparent before its terminator', + ); + } +} + +/// Asserts a `[[tPercent, value], …]` spec table against a keyframe list. +void expectKeyframes(Object? spec, List track, String reason) { + final rows = (spec! as List).cast>(); + expect(rows, hasLength(track.length), reason: '$reason (length)'); + for (var i = 0; i < rows.length; i++) { + expectNum(track[i].t * 100, rows[i][0], '$reason t$i'); + expectNum(track[i].value, rows[i][1], '$reason value$i'); + } +} + +// ─── Spec key ↔ Dart enum mapping ─────────────────────────────────────────── + +const Map _presets = { + 'colorful': colorfulPreset, + 'mono': monoPreset, + 'ocean': oceanPreset, + 'sunset': sunsetPreset, +}; + +const Map _sizes = { + 'sm': BeamVariant.small, + 'md': BeamVariant.rotate, + 'line': BeamVariant.line, + 'pulse-outside': BeamVariant.pulseOutside, + 'pulse-inner': BeamVariant.pulseInside, +}; + +const Map _regions = { + 1: PulseRegion.r1, + 2: PulseRegion.r2, + 3: PulseRegion.r3, +}; + +const Map _quads = { + 'tl': PulseQuad.tl, + 'tr': PulseQuad.tr, + 'bl': PulseQuad.bl, + 'br': PulseQuad.br, +}; + +void main() { + final spec = _loadSpec(); + final upstream = _loadUpstream(); + + Map obj(Map from, String key) => + from[key] as Map; + List arr(Map from, String key) => + from[key] as List; + + final palettes = obj(spec, 'palettes'); + final rotate = obj(spec, 'rotate'); + final line = obj(spec, 'line'); + final pulse = obj(spec, 'pulse'); + final defaults = obj(spec, 'defaults'); + + // ─── Provenance ─────────────────────────────────────────────────────────── + + group('provenance', () { + test( + 'the fixture is the spec version our constants were audited against', + () { + expect(spec['specVersion'], upstreamSpecVersion); + expect(obj(spec, 'sourceLibrary')['version'], upstreamLibraryVersion); + expect(obj(spec, 'sourceLibrary')['name'], 'border-beam'); + }, + ); + + test('UPSTREAM records the fetched commit and versions', () { + expect(upstream['commit'], matches(RegExp(r'^[0-9a-f]{40}$'))); + expect(upstream['styles_sha256'], matches(RegExp(r'^[0-9a-f]{64}$'))); + expect(upstream['version'], upstreamLibraryVersion); + expect(upstream['spec_version'], upstreamSpecVersion); + expect(upstreamRepository, contains('Jakubantalik/Libraries')); + }); + }); + + // ─── palettes.border ────────────────────────────────────────────────────── + + group('palettes.border', () { + final border = obj(palettes, 'border'); + for (final MapEntry(key: name, value: preset) in _presets.entries) { + final specPreset = obj(border, name); + + test('$name border blobs', () { + final blobs = specPreset['border'] as List; + expect( + preset.border, + hasLength(blobs.length), + reason: '$name border length', + ); + for (var i = 0; i < blobs.length; i++) { + final b = blobs[i] as Map; + final ours = preset.border[i]; + expectColor(ours.color, b['color'], '$name border[$i].color'); + expectOffset(ours.position, b['pos'], '$name border[$i].pos'); + expectSize(ours.size, b['size'], '$name border[$i].size'); + } + }); + + test('$name spike colors', () { + final s = obj(specPreset, 'spike'); + expectColor(preset.spike.primary, s['primary'], '$name spike.primary'); + expectColor( + preset.spike.secondary, + s['secondary'], + '$name spike.secondary', + ); + final lt = obj(specPreset, 'spikeLt'); + expectColor( + preset.spikeLt.primary, + lt['primary'], + '$name spikeLt.primary', + ); + expectColor( + preset.spikeLt.secondary, + lt['secondary'], + '$name spikeLt.secondary', + ); + }); + } + }); + + // ─── palettes.small ─────────────────────────────────────────────────────── + + group('palettes.small', () { + final small = obj(palettes, 'small'); + for (final MapEntry(key: name, value: preset) in _presets.entries) { + final specPreset = obj(small, name); + + void checkTable(String key, List ours) { + final blobs = specPreset[key] as List; + expect( + ours, + hasLength(blobs.length), + reason: '$name small.$key length', + ); + for (var i = 0; i < blobs.length; i++) { + final b = blobs[i] as Map; + expectColor(ours[i].color, b['color'], '$name small.$key[$i].color'); + expectOffset(ours[i].position, b['pos'], '$name small.$key[$i].pos'); + expectSize(ours[i].size, b['size'], '$name small.$key[$i].size'); + } + } + + test('$name smallBorder', () => checkTable('border', preset.smallBorder)); + test('$name smallInner', () => checkTable('inner', preset.smallInner)); + } + }); + + // ─── palettes.line / palettes.lineInner ─────────────────────────────────── + + group('palettes.line', () { + final specLine = obj(palettes, 'line'); + final specInner = obj(palettes, 'lineInner'); + + void checkLineBlobs( + String reason, + List blobs, + List ours, + ) { + expect(ours, hasLength(blobs.length), reason: '$reason length'); + for (var i = 0; i < blobs.length; i++) { + final b = blobs[i] as Map; + expectColor(ours[i].color, b['color'], '$reason[$i].color'); + expectNum(ours[i].sizeW, b['sizeW'], '$reason[$i].sizeW'); + expectNum(ours[i].sizeH, b['sizeH'], '$reason[$i].sizeH'); + expectNum(ours[i].offsetX, b['offsetX'], '$reason[$i].offsetX'); + expectNum(ours[i].offsetY, b['offsetY'], '$reason[$i].offsetY'); + } + } + + for (final MapEntry(key: name, value: preset) in _presets.entries) { + test('$name lineDark', () { + checkLineBlobs( + '$name line.dark', + arr(obj(specLine, name), 'dark'), + preset.lineDark, + ); + }); + test('$name lineLight', () { + checkLineBlobs( + '$name line.light', + arr(obj(specLine, name), 'light'), + preset.lineLight, + ); + }); + test('$name lineInner', () { + checkLineBlobs( + '$name lineInner', + specInner[name] as List, + preset.lineInner, + ); + }); + } + }); + + // ─── palettes.lineBloom ─────────────────────────────────────────────────── + + group('palettes.lineBloom', () { + final bloom = obj(palettes, 'lineBloom'); + for (final MapEntry(key: name, value: preset) in _presets.entries) { + for (final theme in const ['dark', 'light']) { + test('$name lineBloom.$theme', () { + final spikes = arr(obj(obj(bloom, name), theme), 'spikes'); + final ours = theme == 'dark' + ? preset.lineBloomDark + : preset.lineBloomLight; + expect( + ours, + hasLength(spikes.length), + reason: '$name lineBloom.$theme length', + ); + for (var i = 0; i < spikes.length; i++) { + final s = spikes[i] as Map; + expectColor( + ours[i].color1, + s['color1'], + '$name lineBloom.$theme[$i].c1', + ); + expectColor( + ours[i].color2, + s['color2'], + '$name lineBloom.$theme[$i].c2', + ); + } + }); + } + } + }); + + // ─── sizeThemePresets ───────────────────────────────────────────────────── + + group('sizeThemePresets', () { + final presets = obj(spec, 'sizeThemePresets'); + for (final MapEntry(key: size, value: variant) in _sizes.entries) { + for (final theme in const ['dark', 'light']) { + test('$size/$theme', () { + final s = obj(obj(presets, size), theme); + final ours = themePresetFor( + variant, + theme == 'dark' ? Brightness.dark : Brightness.light, + ); + expectNum( + ours.strokeOpacity, + s['strokeOpacity'], + '$size/$theme strokeOpacity', + ); + expectNum( + ours.innerOpacity, + s['innerOpacity'], + '$size/$theme innerOpacity', + ); + expectNum( + ours.bloomOpacity, + s['bloomOpacity'], + '$size/$theme bloomOpacity', + ); + expectColor( + ours.innerShadow, + s['innerShadow'], + '$size/$theme innerShadow', + ); + expectNum( + ours.saturation, + s['saturation'], + '$size/$theme saturation', + ); + + // `brightness` and `hairlineOpacity` are optional in the spec; a + // missing key must mean a null field on our side, not a default. + if (s.containsKey('brightness')) { + expect( + ours.brightness, + isNotNull, + reason: '$size/$theme brightness present in spec', + ); + expectNum( + ours.brightness!, + s['brightness'], + '$size/$theme brightness', + ); + } else { + expect( + ours.brightness, + isNull, + reason: '$size/$theme brightness absent from spec', + ); + } + if (s.containsKey('hairlineOpacity')) { + expect( + ours.hairlineOpacity, + isNotNull, + reason: '$size/$theme hairlineOpacity present in spec', + ); + expectNum( + ours.hairlineOpacity!, + s['hairlineOpacity'], + '$size/$theme hairlineOpacity', + ); + } else { + expect( + ours.hairlineOpacity, + isNull, + reason: '$size/$theme hairlineOpacity absent from spec', + ); + } + }); + } + } + }); + + // ─── sizePresets ────────────────────────────────────────────────────────── + + group('sizePresets', () { + final presets = obj(spec, 'sizePresets'); + for (final MapEntry(key: size, value: variant) in _sizes.entries) { + test('$size radius and border width', () { + final s = obj(presets, size); + expectNum( + variant.defaultBorderRadius, + s['borderRadius'], + '$size borderRadius', + ); + expectNum( + variant.defaultBorderWidth, + s['borderWidth'], + '$size borderWidth', + ); + }); + } + // GAP: sizePresets.sm.width/height (70×36) has no counterpart — the + // Flutter widget wraps and measures its child instead of forcing a size. + }); + + // ─── rotate.* ───────────────────────────────────────────────────────────── + + group('rotate stops', () { + test('beamMaskStops ↔ rotateWindow*', () { + expectStopTable( + rotate['beamMaskStops'], + rotateWindowStops, + rotateWindowAlphas, + 'beamMaskStops', + ); + }); + + test('smallMaskStops ↔ smallWindow*', () { + expectStopTable( + rotate['smallMaskStops'], + smallWindowStops, + smallWindowAlphas, + 'smallMaskStops', + ); + }); + + test('whiteGradientStops.dark ↔ rotateHighlight*Dark', () { + expectStopTable( + obj(rotate, 'whiteGradientStops')['dark'], + rotateHighlightStops, + rotateHighlightAlphasDark, + 'whiteGradient.dark', + ); + }); + + test('whiteGradientStops.light ↔ rotateHighlight*Light', () { + expectStopTable( + obj(rotate, 'whiteGradientStops')['light'], + rotateHighlightStops, + rotateHighlightAlphasLight, + 'whiteGradient.light', + ); + }); + + test('bloomGradientStops.dark ↔ rotateBloom*Dark', () { + expectStopTable( + obj(rotate, 'bloomGradientStops')['dark'], + rotateBloomStops, + rotateBloomAlphasDark, + 'bloomGradient.dark', + terminatedAt1: true, + ); + }); + + test('bloomGradientStops.light ↔ rotateBloom*Light', () { + expectStopTable( + obj(rotate, 'bloomGradientStops')['light'], + rotateBloomStops, + rotateBloomAlphasLight, + 'bloomGradient.light', + terminatedAt1: true, + ); + }); + }); + + group('rotate constants', () { + test('innerGradientDerivation ↔ rotateInnerBlob*', () { + final d = obj(rotate, 'innerGradientDerivation'); + expectNum(rotateInnerBlobScale, d['sizeScale'], 'inner sizeScale'); + expectNum(rotateInnerBlobAlpha, d['alpha'], 'inner alpha'); + expectNum(rotateInnerBlobAlphaMono, d['monoAlpha'], 'inner monoAlpha'); + }); + + test('innerShadowBlur ↔ rotate/small inner shadow blur', () { + final b = obj(rotate, 'innerShadowBlur'); + expectNum(rotateInnerShadowBlur, b['md'], 'innerShadowBlur.md'); + expectNum(smallInnerShadowBlur, b['sm'], 'innerShadowBlur.sm'); + }); + + test('bloomBlurPx ↔ rotateBloomBlurSigma', () { + // CSS `filter: blur(Npx)` maps to sigma = N. + expectNum(rotateBloomBlurSigma, rotate['bloomBlurPx'], 'bloomBlurPx'); + }); + + // GAP: rotate.innerEdgeMaskPx (28) and rotate.spin (0→360, linear) have no + // constant of their own — the feather is a default argument on + // `BeamGradients.vertical/horizontalEdgeFeather` and the spin is the + // rotate variant's angle = travelProgress × 2π. + }); + + // ─── line geometry ──────────────────────────────────────────────────────── + + group('line geometry', () { + test('beamMaskEllipse ↔ lineWindow*', () { + final e = obj(line, 'beamMaskEllipse'); + expectNum(lineWindowRadiusX, e['w'], 'beamMask w'); + expectNum(lineWindowRadiusY, e['h'], 'beamMask h'); + final soft = (e['softStop']! as List).cast(); + expectNum(lineWindowMidStop * 100, soft[0], 'beamMask soft stop'); + expectNum(lineWindowMidAlpha, soft[1], 'beamMask soft alpha'); + }); + + test('bloomMaskEllipse ↔ lineBloomWindow*', () { + final e = obj(line, 'bloomMaskEllipse'); + expectNum(lineBloomWindowRadiusX, e['w'], 'bloomMask w'); + expectNum(lineBloomWindowRadiusY, e['h'], 'bloomMask h'); + final soft = (e['softStop']! as List).cast(); + expectNum(lineBloomWindowMidStop * 100, soft[0], 'bloomMask soft stop'); + expectNum(lineBloomWindowMidAlpha, soft[1], 'bloomMask soft alpha'); + }); + + test('whiteHighlight.dark ↔ lineHighlight*Dark', () { + final h = obj(obj(line, 'whiteHighlight'), 'dark'); + expectNum(lineHighlightRadiusXDark, h['w'], 'highlight.dark w'); + expectNum(lineHighlightRadiusYDark, h['h'], 'highlight.dark h'); + expectNum(lineHighlightOffsetY, h['yOffset'], 'highlight.dark yOffset'); + expectStopTable( + h['stops'], + lineHighlightStopsDark, + lineHighlightAlphasDark, + 'highlight.dark stops', + ); + }); + + test('whiteHighlight.light ↔ lineHighlight*Light', () { + final h = obj(obj(line, 'whiteHighlight'), 'light'); + expectNum(lineHighlightRadiusXLight, h['w'], 'highlight.light w'); + expectNum(lineHighlightRadiusYLight, h['h'], 'highlight.light h'); + expectNum(lineHighlightOffsetY, h['yOffset'], 'highlight.light yOffset'); + expectStopTable( + h['stops'], + lineHighlightStopsLight, + lineHighlightAlphasLight, + 'highlight.light stops', + ); + // The light-theme highlight paints black, not white. + expect(h['onBlack'], isTrue); + }); + + test('bloom blur sigmas', () { + expectNum(lineBloomBlurSigma, line['bloomBlurPx'], 'line bloomBlurPx'); + // The spec calls this key `monoBloomExtraBlurPx`, but in `styles.ts` the + // mono branch REPLACES the animated bloom filter (`blur(8px) hue-rotate…`) + // with a bare `filter: blur(6px)` — it does not add to it. The value is + // the mono sigma, which is what we store. + expectNum( + lineBloomBlurSigmaMono, + line['monoBloomExtraBlurPx'], + 'line monoBloomExtraBlurPx', + ); + }); + + // GAP: no `line.innerShadowBlur` in the spec; `lineInnerShadowBlur` (9) + // is transcribed from `box-shadow: inset 0 0 9px 1px` in styles.ts. + }); + + // ─── line keyframes ─────────────────────────────────────────────────────── + + group('line keyframes', () { + final kf = obj(line, 'keyframes'); + + test('travel.x ↔ lineTravelX', () { + expectKeyframes(obj(kf, 'travel')['x'], lineTravelX, 'travel.x'); + }); + test('travel.w ↔ lineTravelW', () { + expectKeyframes(obj(kf, 'travel')['w'], lineTravelW, 'travel.w'); + }); + test('edgeFade ↔ lineEdgeFade', () { + expectKeyframes(kf['edgeFade'], lineEdgeFade, 'edgeFade'); + }); + test('breathe ↔ lineBreatheH', () { + expectKeyframes(kf['breathe'], lineBreatheH, 'breathe'); + }); + test('spike ↔ lineSpike', () { + expectKeyframes(kf['spike'], lineSpike, 'spike'); + }); + test('spike2 ↔ lineSpike2', () { + expectKeyframes(kf['spike2'], lineSpike2, 'spike2'); + }); + + test('durationScale ↔ BeamConfig breathe/spike factors', () { + final s = obj(kf, 'durationScale'); + final config = _configFor(BeamVariant.line); + expectNum(1, s['travel'], 'durationScale.travel'); + expectNum(1, s['edgeFade'], 'durationScale.edgeFade'); + expectNum(config.breatheFactor, s['breathe'], 'durationScale.breathe'); + expectNum(config.spikeFactor, s['spike'], 'durationScale.spike'); + expectNum(config.spike2Factor, s['spike2'], 'durationScale.spike2'); + }); + + test('easing per track', () { + final e = obj(kf, 'easing'); + // The travel/edge tracks sample linearly; breathe/spike ease per + // segment (`sampleKeyframes(..., easedSegments: true)`). + expect(e['travel'], 'linear'); + expect(e['edgeFade'], 'linear'); + expect(e['breathe'], 'easeInOut'); + expect(e['spike'], 'easeInOut'); + expect(e['spike2'], 'easeInOut'); + }); + }); + + // ─── line.bloomGradients — the expanded spike table ─────────────────────── + // + // The spec expands each palette × theme into 7 fixed spikes plus (dark) the + // traveling dot and ambient glow, or (light) the traveling shadow. Our port + // keeps the geometry in `line_geometry.dart` and the colors in the palette + // tables, with the mono attenuation applied at paint time — so the mono rows + // are compared against `ourAlpha × attenuation`. + + group('line.bloomGradients', () { + final grads = obj(line, 'bloomGradients'); + + /// Indices of the four thin (color-palette) spikes within the 7. + const thin = [0, 2, 4, 6]; + + for (final MapEntry(key: name, value: preset) in _presets.entries) { + final isMono = name == 'mono'; + for (final theme in const ['dark', 'light']) { + final isDark = theme == 'dark'; + + test('$name/$theme spike geometry', () { + final rows = arr( + obj(grads, name), + theme, + ).cast>(); + expect(lineSpikes, hasLength(7)); + for (var i = 0; i < 7; i++) { + final r = rows[i]; + final s = lineSpikes[i]; + expectNum(s.fx * 100, r['xPct'], '$name/$theme spike$i xPct'); + expectNum(-s.yInset, r['yOffPx'], '$name/$theme spike$i yOffPx'); + final stops = (r['stops']! as List).cast>(); + expect( + stops, + hasLength(3), + reason: '$name/$theme spike$i stop count', + ); + expectNum(0, stops[0]['pos'], '$name/$theme spike$i pos0'); + expectNum(s.midStop, stops[1]['pos'], '$name/$theme spike$i mid'); + expectNum(s.endStop, stops[2]['pos'], '$name/$theme spike$i end'); + expectNum(0, stops[2]['a'], '$name/$theme spike$i end alpha'); + + // Radii. + final w = obj(r, 'w')['base']! as num; + final h = obj(r, 'h')['base']! as num; + final t = thin.indexOf(i); + if (t >= 0) { + final wantW = isMono + ? (i == 6 && !isDark + ? lineMonoThinSpikeWidthLight92 + : lineMonoThinSpikeWidths[t]) + : (i == 6 && !isDark + ? lineThinSpikeWidthLight92 + : lineThinSpikeWidths[t]); + final wantH = isMono + ? lineMonoThinSpikeHeights[t] + : lineThinSpikeHeights[t]; + expectNum(wantW, w, '$name/$theme spike$i width'); + expectNum(wantH, h, '$name/$theme spike$i height'); + } else { + final wantW = switch (i) { + 1 => lineSpikeWideRadiusX22, + 3 => lineSpikeWideRadiusX50, + _ => lineSpikeRadiusX78, + }; + final wantH = switch (i) { + 1 => lineSpikeRadiusY22, + 3 => lineSpikeRadiusY50, + _ => lineSpikeRadiusY78, + }; + expectNum(wantW, w, '$name/$theme spike$i width'); + expectNum(wantH, h, '$name/$theme spike$i height'); + } + } + }); + + test('$name/$theme spike colors', () { + final rows = arr( + obj(grads, name), + theme, + ).cast>(); + final spikes = isDark ? preset.spike : preset.spikeLt; + final table = isDark ? preset.lineBloomDark : preset.lineBloomLight; + + List> stopsOf(int i) => + (rows[i]['stops']! as List).cast>(); + + // Spike 0 — the primary accent. + final s0 = stopsOf(0); + expectChannels( + spikes.primary, + stop: s0[0], + reason: '$name/$theme spike0 core', + alphaScale: isMono ? lineMonoSpike1 : 1, + ); + expectChannels( + spikes.primary, + stop: s0[1], + reason: '$name/$theme spike0 mid', + alphaScale: isMono + ? (isDark ? lineMonoSpike1MidDark : lineMonoSpike1MidLight) + : (isDark ? 1 : lineSpike1MidLightAlpha), + ); + + // Spike 1 — the secondary accent. + final s1 = stopsOf(1); + expectChannels( + spikes.secondary, + stop: s1[0], + reason: '$name/$theme spike1 core', + alphaScale: isMono ? lineMonoSpike2 : 1, + ); + expectChannels( + spikes.secondary, + stop: s1[1], + reason: '$name/$theme spike1 mid', + alphaScale: isMono + ? (isDark ? lineMonoSpike2MidDark : lineMonoSpike2MidLight) + : (isDark ? lineSpike2MidDarkAlpha : lineSpike2MidLightAlpha) / + spikes.secondary.a, + ); + + // Spikes 2–6 — the five palette bloom pairs. + for (var i = 2; i < 7; i++) { + final st = stopsOf(i); + final pair = table[i - 2]; + expectChannels( + pair.color1, + stop: st[0], + reason: '$name/$theme spike$i core', + alphaScale: isMono ? lineMonoTableSpike1 : 1, + ); + expectChannels( + pair.color2, + stop: st[1], + reason: '$name/$theme spike$i mid', + alphaScale: isMono ? lineMonoTableSpike2 : 1, + ); + } + }); + + test('$name/$theme traveling dot, ambient glow and shadow', () { + final rows = arr( + obj(grads, name), + theme, + ).cast>(); + if (isDark) { + expect( + rows, + hasLength(9), + reason: 'dark carries dot + ambient after the 7 spikes', + ); + final dot = rows[7]; + expectNum(lineDotOffsetY, dot['yOffPx'], '$name dot yOffPx'); + expectNum(lineDotRadiusX, obj(dot, 'w')['base'], '$name dot w'); + expectNum(lineDotRadiusY, obj(dot, 'h')['base'], '$name dot h'); + final dotStops = (dot['stops']! as List) + .cast>(); + final dotAlphas = isMono ? lineDotAlphasMono : lineDotAlphas; + for (var i = 0; i < lineDotStops.length; i++) { + expectNum(lineDotStops[i], dotStops[i]['pos'], '$name dot pos$i'); + expectNum( + i < dotAlphas.length ? dotAlphas[i] : 0, + dotStops[i]['a'], + '$name dot alpha$i', + ); + expectNum(255, dotStops[i]['r'], '$name dot is white'); + } + + final amb = rows[8]; + expectNum(0, amb['yOffPx'], '$name ambient yOffPx'); + expectNum(lineAmbientRadiusX, obj(amb, 'w')['base'], '$name amb w'); + expectNum(lineAmbientRadiusY, obj(amb, 'h')['base'], '$name amb h'); + final ambStops = (amb['stops']! as List) + .cast>(); + final ambAlphas = isMono + ? lineAmbientAlphasMono + : lineAmbientAlphas; + for (var i = 0; i < lineAmbientStops.length; i++) { + expectNum( + lineAmbientStops[i], + ambStops[i]['pos'], + '$name amb pos$i', + ); + expectNum( + i < ambAlphas.length ? ambAlphas[i] : 0, + ambStops[i]['a'], + '$name amb alpha$i', + ); + } + } else { + expect( + rows, + hasLength(8), + reason: 'light carries one shadow blob after the 7 spikes', + ); + final sh = rows[7]; + expectNum(0, sh['yOffPx'], '$name shadow yOffPx'); + expectNum( + lineShadowRadiusX, + obj(sh, 'w')['base'], + '$name shadow w', + ); + expectNum( + lineShadowRadiusY, + obj(sh, 'h')['base'], + '$name shadow h', + ); + final shStops = (sh['stops']! as List).cast>(); + for (var i = 0; i < lineShadowStops.length; i++) { + expectNum( + lineShadowStops[i], + shStops[i]['pos'], + '$name shadow pos$i', + ); + expectNum( + lineShadowAlphas[i], + shStops[i]['a'], + '$name shadow alpha$i', + ); + expectNum(0, shStops[i]['r'], '$name shadow is black'); + } + } + }); + } + } + }); + + // ─── pulse tables ───────────────────────────────────────────────────────── + + group('pulse tables', () { + test('ringMap ↔ pulseRingMap', () { + final rows = arr(pulse, 'ringMap').cast>(); + expect(pulseRingMap, hasLength(rows.length)); + for (var i = 0; i < rows.length; i++) { + expect( + pulseRingMap[i].region, + _regions[rows[i]['region']], + reason: 'ringMap[$i].region', + ); + expect( + pulseRingMap[i].quad, + _quads[rows[i]['quad']], + reason: 'ringMap[$i].quad', + ); + } + }); + + test('innerSizes ↔ pulseInnerSizes', () { + final rows = arr(pulse, 'innerSizes').cast>(); + expect(pulseInnerSizes, hasLength(rows.length)); + for (var i = 0; i < rows.length; i++) { + expectNum(pulseInnerSizes[i].width, rows[i][0], 'innerSizes[$i].w'); + expectNum(pulseInnerSizes[i].height, rows[i][1], 'innerSizes[$i].h'); + } + }); + + void checkSpecs( + String key, + List ours, { + required bool positioned, + }) { + final rows = arr(pulse, key).cast>(); + expect(ours, hasLength(rows.length), reason: '$key length'); + for (var i = 0; i < rows.length; i++) { + final r = rows[i]; + expect(ours[i].ci, r['ci'], reason: '$key[$i].ci'); + expect( + ours[i].region, + _regions[r['region']], + reason: '$key[$i].region', + ); + expect(ours[i].quad, _quads[r['quad']], reason: '$key[$i].quad'); + expectNum(ours[i].w, r['w'], '$key[$i].w'); + expectNum(ours[i].h, r['h'], '$key[$i].h'); + if (positioned) { + expect(ours[i].x, isNotNull, reason: '$key[$i].x'); + expect(ours[i].y, isNotNull, reason: '$key[$i].y'); + expect( + ours[i].x!, + closeTo(cssPercent(r['x']), _epsilon), + reason: '$key[$i].x', + ); + expect( + ours[i].y!, + closeTo(cssPercent(r['y']), _epsilon), + reason: '$key[$i].y', + ); + } else { + expect(r.containsKey('x'), isFalse, reason: '$key[$i] has no x'); + expect(ours[i].x, isNull, reason: '$key[$i].x inherits'); + expect(ours[i].y, isNull, reason: '$key[$i].y inherits'); + } + } + } + + test('innerBloom ↔ pulseInnerBloom', () { + checkSpecs('innerBloom', pulseInnerBloom, positioned: false); + }); + test('outerCore ↔ pulseOuterCore', () { + checkSpecs('outerCore', pulseOuterCore, positioned: true); + }); + test('outerBloom ↔ pulseOuterBloom', () { + checkSpecs('outerBloom', pulseOuterBloom, positioned: true); + }); + + test('innerCornerAccent ↔ pulseInnerCorner*', () { + final a = obj(pulse, 'innerCornerAccent'); + expectNum(pulseInnerCornerRadius, a['sizePx'], 'cornerAccent sizePx'); + expectNum( + pulseInnerCornerAlphaDark, + obj(a, 'alpha')['dark'], + 'cornerAccent alpha.dark', + ); + expectNum( + pulseInnerCornerAlphaLight, + obj(a, 'alpha')['light'], + 'cornerAccent alpha.light', + ); + expectNum( + pulseInnerCornerEndStop * 100, + a['fadeStop'], + 'cornerAccent fadeStop', + ); + }); + + test('innerBloomBlurPx ↔ pulseInnerBloomBlurSigma', () { + expectNum( + pulseInnerBloomBlurSigma, + pulse['innerBloomBlurPx'], + 'innerBloomBlurPx', + ); + }); + + test('oscillatorCurve is the cosine ping-pong we implement', () { + expect(pulse['oscillatorCurve'], 'cosinePingPong'); + expect(pingPong(0), closeTo(0, _epsilon)); + expect(pingPong(0.5), closeTo(1, _epsilon)); + expect(pingPong(1), closeTo(0, _epsilon)); + // Cosine is even, so negative phases (positive delays) are valid. + expect(pingPong(-0.25), closeTo(pingPong(0.25), _epsilon)); + }); + }); + + // ─── pulse params, oscillators and hue periods ──────────────────────────── + + group('pulse params', () { + const variants = { + 'inner': BeamVariant.pulseInside, + 'outside': BeamVariant.pulseOutside, + }; + + for (final MapEntry(key: key, value: variant) in variants.entries) { + for (final theme in const ['dark', 'light']) { + final brightness = theme == 'dark' ? Brightness.dark : Brightness.light; + + test('$key/$theme params ↔ PulseParams.resolve', () { + final s = obj(obj(obj(pulse, key), theme), 'params'); + // 2.3 is the source's pulse cycle duration, at which durScale == 1. + final p = PulseParams.resolve(variant, brightness, 2.3); + expectNum(p.sp, s['sp'], '$key/$theme sp'); + expectNum(p.dr, s['dr'], '$key/$theme dr'); + expectNum(p.op, s['op'], '$key/$theme op'); + expectNum(p.gh, s['gh'], '$key/$theme gh'); + expectNum(p.bs, s['bs'], '$key/$theme bs'); + expectNum(p.ss, s['ss'], '$key/$theme ss'); + expectNum(p.ghs, s['ghs'], '$key/$theme ghs'); + expectNum(p.huePeriod, s['huePeriod'], '$key/$theme huePeriod'); + }); + + test('$key/$theme oscillators ↔ PulseOscillatorBank', () { + final rows = arr( + obj(obj(pulse, key), theme), + 'oscillators', + ).cast>(); + final bank = PulseOscillatorBank( + PulseParams.resolve(variant, brightness, 2.3), + ); + expect( + bank.oscillators, + hasLength(rows.length), + reason: '$key/$theme oscillator count', + ); + expect( + bank.oscillators.keys.toList(), + rows.map((r) => r['prop']).toList(), + reason: '$key/$theme oscillator order', + ); + for (final r in rows) { + final prop = r['prop']! as String; + final o = bank.oscillators[prop]!; + expectNum(o.a, r['a'], '$key/$theme $prop.a'); + expectNum(o.b, r['b'], '$key/$theme $prop.b'); + expectNum(o.period, r['period'], '$key/$theme $prop.period'); + expectNum(o.delay, r['delay'], '$key/$theme $prop.delay'); + expect( + r['unit'], + prop.startsWith('bx') || prop.startsWith('by') ? 'px' : '', + reason: '$key/$theme $prop.unit', + ); + } + }); + + test('$key/$theme frozenBloomAlpha ↔ 1 − op/2', () { + final s = obj(obj(pulse, key), theme); + final p = PulseParams.resolve(variant, brightness, 2.3); + expectNum( + 1 - p.op * 0.5, + s['frozenBloomAlpha'], + '$key/$theme frozenBloomAlpha', + ); + }); + } + } + + test('huePeriod ↔ BeamVariant.defaultHuePeriod', () { + final h = obj(pulse, 'huePeriod'); + expectNum( + BeamVariant.pulseInside.defaultHuePeriod.inMilliseconds / 1000, + h['pulse-inner'], + 'pulse-inner huePeriod', + ); + expectNum( + BeamVariant.pulseOutside.defaultHuePeriod.inMilliseconds / 1000, + h['pulse-outside'], + 'pulse-outside huePeriod', + ); + }); + }); + + // ─── pulse.outsideConstants ─────────────────────────────────────────────── + + group('pulse.outsideConstants', () { + // Only the VERBATIM half of `pulse_constants.dart` maps here. The blur and + // inset values below them in that file are the source demo page's tuned + // `.beam-host--pulse-outside-tuned` recipe, which this port bakes in — the + // spec records the library's untuned defaults instead, so they are + // deliberately not compared. See the file header. + final c = obj(pulse, 'outsideConstants'); + + test('glowScale ↔ pulseOuterScaleX/Y', () { + expectNum(pulseOuterScaleX, obj(c, 'glowScale')['x'], 'glowScale.x'); + expectNum(pulseOuterScaleY, obj(c, 'glowScale')['y'], 'glowScale.y'); + }); + + test('referenceSize ↔ pulseOuterReference*', () { + expectNum( + pulseOuterReferenceWidth, + obj(c, 'referenceSize')['w'], + 'referenceSize.w', + ); + expectNum( + pulseOuterReferenceHeight, + obj(c, 'referenceSize')['h'], + 'referenceSize.h', + ); + }); + + test('scaleClamp ↔ pulseOuterMin/MaxScale', () { + expectNum(pulseOuterMinScale, obj(c, 'scaleClamp')['min'], 'clamp.min'); + expectNum(pulseOuterMaxScale, obj(c, 'scaleClamp')['max'], 'clamp.max'); + }); + + // GAPS in pulse_constants.dart, all deliberate: + // * glowBlurPx (3/6), bloomBlurPx (22.5/15), coreInsetPx (10), + // bloomInsetPx (30) — the library's untuned defaults; the port ships + // the demo recipe (6/14 insets, 10/19 unit-scaled blurs) instead, all + // overridable through coreBlur/bloomBlur. + // * hairline.rgb (dark 70,70,70 / light 0,0,0) — lives inline in + // `PulseOuterStrategy.paintAbove`, not in a constant. + // * pulseOuterGlowUnitDamping (0.7) has no spec counterpart. + }); + + // ─── defaults ───────────────────────────────────────────────────────────── + + group('defaults', () { + test('cycle durations ↔ BeamVariant.defaultCycleDuration', () { + final d = obj(defaults, 'duration'); + double secs(BeamVariant v) => + v.defaultCycleDuration.inMicroseconds / 1000000; + expectNum(secs(BeamVariant.rotate), d['rotate'], 'rotate duration'); + expectNum(secs(BeamVariant.small), d['rotate'], 'small duration'); + expectNum(secs(BeamVariant.line), d['line'], 'line duration'); + expectNum(secs(BeamVariant.pulseInside), d['pulse'], 'pulseInside'); + expectNum(secs(BeamVariant.pulseOutside), d['pulse'], 'pulseOutside'); + }); + + test('hueRange and the line cap ↔ BeamConfig.resolve', () { + expectNum( + _configFor(BeamVariant.rotate).hueRange, + defaults['hueRange'], + 'default hueRange', + ); + // The line variant caps the range; a range under the cap passes through. + expectNum( + _configFor(BeamVariant.line).hueRange, + defaults['lineHueRangeCap'], + 'line hueRange cap', + ); + expect(_configFor(BeamVariant.line, hueRange: 5).hueRange, 5.0); + }); + + test('fade seconds ↔ BeamClock', () { + expectNum(BeamClock.fadeInSeconds, defaults['fadeInSeconds'], 'fadeIn'); + expectNum( + BeamClock.fadeOutSeconds, + defaults['fadeOutSeconds'], + 'fadeOut', + ); + }); + + test('strength and brightness fallback ↔ BeamConfig.resolve', () { + expectNum( + _configFor(BeamVariant.rotate).strength, + defaults['strength'], + 'default strength', + ); + // `md` has no `brightness` in its theme preset, so the fallback shows. + expect( + themePresetFor(BeamVariant.rotate, Brightness.dark).brightness, + isNull, + ); + expectNum( + _configFor(BeamVariant.rotate).brightnessFactor, + defaults['brightnessFallback'], + 'brightness fallback', + ); + }); + + test('mono treatment ↔ BeamColors.mono', () { + final mono = BeamColors.mono.resolve(); + expectNum( + mono.opacityMultiplier, + defaults['monoOpacityMultiplier'], + 'monoOpacityMultiplier', + ); + expect(mono.forcesStaticColors, defaults['monoForcesStaticColors']); + expect(mono.monoTreatment, isTrue); + }); + + test('hue-shift periods ↔ BeamVariant defaults', () { + double secs(Duration d) => d.inMicroseconds / 1000000; + for (final v in [ + BeamVariant.rotate, + BeamVariant.small, + BeamVariant.line, + ]) { + expectNum( + secs(v.defaultHuePeriod), + defaults['rotateHueShiftPeriod'], + '$v huePeriod', + ); + } + expectNum( + secs(BeamVariant.line.defaultBloomHuePeriod), + defaults['lineBloomHueShiftPeriod'], + 'line bloom huePeriod', + ); + }); + + test('line bloom hue range carries the +10° bonus', () { + final config = _configFor(BeamVariant.line); + final resolver = BeamPhaseResolver(config); + // `_pingPongHue` reaches −range at t=0 and +range at half a period. + final low = resolver.sample(0, 1).bloomHueDegrees; + final high = resolver + .sample(config.bloomHuePeriodSeconds / 2, 1) + .bloomHueDegrees; + expectNum( + (high - low) / 2 - config.hueRange, + defaults['lineBloomHueRangeBonus'], + 'lineBloomHueRangeBonus', + ); + }); + + test('the spec\'s declared defaults name our built-ins', () { + expect(defaults['size'], 'md'); + expect(defaults['colorVariant'], 'colorful'); + expect(defaults['theme'], 'dark'); + expect(defaults['fadeEasing'], 'ease'); + }); + + // GAP: `defaults.pulseDriverFps` (30) is the source's pulse-driver tick + // rate; ours is BeamClock's optional fps cap, not a constant. + // GAP: `filters.order` / `filters.hueRotateModel` describe the CSS filter + // pipeline our `BeamColorMatrix` implements; no constant mirrors them. + }); + + // ─── enums ──────────────────────────────────────────────────────────────── + + group('enums', () { + test('sizes ↔ BeamVariant', () { + expect( + arr(obj(spec, 'enums'), 'sizes').cast().toSet(), + _sizes.keys.toSet(), + ); + expect(_sizes.values.toSet(), BeamVariant.values.toSet()); + }); + + test('colorVariants ↔ the four transcribed presets', () { + expect( + arr(obj(spec, 'enums'), 'colorVariants').cast().toSet(), + _presets.keys.toSet(), + ); + }); + + test('themes ↔ Brightness', () { + expect(arr(obj(spec, 'enums'), 'themes').cast(), [ + 'dark', + 'light', + ]); + }); + }); +} + +/// A resolved config for [variant] at the source's defaults. +BeamConfig _configFor(BeamVariant variant, {double? hueRange}) => + BeamConfig.resolve( + variant: variant, + palette: const BeamPalette(data: colorfulPreset), + brightness: Brightness.dark, + style: hueRange == null + ? const BeamStyle() + : BeamStyle(hueRange: hueRange), + ); diff --git a/test/fixtures/UPSTREAM b/test/fixtures/UPSTREAM new file mode 100644 index 0000000..e752dc6 --- /dev/null +++ b/test/fixtures/UPSTREAM @@ -0,0 +1,5 @@ +commit=3862ffa345217443b63696a8c331a0664eea4b04 +version=1.3.0 +package_version=1.4.0 +spec_version=1.0.0 +styles_sha256=547f7c91c4a33eade75dd00869a2bd49d5dac4ddc95be841b03e35f64a0fd344 diff --git a/test/fixtures/beam-spec.json b/test/fixtures/beam-spec.json new file mode 100644 index 0000000..85865ba --- /dev/null +++ b/test/fixtures/beam-spec.json @@ -0,0 +1,5731 @@ +{ + "specVersion": "1.0.0", + "sourceLibrary": { + "name": "border-beam", + "version": "1.3.0" + }, + "generated": "2026-07-27", + "reference": "Web demo at https://beam.jakubantalik.com is the visual ground truth.", + "enums": { + "sizes": [ + "sm", + "md", + "line", + "pulse-outside", + "pulse-inner" + ], + "colorVariants": [ + "colorful", + "mono", + "ocean", + "sunset" + ], + "themes": [ + "dark", + "light" + ] + }, + "defaults": { + "size": "md", + "colorVariant": "colorful", + "theme": "dark", + "duration": { + "line": 3.1, + "pulse": 2.3, + "rotate": 1.96 + }, + "hueRange": 30, + "lineHueRangeCap": 13, + "strength": 1, + "brightnessFallback": 1.3, + "fadeInSeconds": 0.6, + "fadeOutSeconds": 0.5, + "fadeEasing": "ease", + "rotateHueShiftPeriod": 12, + "lineBloomHueShiftPeriod": 8, + "lineBloomHueRangeBonus": 10, + "monoOpacityMultiplier": 0.5, + "monoForcesStaticColors": true, + "pulseDriverFps": 30 + }, + "sizePresets": { + "sm": { + "borderRadius": 32, + "borderWidth": 1, + "width": 70, + "height": 36 + }, + "md": { + "borderRadius": 16, + "borderWidth": 1 + }, + "line": { + "borderRadius": 16, + "borderWidth": 1 + }, + "pulse-outside": { + "borderRadius": 16, + "borderWidth": 1 + }, + "pulse-inner": { + "borderRadius": 16, + "borderWidth": 1 + } + }, + "sizeThemePresets": { + "sm": { + "dark": { + "strokeOpacity": 0.46, + "innerOpacity": 0.24, + "bloomOpacity": 0.38, + "innerShadow": "rgba(255, 255, 255, 0.3)", + "saturation": 1.2 + }, + "light": { + "strokeOpacity": 0.12, + "innerOpacity": 0.3, + "bloomOpacity": 0.16, + "innerShadow": "rgba(0, 0, 0, 0.14)", + "saturation": 1.8 + } + }, + "md": { + "dark": { + "strokeOpacity": 0.26, + "innerOpacity": 0.42, + "bloomOpacity": 0.24, + "innerShadow": "rgba(255, 255, 255, 0.27)", + "saturation": 1.2 + }, + "light": { + "strokeOpacity": 0.12, + "innerOpacity": 0.26, + "bloomOpacity": 0.34, + "innerShadow": "rgba(0, 0, 0, 0.14)", + "saturation": 1.5 + } + }, + "line": { + "dark": { + "strokeOpacity": 1.14, + "innerOpacity": 0.7, + "bloomOpacity": 0.8, + "innerShadow": "rgba(255, 255, 255, 0.1)", + "saturation": 1.2 + }, + "light": { + "strokeOpacity": 0.16, + "innerOpacity": 0.32, + "bloomOpacity": 0.3, + "innerShadow": "rgba(0, 0, 0, 0.14)", + "saturation": 1.95 + } + }, + "pulse-outside": { + "dark": { + "strokeOpacity": 0.94, + "innerOpacity": 0.34, + "bloomOpacity": 0.3, + "innerShadow": "transparent", + "saturation": 1.2, + "brightness": 1.9, + "hairlineOpacity": 0 + }, + "light": { + "strokeOpacity": 1.96, + "innerOpacity": 1.04, + "bloomOpacity": 0.42, + "innerShadow": "transparent", + "saturation": 0.6, + "brightness": 1.7, + "hairlineOpacity": 0 + } + }, + "pulse-inner": { + "dark": { + "strokeOpacity": 1.54, + "innerOpacity": 0.44, + "bloomOpacity": 0.66, + "innerShadow": "transparent", + "saturation": 1.2, + "brightness": 0.75 + }, + "light": { + "strokeOpacity": 0.32, + "innerOpacity": 0.4, + "bloomOpacity": 0.8, + "innerShadow": "transparent", + "saturation": 0.75, + "brightness": 1.3 + } + } + }, + "palettes": { + "border": { + "colorful": { + "border": [ + { + "color": "rgb(255, 50, 100)", + "pos": "33% -7.4%", + "size": "70px 40px" + }, + { + "color": "rgb(40, 140, 255)", + "pos": "12% -5%", + "size": "60px 35px" + }, + { + "color": "rgb(50, 200, 80)", + "pos": "2.1% 68.3%", + "size": "40px 70px" + }, + { + "color": "rgb(30, 185, 170)", + "pos": "2.1% 68.3%", + "size": "20px 35px" + }, + { + "color": "rgb(100, 70, 255)", + "pos": "74.4% 100%", + "size": "180px 32px" + }, + { + "color": "rgb(40, 140, 255)", + "pos": "55% 100%", + "size": "85px 26px" + }, + { + "color": "rgb(255, 120, 40)", + "pos": "93.9% 0%", + "size": "74px 32px" + }, + { + "color": "rgb(240, 50, 180)", + "pos": "100% 27.1%", + "size": "26px 42px" + }, + { + "color": "rgb(180, 40, 240)", + "pos": "100% 27.1%", + "size": "52px 48px" + } + ], + "spike": { + "primary": "rgb(255, 60, 80)", + "secondary": "rgba(40, 190, 180, 0.98)" + }, + "spikeLt": { + "primary": "rgb(200, 30, 60)", + "secondary": "rgb(20, 150, 140)" + } + }, + "mono": { + "border": [ + { + "color": "rgb(180, 180, 180)", + "pos": "33% -7.4%", + "size": "70px 40px" + }, + { + "color": "rgb(140, 140, 140)", + "pos": "12% -5%", + "size": "60px 35px" + }, + { + "color": "rgb(160, 160, 160)", + "pos": "2.1% 68.3%", + "size": "40px 70px" + }, + { + "color": "rgb(130, 130, 130)", + "pos": "2.1% 68.3%", + "size": "20px 35px" + }, + { + "color": "rgb(170, 170, 170)", + "pos": "74.4% 100%", + "size": "180px 32px" + }, + { + "color": "rgb(150, 150, 150)", + "pos": "55% 100%", + "size": "85px 26px" + }, + { + "color": "rgb(190, 190, 190)", + "pos": "93.9% 0%", + "size": "74px 32px" + }, + { + "color": "rgb(145, 145, 145)", + "pos": "100% 27.1%", + "size": "26px 42px" + }, + { + "color": "rgb(165, 165, 165)", + "pos": "100% 27.1%", + "size": "52px 48px" + } + ], + "spike": { + "primary": "rgb(200, 200, 200)", + "secondary": "rgb(170, 170, 170)" + }, + "spikeLt": { + "primary": "rgb(80, 80, 80)", + "secondary": "rgb(120, 120, 120)" + } + }, + "ocean": { + "border": [ + { + "color": "rgb(100, 80, 220)", + "pos": "33% -7.4%", + "size": "70px 40px" + }, + { + "color": "rgb(60, 120, 255)", + "pos": "12% -5%", + "size": "60px 35px" + }, + { + "color": "rgb(80, 100, 200)", + "pos": "2.1% 68.3%", + "size": "40px 70px" + }, + { + "color": "rgb(50, 140, 220)", + "pos": "2.1% 68.3%", + "size": "20px 35px" + }, + { + "color": "rgb(120, 80, 255)", + "pos": "74.4% 100%", + "size": "180px 32px" + }, + { + "color": "rgb(70, 130, 255)", + "pos": "55% 100%", + "size": "85px 26px" + }, + { + "color": "rgb(140, 100, 240)", + "pos": "93.9% 0%", + "size": "74px 32px" + }, + { + "color": "rgb(90, 110, 230)", + "pos": "100% 27.1%", + "size": "26px 42px" + }, + { + "color": "rgb(130, 70, 255)", + "pos": "100% 27.1%", + "size": "52px 48px" + } + ], + "spike": { + "primary": "rgb(100, 120, 255)", + "secondary": "rgba(130, 100, 220, 0.98)" + }, + "spikeLt": { + "primary": "rgb(60, 60, 180)", + "secondary": "rgb(80, 100, 200)" + } + }, + "sunset": { + "border": [ + { + "color": "rgb(255, 80, 50)", + "pos": "33% -7.4%", + "size": "70px 40px" + }, + { + "color": "rgb(255, 160, 40)", + "pos": "12% -5%", + "size": "60px 35px" + }, + { + "color": "rgb(255, 120, 60)", + "pos": "2.1% 68.3%", + "size": "40px 70px" + }, + { + "color": "rgb(255, 200, 50)", + "pos": "2.1% 68.3%", + "size": "20px 35px" + }, + { + "color": "rgb(255, 100, 80)", + "pos": "74.4% 100%", + "size": "180px 32px" + }, + { + "color": "rgb(255, 180, 60)", + "pos": "55% 100%", + "size": "85px 26px" + }, + { + "color": "rgb(255, 60, 60)", + "pos": "93.9% 0%", + "size": "74px 32px" + }, + { + "color": "rgb(255, 140, 50)", + "pos": "100% 27.1%", + "size": "26px 42px" + }, + { + "color": "rgb(255, 90, 70)", + "pos": "100% 27.1%", + "size": "52px 48px" + } + ], + "spike": { + "primary": "rgb(255, 140, 80)", + "secondary": "rgba(255, 100, 60, 0.98)" + }, + "spikeLt": { + "primary": "rgb(200, 80, 40)", + "secondary": "rgb(220, 120, 30)" + } + } + }, + "small": { + "colorful": { + "border": [ + { + "color": "rgb(50, 200, 80)", + "pos": "2% 68%", + "size": "9px 18px" + }, + { + "color": "rgb(30, 185, 170)", + "pos": "2% 68%", + "size": "4px 8px" + }, + { + "color": "rgb(255, 120, 40)", + "pos": "72% -3%", + "size": "59px 9px" + }, + { + "color": "rgb(100, 70, 255)", + "pos": "74% 100%", + "size": "42px 7px" + }, + { + "color": "rgb(240, 50, 180)", + "pos": "100% 27%", + "size": "10px 17px" + }, + { + "color": "rgb(180, 40, 240)", + "pos": "100% 27%", + "size": "10px 18px" + }, + { + "color": "rgb(40, 140, 255)", + "pos": "100% 27%", + "size": "5px 10px" + }, + { + "color": "rgb(255, 50, 100)", + "pos": "100% 27%", + "size": "11px 12px" + } + ], + "inner": [ + { + "color": "rgba(50, 200, 80, 0.5)", + "pos": "2% 68%", + "size": "9px 18px" + }, + { + "color": "rgba(30, 185, 170, 0.45)", + "pos": "2% 68%", + "size": "4px 8px" + }, + { + "color": "rgba(255, 120, 40, 0.35)", + "pos": "72% -3%", + "size": "59px 9px" + }, + { + "color": "rgba(100, 70, 255, 0.35)", + "pos": "74% 100%", + "size": "42px 7px" + }, + { + "color": "rgba(240, 50, 180, 0.3)", + "pos": "100% 27%", + "size": "10px 17px" + }, + { + "color": "rgba(180, 40, 240, 0.4)", + "pos": "100% 27%", + "size": "10px 18px" + }, + { + "color": "rgba(40, 140, 255, 0.3)", + "pos": "100% 27%", + "size": "5px 10px" + }, + { + "color": "rgba(255, 50, 100, 0.3)", + "pos": "100% 27%", + "size": "11px 12px" + } + ] + }, + "mono": { + "border": [ + { + "color": "rgb(160, 160, 160)", + "pos": "2% 68%", + "size": "9px 18px" + }, + { + "color": "rgb(140, 140, 140)", + "pos": "2% 68%", + "size": "4px 8px" + }, + { + "color": "rgb(180, 180, 180)", + "pos": "72% -3%", + "size": "59px 9px" + }, + { + "color": "rgb(150, 150, 150)", + "pos": "74% 100%", + "size": "42px 7px" + }, + { + "color": "rgb(170, 170, 170)", + "pos": "100% 27%", + "size": "10px 17px" + }, + { + "color": "rgb(155, 155, 155)", + "pos": "100% 27%", + "size": "10px 18px" + }, + { + "color": "rgb(145, 145, 145)", + "pos": "100% 27%", + "size": "5px 10px" + }, + { + "color": "rgb(165, 165, 165)", + "pos": "100% 27%", + "size": "11px 12px" + } + ], + "inner": [ + { + "color": "rgba(160, 160, 160, 0.25)", + "pos": "2% 68%", + "size": "9px 18px" + }, + { + "color": "rgba(140, 140, 140, 0.22)", + "pos": "2% 68%", + "size": "4px 8px" + }, + { + "color": "rgba(180, 180, 180, 0.17)", + "pos": "72% -3%", + "size": "59px 9px" + }, + { + "color": "rgba(150, 150, 150, 0.17)", + "pos": "74% 100%", + "size": "42px 7px" + }, + { + "color": "rgba(170, 170, 170, 0.15)", + "pos": "100% 27%", + "size": "10px 17px" + }, + { + "color": "rgba(155, 155, 155, 0.20)", + "pos": "100% 27%", + "size": "10px 18px" + }, + { + "color": "rgba(145, 145, 145, 0.15)", + "pos": "100% 27%", + "size": "5px 10px" + }, + { + "color": "rgba(165, 165, 165, 0.15)", + "pos": "100% 27%", + "size": "11px 12px" + } + ] + }, + "ocean": { + "border": [ + { + "color": "rgb(60, 140, 200)", + "pos": "2% 68%", + "size": "9px 18px" + }, + { + "color": "rgb(50, 120, 180)", + "pos": "2% 68%", + "size": "4px 8px" + }, + { + "color": "rgb(100, 80, 220)", + "pos": "72% -3%", + "size": "59px 9px" + }, + { + "color": "rgb(80, 100, 255)", + "pos": "74% 100%", + "size": "42px 7px" + }, + { + "color": "rgb(120, 70, 240)", + "pos": "100% 27%", + "size": "10px 17px" + }, + { + "color": "rgb(90, 80, 220)", + "pos": "100% 27%", + "size": "10px 18px" + }, + { + "color": "rgb(70, 110, 255)", + "pos": "100% 27%", + "size": "5px 10px" + }, + { + "color": "rgb(110, 90, 230)", + "pos": "100% 27%", + "size": "11px 12px" + } + ], + "inner": [ + { + "color": "rgba(60, 140, 200, 0.5)", + "pos": "2% 68%", + "size": "9px 18px" + }, + { + "color": "rgba(50, 120, 180, 0.45)", + "pos": "2% 68%", + "size": "4px 8px" + }, + { + "color": "rgba(100, 80, 220, 0.35)", + "pos": "72% -3%", + "size": "59px 9px" + }, + { + "color": "rgba(80, 100, 255, 0.35)", + "pos": "74% 100%", + "size": "42px 7px" + }, + { + "color": "rgba(120, 70, 240, 0.3)", + "pos": "100% 27%", + "size": "10px 17px" + }, + { + "color": "rgba(90, 80, 220, 0.4)", + "pos": "100% 27%", + "size": "10px 18px" + }, + { + "color": "rgba(70, 110, 255, 0.3)", + "pos": "100% 27%", + "size": "5px 10px" + }, + { + "color": "rgba(110, 90, 230, 0.3)", + "pos": "100% 27%", + "size": "11px 12px" + } + ] + }, + "sunset": { + "border": [ + { + "color": "rgb(255, 180, 50)", + "pos": "2% 68%", + "size": "9px 18px" + }, + { + "color": "rgb(255, 150, 40)", + "pos": "2% 68%", + "size": "4px 8px" + }, + { + "color": "rgb(255, 80, 60)", + "pos": "72% -3%", + "size": "59px 9px" + }, + { + "color": "rgb(255, 100, 80)", + "pos": "74% 100%", + "size": "42px 7px" + }, + { + "color": "rgb(255, 60, 80)", + "pos": "100% 27%", + "size": "10px 17px" + }, + { + "color": "rgb(255, 120, 60)", + "pos": "100% 27%", + "size": "10px 18px" + }, + { + "color": "rgb(255, 200, 50)", + "pos": "100% 27%", + "size": "5px 10px" + }, + { + "color": "rgb(255, 90, 70)", + "pos": "100% 27%", + "size": "11px 12px" + } + ], + "inner": [ + { + "color": "rgba(255, 180, 50, 0.5)", + "pos": "2% 68%", + "size": "9px 18px" + }, + { + "color": "rgba(255, 150, 40, 0.45)", + "pos": "2% 68%", + "size": "4px 8px" + }, + { + "color": "rgba(255, 80, 60, 0.35)", + "pos": "72% -3%", + "size": "59px 9px" + }, + { + "color": "rgba(255, 100, 80, 0.35)", + "pos": "74% 100%", + "size": "42px 7px" + }, + { + "color": "rgba(255, 60, 80, 0.3)", + "pos": "100% 27%", + "size": "10px 17px" + }, + { + "color": "rgba(255, 120, 60, 0.4)", + "pos": "100% 27%", + "size": "10px 18px" + }, + { + "color": "rgba(255, 200, 50, 0.3)", + "pos": "100% 27%", + "size": "5px 10px" + }, + { + "color": "rgba(255, 90, 70, 0.3)", + "pos": "100% 27%", + "size": "11px 12px" + } + ] + } + }, + "line": { + "colorful": { + "dark": [ + { + "color": "rgb(255, 50, 100)", + "sizeW": 36, + "sizeH": 36, + "offsetX": 0, + "offsetY": 2 + }, + { + "color": "rgb(40, 180, 220)", + "sizeW": 30, + "sizeH": 32, + "offsetX": 39, + "offsetY": 0 + }, + { + "color": "rgb(50, 200, 80)", + "sizeW": 33, + "sizeH": 28, + "offsetX": -36, + "offsetY": 2 + }, + { + "color": "rgb(180, 40, 240)", + "sizeW": 29, + "sizeH": 34, + "offsetX": -54, + "offsetY": 0 + }, + { + "color": "rgb(255, 160, 30)", + "sizeW": 27, + "sizeH": 30, + "offsetX": 51, + "offsetY": -1 + }, + { + "color": "rgb(100, 70, 255)", + "sizeW": 36, + "sizeH": 24, + "offsetX": 21, + "offsetY": 1 + }, + { + "color": "rgb(40, 140, 255)", + "sizeW": 30, + "sizeH": 22, + "offsetX": -21, + "offsetY": 0 + }, + { + "color": "rgb(240, 50, 180)", + "sizeW": 25, + "sizeH": 28, + "offsetX": 66, + "offsetY": 1 + }, + { + "color": "rgb(30, 185, 170)", + "sizeW": 23, + "sizeH": 30, + "offsetX": -66, + "offsetY": -1 + } + ], + "light": [ + { + "color": "rgb(255, 50, 100)", + "sizeW": 45, + "sizeH": 36, + "offsetX": 0, + "offsetY": 2 + }, + { + "color": "rgb(40, 140, 255)", + "sizeW": 35, + "sizeH": 32, + "offsetX": 65, + "offsetY": 0 + }, + { + "color": "rgb(50, 200, 80)", + "sizeW": 40, + "sizeH": 28, + "offsetX": -60, + "offsetY": 2 + }, + { + "color": "rgb(180, 40, 240)", + "sizeW": 35, + "sizeH": 34, + "offsetX": -90, + "offsetY": 0 + }, + { + "color": "rgb(30, 185, 170)", + "sizeW": 38, + "sizeH": 30, + "offsetX": 85, + "offsetY": -1 + }, + { + "color": "rgb(100, 70, 255)", + "sizeW": 50, + "sizeH": 24, + "offsetX": 35, + "offsetY": 1 + }, + { + "color": "rgb(40, 140, 255)", + "sizeW": 40, + "sizeH": 22, + "offsetX": -35, + "offsetY": 0 + }, + { + "color": "rgb(255, 120, 40)", + "sizeW": 35, + "sizeH": 28, + "offsetX": 110, + "offsetY": 1 + }, + { + "color": "rgb(240, 50, 180)", + "sizeW": 30, + "sizeH": 30, + "offsetX": -110, + "offsetY": -1 + } + ] + }, + "mono": { + "dark": [ + { + "color": "rgb(200, 200, 200)", + "sizeW": 36, + "sizeH": 36, + "offsetX": 0, + "offsetY": 2 + }, + { + "color": "rgb(170, 170, 170)", + "sizeW": 30, + "sizeH": 32, + "offsetX": 39, + "offsetY": 0 + }, + { + "color": "rgb(155, 155, 155)", + "sizeW": 33, + "sizeH": 28, + "offsetX": -36, + "offsetY": 2 + }, + { + "color": "rgb(185, 185, 185)", + "sizeW": 29, + "sizeH": 34, + "offsetX": -54, + "offsetY": 0 + }, + { + "color": "rgb(165, 165, 165)", + "sizeW": 27, + "sizeH": 30, + "offsetX": 51, + "offsetY": -1 + }, + { + "color": "rgb(180, 180, 180)", + "sizeW": 36, + "sizeH": 24, + "offsetX": 21, + "offsetY": 1 + }, + { + "color": "rgb(160, 160, 160)", + "sizeW": 30, + "sizeH": 22, + "offsetX": -21, + "offsetY": 0 + }, + { + "color": "rgb(175, 175, 175)", + "sizeW": 25, + "sizeH": 28, + "offsetX": 66, + "offsetY": 1 + }, + { + "color": "rgb(190, 190, 190)", + "sizeW": 23, + "sizeH": 30, + "offsetX": -66, + "offsetY": -1 + } + ], + "light": [ + { + "color": "rgb(100, 100, 100)", + "sizeW": 45, + "sizeH": 36, + "offsetX": 0, + "offsetY": 2 + }, + { + "color": "rgb(80, 80, 80)", + "sizeW": 35, + "sizeH": 32, + "offsetX": 65, + "offsetY": 0 + }, + { + "color": "rgb(90, 90, 90)", + "sizeW": 40, + "sizeH": 28, + "offsetX": -60, + "offsetY": 2 + }, + { + "color": "rgb(70, 70, 70)", + "sizeW": 35, + "sizeH": 34, + "offsetX": -90, + "offsetY": 0 + }, + { + "color": "rgb(85, 85, 85)", + "sizeW": 38, + "sizeH": 30, + "offsetX": 85, + "offsetY": -1 + }, + { + "color": "rgb(95, 95, 95)", + "sizeW": 50, + "sizeH": 24, + "offsetX": 35, + "offsetY": 1 + }, + { + "color": "rgb(75, 75, 75)", + "sizeW": 40, + "sizeH": 22, + "offsetX": -35, + "offsetY": 0 + }, + { + "color": "rgb(105, 105, 105)", + "sizeW": 35, + "sizeH": 28, + "offsetX": 110, + "offsetY": 1 + }, + { + "color": "rgb(65, 65, 65)", + "sizeW": 30, + "sizeH": 30, + "offsetX": -110, + "offsetY": -1 + } + ] + }, + "ocean": { + "dark": [ + { + "color": "rgb(100, 80, 220)", + "sizeW": 36, + "sizeH": 36, + "offsetX": 0, + "offsetY": 2 + }, + { + "color": "rgb(60, 120, 255)", + "sizeW": 30, + "sizeH": 32, + "offsetX": 39, + "offsetY": 0 + }, + { + "color": "rgb(80, 100, 200)", + "sizeW": 33, + "sizeH": 28, + "offsetX": -36, + "offsetY": 2 + }, + { + "color": "rgb(130, 70, 255)", + "sizeW": 29, + "sizeH": 34, + "offsetX": -54, + "offsetY": 0 + }, + { + "color": "rgb(70, 130, 255)", + "sizeW": 27, + "sizeH": 30, + "offsetX": 51, + "offsetY": -1 + }, + { + "color": "rgb(120, 80, 255)", + "sizeW": 36, + "sizeH": 24, + "offsetX": 21, + "offsetY": 1 + }, + { + "color": "rgb(90, 110, 230)", + "sizeW": 30, + "sizeH": 22, + "offsetX": -21, + "offsetY": 0 + }, + { + "color": "rgb(110, 90, 240)", + "sizeW": 25, + "sizeH": 28, + "offsetX": 66, + "offsetY": 1 + }, + { + "color": "rgb(140, 100, 255)", + "sizeW": 23, + "sizeH": 30, + "offsetX": -66, + "offsetY": -1 + } + ], + "light": [ + { + "color": "rgb(80, 60, 200)", + "sizeW": 45, + "sizeH": 36, + "offsetX": 0, + "offsetY": 2 + }, + { + "color": "rgb(50, 100, 220)", + "sizeW": 35, + "sizeH": 32, + "offsetX": 65, + "offsetY": 0 + }, + { + "color": "rgb(70, 90, 190)", + "sizeW": 40, + "sizeH": 28, + "offsetX": -60, + "offsetY": 2 + }, + { + "color": "rgb(110, 60, 220)", + "sizeW": 35, + "sizeH": 34, + "offsetX": -90, + "offsetY": 0 + }, + { + "color": "rgb(60, 110, 230)", + "sizeW": 38, + "sizeH": 30, + "offsetX": 85, + "offsetY": -1 + }, + { + "color": "rgb(100, 70, 240)", + "sizeW": 50, + "sizeH": 24, + "offsetX": 35, + "offsetY": 1 + }, + { + "color": "rgb(80, 100, 210)", + "sizeW": 40, + "sizeH": 22, + "offsetX": -35, + "offsetY": 0 + }, + { + "color": "rgb(90, 80, 225)", + "sizeW": 35, + "sizeH": 28, + "offsetX": 110, + "offsetY": 1 + }, + { + "color": "rgb(120, 90, 245)", + "sizeW": 30, + "sizeH": 30, + "offsetX": -110, + "offsetY": -1 + } + ] + }, + "sunset": { + "dark": [ + { + "color": "rgb(255, 100, 60)", + "sizeW": 36, + "sizeH": 36, + "offsetX": 0, + "offsetY": 2 + }, + { + "color": "rgb(255, 180, 50)", + "sizeW": 30, + "sizeH": 32, + "offsetX": 39, + "offsetY": 0 + }, + { + "color": "rgb(255, 140, 70)", + "sizeW": 33, + "sizeH": 28, + "offsetX": -36, + "offsetY": 2 + }, + { + "color": "rgb(255, 80, 80)", + "sizeW": 29, + "sizeH": 34, + "offsetX": -54, + "offsetY": 0 + }, + { + "color": "rgb(255, 200, 60)", + "sizeW": 27, + "sizeH": 30, + "offsetX": 51, + "offsetY": -1 + }, + { + "color": "rgb(255, 120, 50)", + "sizeW": 36, + "sizeH": 24, + "offsetX": 21, + "offsetY": 1 + }, + { + "color": "rgb(255, 160, 80)", + "sizeW": 30, + "sizeH": 22, + "offsetX": -21, + "offsetY": 0 + }, + { + "color": "rgb(255, 90, 60)", + "sizeW": 25, + "sizeH": 28, + "offsetX": 66, + "offsetY": 1 + }, + { + "color": "rgb(255, 70, 70)", + "sizeW": 23, + "sizeH": 30, + "offsetX": -66, + "offsetY": -1 + } + ], + "light": [ + { + "color": "rgb(220, 80, 40)", + "sizeW": 45, + "sizeH": 36, + "offsetX": 0, + "offsetY": 2 + }, + { + "color": "rgb(230, 150, 30)", + "sizeW": 35, + "sizeH": 32, + "offsetX": 65, + "offsetY": 0 + }, + { + "color": "rgb(210, 110, 50)", + "sizeW": 40, + "sizeH": 28, + "offsetX": -60, + "offsetY": 2 + }, + { + "color": "rgb(200, 60, 60)", + "sizeW": 35, + "sizeH": 34, + "offsetX": -90, + "offsetY": 0 + }, + { + "color": "rgb(220, 170, 40)", + "sizeW": 38, + "sizeH": 30, + "offsetX": 85, + "offsetY": -1 + }, + { + "color": "rgb(210, 100, 30)", + "sizeW": 50, + "sizeH": 24, + "offsetX": 35, + "offsetY": 1 + }, + { + "color": "rgb(230, 130, 60)", + "sizeW": 40, + "sizeH": 22, + "offsetX": -35, + "offsetY": 0 + }, + { + "color": "rgb(190, 70, 50)", + "sizeW": 35, + "sizeH": 28, + "offsetX": 110, + "offsetY": 1 + }, + { + "color": "rgb(180, 50, 50)", + "sizeW": 30, + "sizeH": 30, + "offsetX": -110, + "offsetY": -1 + } + ] + } + }, + "lineInner": { + "colorful": [ + { + "color": "rgba(255, 50, 100, 0.48)", + "sizeW": 33, + "sizeH": 30, + "offsetX": 0, + "offsetY": 0 + }, + { + "color": "rgba(40, 180, 220, 0.42)", + "sizeW": 24, + "sizeH": 26, + "offsetX": 39, + "offsetY": -3 + }, + { + "color": "rgba(50, 200, 80, 0.48)", + "sizeW": 27, + "sizeH": 24, + "offsetX": -36, + "offsetY": 0 + }, + { + "color": "rgba(180, 40, 240, 0.42)", + "sizeW": 23, + "sizeH": 28, + "offsetX": -54, + "offsetY": -2 + }, + { + "color": "rgba(255, 160, 30, 0.50)", + "sizeW": 24, + "sizeH": 24, + "offsetX": 51, + "offsetY": -1 + }, + { + "color": "rgba(100, 70, 255, 0.45)", + "sizeW": 30, + "sizeH": 20, + "offsetX": 21, + "offsetY": 0 + }, + { + "color": "rgba(40, 140, 255, 0.40)", + "sizeW": 25, + "sizeH": 18, + "offsetX": -21, + "offsetY": -2 + }, + { + "color": "rgba(240, 50, 180, 0.45)", + "sizeW": 21, + "sizeH": 24, + "offsetX": 66, + "offsetY": 0 + }, + { + "color": "rgba(30, 185, 170, 0.52)", + "sizeW": 18, + "sizeH": 26, + "offsetX": -66, + "offsetY": -1 + } + ], + "mono": [ + { + "color": "rgba(200, 200, 200, 0.48)", + "sizeW": 33, + "sizeH": 30, + "offsetX": 0, + "offsetY": 0 + }, + { + "color": "rgba(170, 170, 170, 0.42)", + "sizeW": 24, + "sizeH": 26, + "offsetX": 39, + "offsetY": -3 + }, + { + "color": "rgba(155, 155, 155, 0.48)", + "sizeW": 27, + "sizeH": 24, + "offsetX": -36, + "offsetY": 0 + }, + { + "color": "rgba(185, 185, 185, 0.42)", + "sizeW": 23, + "sizeH": 28, + "offsetX": -54, + "offsetY": -2 + }, + { + "color": "rgba(165, 165, 165, 0.50)", + "sizeW": 24, + "sizeH": 24, + "offsetX": 51, + "offsetY": -1 + }, + { + "color": "rgba(180, 180, 180, 0.45)", + "sizeW": 30, + "sizeH": 20, + "offsetX": 21, + "offsetY": 0 + }, + { + "color": "rgba(160, 160, 160, 0.40)", + "sizeW": 25, + "sizeH": 18, + "offsetX": -21, + "offsetY": -2 + }, + { + "color": "rgba(175, 175, 175, 0.45)", + "sizeW": 21, + "sizeH": 24, + "offsetX": 66, + "offsetY": 0 + }, + { + "color": "rgba(190, 190, 190, 0.52)", + "sizeW": 18, + "sizeH": 26, + "offsetX": -66, + "offsetY": -1 + } + ], + "ocean": [ + { + "color": "rgba(100, 80, 220, 0.48)", + "sizeW": 33, + "sizeH": 30, + "offsetX": 0, + "offsetY": 0 + }, + { + "color": "rgba(60, 120, 255, 0.42)", + "sizeW": 24, + "sizeH": 26, + "offsetX": 39, + "offsetY": -3 + }, + { + "color": "rgba(80, 100, 200, 0.48)", + "sizeW": 27, + "sizeH": 24, + "offsetX": -36, + "offsetY": 0 + }, + { + "color": "rgba(130, 70, 255, 0.42)", + "sizeW": 23, + "sizeH": 28, + "offsetX": -54, + "offsetY": -2 + }, + { + "color": "rgba(70, 130, 255, 0.50)", + "sizeW": 24, + "sizeH": 24, + "offsetX": 51, + "offsetY": -1 + }, + { + "color": "rgba(120, 80, 255, 0.45)", + "sizeW": 30, + "sizeH": 20, + "offsetX": 21, + "offsetY": 0 + }, + { + "color": "rgba(90, 110, 230, 0.40)", + "sizeW": 25, + "sizeH": 18, + "offsetX": -21, + "offsetY": -2 + }, + { + "color": "rgba(110, 90, 240, 0.45)", + "sizeW": 21, + "sizeH": 24, + "offsetX": 66, + "offsetY": 0 + }, + { + "color": "rgba(140, 100, 255, 0.52)", + "sizeW": 18, + "sizeH": 26, + "offsetX": -66, + "offsetY": -1 + } + ], + "sunset": [ + { + "color": "rgba(255, 100, 60, 0.48)", + "sizeW": 33, + "sizeH": 30, + "offsetX": 0, + "offsetY": 0 + }, + { + "color": "rgba(255, 180, 50, 0.42)", + "sizeW": 24, + "sizeH": 26, + "offsetX": 39, + "offsetY": -3 + }, + { + "color": "rgba(255, 140, 70, 0.48)", + "sizeW": 27, + "sizeH": 24, + "offsetX": -36, + "offsetY": 0 + }, + { + "color": "rgba(255, 80, 80, 0.42)", + "sizeW": 23, + "sizeH": 28, + "offsetX": -54, + "offsetY": -2 + }, + { + "color": "rgba(255, 200, 60, 0.50)", + "sizeW": 24, + "sizeH": 24, + "offsetX": 51, + "offsetY": -1 + }, + { + "color": "rgba(255, 120, 50, 0.45)", + "sizeW": 30, + "sizeH": 20, + "offsetX": 21, + "offsetY": 0 + }, + { + "color": "rgba(255, 160, 80, 0.40)", + "sizeW": 25, + "sizeH": 18, + "offsetX": -21, + "offsetY": -2 + }, + { + "color": "rgba(255, 90, 60, 0.45)", + "sizeW": 21, + "sizeH": 24, + "offsetX": 66, + "offsetY": 0 + }, + { + "color": "rgba(255, 70, 70, 0.52)", + "sizeW": 18, + "sizeH": 26, + "offsetX": -66, + "offsetY": -1 + } + ] + }, + "lineBloom": { + "colorful": { + "dark": { + "spikes": [ + { + "color1": "rgb(100, 70, 255)", + "color2": "rgba(100, 70, 255, 1)" + }, + { + "color1": "rgba(255, 170, 40, 0.59)", + "color2": "rgba(255, 170, 40, 0.29)" + }, + { + "color1": "rgb(50, 200, 100)", + "color2": "rgba(50, 200, 100, 1)" + }, + { + "color1": "rgba(200, 50, 240, 0.91)", + "color2": "rgba(200, 50, 240, 0.45)" + }, + { + "color1": "rgb(40, 140, 255)", + "color2": "rgba(40, 140, 255, 1)" + } + ] + }, + "light": { + "spikes": [ + { + "color1": "rgb(80, 50, 200)", + "color2": "rgba(80, 50, 200, 0.8)" + }, + { + "color1": "rgba(210, 130, 0, 0.7)", + "color2": "rgba(210, 130, 0, 0.46)" + }, + { + "color1": "rgb(30, 160, 70)", + "color2": "rgba(30, 160, 70, 0.82)" + }, + { + "color1": "rgb(160, 30, 190)", + "color2": "rgba(160, 30, 190, 0.7)" + }, + { + "color1": "rgb(30, 100, 200)", + "color2": "rgba(30, 100, 200, 0.78)" + } + ] + } + }, + "mono": { + "dark": { + "spikes": [ + { + "color1": "rgb(200, 200, 200)", + "color2": "rgba(200, 200, 200, 1)" + }, + { + "color1": "rgba(180, 180, 180, 0.59)", + "color2": "rgba(180, 180, 180, 0.29)" + }, + { + "color1": "rgb(190, 190, 190)", + "color2": "rgba(190, 190, 190, 1)" + }, + { + "color1": "rgba(170, 170, 170, 0.91)", + "color2": "rgba(170, 170, 170, 0.45)" + }, + { + "color1": "rgb(185, 185, 185)", + "color2": "rgba(185, 185, 185, 1)" + } + ] + }, + "light": { + "spikes": [ + { + "color1": "rgb(80, 80, 80)", + "color2": "rgba(80, 80, 80, 0.8)" + }, + { + "color1": "rgba(100, 100, 100, 0.7)", + "color2": "rgba(100, 100, 100, 0.46)" + }, + { + "color1": "rgb(70, 70, 70)", + "color2": "rgba(70, 70, 70, 0.82)" + }, + { + "color1": "rgb(90, 90, 90)", + "color2": "rgba(90, 90, 90, 0.7)" + }, + { + "color1": "rgb(85, 85, 85)", + "color2": "rgba(85, 85, 85, 0.78)" + } + ] + } + }, + "ocean": { + "dark": { + "spikes": [ + { + "color1": "rgb(100, 80, 255)", + "color2": "rgb(100, 80, 255)" + }, + { + "color1": "rgba(80, 130, 220, 0.59)", + "color2": "rgba(80, 130, 220, 0.29)" + }, + { + "color1": "rgb(60, 100, 255)", + "color2": "rgb(60, 100, 255)" + }, + { + "color1": "rgba(90, 120, 200, 0.91)", + "color2": "rgba(90, 120, 200, 0.45)" + }, + { + "color1": "rgb(120, 90, 255)", + "color2": "rgb(120, 90, 255)" + } + ] + }, + "light": { + "spikes": [ + { + "color1": "rgb(50, 40, 180)", + "color2": "rgba(50, 40, 180, 0.8)" + }, + { + "color1": "rgba(40, 80, 200, 0.7)", + "color2": "rgba(40, 80, 200, 0.46)" + }, + { + "color1": "rgb(30, 50, 190)", + "color2": "rgba(30, 50, 190, 0.82)" + }, + { + "color1": "rgb(60, 90, 180)", + "color2": "rgba(60, 90, 180, 0.7)" + }, + { + "color1": "rgb(70, 60, 200)", + "color2": "rgba(70, 60, 200, 0.78)" + } + ] + } + }, + "sunset": { + "dark": { + "spikes": [ + { + "color1": "rgb(255, 100, 80)", + "color2": "rgb(255, 100, 80)" + }, + { + "color1": "rgba(255, 150, 80, 0.59)", + "color2": "rgba(255, 150, 80, 0.29)" + }, + { + "color1": "rgb(255, 80, 60)", + "color2": "rgb(255, 80, 60)" + }, + { + "color1": "rgba(255, 120, 50, 0.91)", + "color2": "rgba(255, 120, 50, 0.45)" + }, + { + "color1": "rgb(255, 140, 70)", + "color2": "rgb(255, 140, 70)" + } + ] + }, + "light": { + "spikes": [ + { + "color1": "rgb(200, 60, 30)", + "color2": "rgba(200, 60, 30, 0.8)" + }, + { + "color1": "rgba(220, 100, 20, 0.7)", + "color2": "rgba(220, 100, 20, 0.46)" + }, + { + "color1": "rgb(180, 40, 20)", + "color2": "rgba(180, 40, 20, 0.82)" + }, + { + "color1": "rgb(210, 80, 10)", + "color2": "rgba(210, 80, 10, 0.7)" + }, + { + "color1": "rgb(190, 70, 30)", + "color2": "rgba(190, 70, 30, 0.78)" + } + ] + } + } + } + }, + "rotate": { + "whiteGradientStops": { + "dark": [ + [ + 0, + 0 + ], + [ + 54, + 0 + ], + [ + 57, + 0.1 + ], + [ + 60, + 0.3 + ], + [ + 63, + 0.6 + ], + [ + 66, + 0.75 + ], + [ + 69, + 0.6 + ], + [ + 72, + 0.3 + ], + [ + 75, + 0.1 + ], + [ + 78, + 0 + ], + [ + 100, + 0 + ] + ], + "light": [ + [ + 0, + 0 + ], + [ + 54, + 0 + ], + [ + 57, + 0.08 + ], + [ + 60, + 0.2 + ], + [ + 63, + 0.4 + ], + [ + 66, + 0.55 + ], + [ + 69, + 0.4 + ], + [ + 72, + 0.2 + ], + [ + 75, + 0.08 + ], + [ + 78, + 0 + ], + [ + 100, + 0 + ] + ] + }, + "bloomGradientStops": { + "dark": [ + [ + 0, + 0 + ], + [ + 58, + 0 + ], + [ + 62, + 0.03 + ], + [ + 65, + 0.08 + ], + [ + 67, + 0.2 + ], + [ + 69, + 0.45 + ], + [ + 70, + 0.85 + ], + [ + 70.5, + 0.85 + ], + [ + 71.5, + 0.45 + ], + [ + 73, + 0.2 + ], + [ + 75, + 0.08 + ], + [ + 78, + 0.03 + ], + [ + 82, + 0 + ] + ], + "light": [ + [ + 0, + 0 + ], + [ + 58, + 0 + ], + [ + 62, + 0.02 + ], + [ + 65, + 0.08 + ], + [ + 67, + 0.2 + ], + [ + 69, + 0.4 + ], + [ + 70, + 0.6 + ], + [ + 70.5, + 0.6 + ], + [ + 71.5, + 0.4 + ], + [ + 73, + 0.2 + ], + [ + 75, + 0.08 + ], + [ + 78, + 0.02 + ], + [ + 82, + 0 + ] + ] + }, + "beamMaskStops": [ + [ + 0, + 0 + ], + [ + 30, + 0 + ], + [ + 36, + 0.1 + ], + [ + 44, + 0.35 + ], + [ + 52, + 1 + ], + [ + 80, + 1 + ], + [ + 86, + 0.35 + ], + [ + 92, + 0.1 + ], + [ + 95, + 0 + ], + [ + 100, + 0 + ] + ], + "smallMaskStops": [ + [ + 0, + 0 + ], + [ + 22, + 0 + ], + [ + 28, + 0.12 + ], + [ + 36, + 0.4 + ], + [ + 46, + 1 + ], + [ + 82, + 1 + ], + [ + 88, + 0.4 + ], + [ + 94, + 0.12 + ], + [ + 97, + 0 + ], + [ + 100, + 0 + ] + ], + "innerGradientDerivation": { + "sizeScale": 0.9, + "alpha": 0.45, + "monoAlpha": 0.225 + }, + "innerEdgeMaskPx": 28, + "innerShadowBlur": { + "md": 9, + "sm": 5 + }, + "bloomBlurPx": 8, + "spin": { + "from": 0, + "to": 360, + "easing": "linear" + } + }, + "line": { + "keyframes": { + "travel": { + "x": [ + [ + 0, + 0.06 + ], + [ + 10, + 0.15 + ], + [ + 20, + 0.25 + ], + [ + 30, + 0.35 + ], + [ + 40, + 0.44 + ], + [ + 50, + 0.5 + ], + [ + 60, + 0.56 + ], + [ + 70, + 0.65 + ], + [ + 80, + 0.75 + ], + [ + 90, + 0.85 + ], + [ + 100, + 0.94 + ] + ], + "w": [ + [ + 0, + 0.5 + ], + [ + 10, + 0.8 + ], + [ + 20, + 1.1 + ], + [ + 30, + 1.3 + ], + [ + 40, + 1.45 + ], + [ + 50, + 1.5 + ], + [ + 60, + 1.45 + ], + [ + 70, + 1.3 + ], + [ + 80, + 1.1 + ], + [ + 90, + 0.8 + ], + [ + 100, + 0.5 + ] + ] + }, + "edgeFade": [ + [ + 0, + 0 + ], + [ + 12.5, + 0 + ], + [ + 32.5, + 1 + ], + [ + 67.5, + 1 + ], + [ + 87.5, + 0 + ], + [ + 100, + 0 + ] + ], + "breathe": [ + [ + 0, + 0.8 + ], + [ + 25, + 1.25 + ], + [ + 55, + 0.85 + ], + [ + 80, + 1.3 + ], + [ + 100, + 0.8 + ] + ], + "spike": [ + [ + 0, + 0.8 + ], + [ + 25, + 1.3 + ], + [ + 50, + 0.9 + ], + [ + 75, + 1.4 + ], + [ + 100, + 0.8 + ] + ], + "spike2": [ + [ + 0, + 1.2 + ], + [ + 25, + 0.7 + ], + [ + 50, + 1.4 + ], + [ + 75, + 0.8 + ], + [ + 100, + 1.2 + ] + ], + "durationScale": { + "travel": 1, + "edgeFade": 1, + "breathe": 1.3, + "spike": 1.33, + "spike2": 1.7 + }, + "easing": { + "travel": "linear", + "edgeFade": "linear", + "breathe": "easeInOut", + "spike": "easeInOut", + "spike2": "easeInOut" + } + }, + "beamMaskEllipse": { + "w": 78, + "h": 60, + "softStop": [ + 45, + 0.5 + ] + }, + "bloomMaskEllipse": { + "w": 84, + "h": 110, + "softStop": [ + 35, + 0.5 + ] + }, + "whiteHighlight": { + "dark": { + "w": 24, + "h": 28, + "yOffset": 2, + "stops": [ + [ + 0, + 0.38 + ], + [ + 30, + 0.12 + ], + [ + 65, + 0 + ] + ] + }, + "light": { + "w": 35, + "h": 28, + "yOffset": 2, + "stops": [ + [ + 0, + 0.6 + ], + [ + 35, + 0.25 + ], + [ + 70, + 0 + ] + ], + "onBlack": true + } + }, + "bloomGradients": { + "colorful": { + "dark": [ + { + "xPct": 8, + "yOffPx": -2, + "w": { + "base": 0.8, + "mult": "spike" + }, + "h": { + "base": 92, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 60, + "b": 80, + "a": 1, + "pos": 0 + }, + { + "r": 255, + "g": 60, + "b": 80, + "a": 1, + "pos": 0.3 + }, + { + "r": 255, + "g": 60, + "b": 80, + "a": 0, + "pos": 0.88 + } + ] + }, + { + "xPct": 22, + "yOffPx": -4, + "w": { + "base": 10, + "mult": "spike2" + }, + "h": { + "base": 35, + "mult": "h" + }, + "stops": [ + { + "r": 40, + "g": 190, + "b": 180, + "a": 0.98, + "pos": 0 + }, + { + "r": 40, + "g": 190, + "b": 180, + "a": 0.49, + "pos": 0.5 + }, + { + "r": 40, + "g": 190, + "b": 180, + "a": 0, + "pos": 0.95 + } + ] + }, + { + "xPct": 36, + "yOffPx": -3, + "w": { + "base": 2, + "mult": "inv-spike" + }, + "h": { + "base": 72, + "mult": "h" + }, + "stops": [ + { + "r": 100, + "g": 70, + "b": 255, + "a": 1, + "pos": 0 + }, + { + "r": 100, + "g": 70, + "b": 255, + "a": 1, + "pos": 0.4 + }, + { + "r": 100, + "g": 70, + "b": 255, + "a": 0, + "pos": 0.9 + } + ] + }, + { + "xPct": 50, + "yOffPx": -2, + "w": { + "base": 14, + "mult": "spike2" + }, + "h": { + "base": 28, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 170, + "b": 40, + "a": 0.59, + "pos": 0 + }, + { + "r": 255, + "g": 170, + "b": 40, + "a": 0.29, + "pos": 0.55 + }, + { + "r": 255, + "g": 170, + "b": 40, + "a": 0, + "pos": 0.96 + } + ] + }, + { + "xPct": 64, + "yOffPx": -4, + "w": { + "base": 1.2, + "mult": "inv-spike2" + }, + "h": { + "base": 85, + "mult": "h" + }, + "stops": [ + { + "r": 50, + "g": 200, + "b": 100, + "a": 1, + "pos": 0 + }, + { + "r": 50, + "g": 200, + "b": 100, + "a": 1, + "pos": 0.35 + }, + { + "r": 50, + "g": 200, + "b": 100, + "a": 0, + "pos": 0.89 + } + ] + }, + { + "xPct": 78, + "yOffPx": -2, + "w": { + "base": 7, + "mult": "spike" + }, + "h": { + "base": 45, + "mult": "h" + }, + "stops": [ + { + "r": 200, + "g": 50, + "b": 240, + "a": 0.91, + "pos": 0 + }, + { + "r": 200, + "g": 50, + "b": 240, + "a": 0.45, + "pos": 0.48 + }, + { + "r": 200, + "g": 50, + "b": 240, + "a": 0, + "pos": 0.94 + } + ] + }, + { + "xPct": 92, + "yOffPx": -3, + "w": { + "base": 0.6, + "mult": "inv-spike" + }, + "h": { + "base": 60, + "mult": "h" + }, + "stops": [ + { + "r": 40, + "g": 140, + "b": 255, + "a": 1, + "pos": 0 + }, + { + "r": 40, + "g": 140, + "b": 255, + "a": 1, + "pos": 0.42 + }, + { + "r": 40, + "g": 140, + "b": 255, + "a": 0, + "pos": 0.91 + } + ] + }, + { + "xPct": null, + "yOffPx": 1, + "w": { + "base": 21, + "mult": "spike" + }, + "h": { + "base": 15, + "mult": "spike2" + }, + "stops": [ + { + "r": 255, + "g": 255, + "b": 255, + "a": 1, + "pos": 0 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.9, + "pos": 0.2 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.5, + "pos": 0.5 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0, + "pos": 1 + } + ] + }, + { + "xPct": null, + "yOffPx": 0, + "w": { + "base": 42, + "mult": "w" + }, + "h": { + "base": 40, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.3, + "pos": 0 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.12, + "pos": 0.25 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.03, + "pos": 0.55 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0, + "pos": 0.8 + } + ] + } + ], + "light": [ + { + "xPct": 8, + "yOffPx": -2, + "w": { + "base": 0.8, + "mult": "spike" + }, + "h": { + "base": 92, + "mult": "h" + }, + "stops": [ + { + "r": 200, + "g": 30, + "b": 60, + "a": 1, + "pos": 0 + }, + { + "r": 200, + "g": 30, + "b": 60, + "a": 0.85, + "pos": 0.3 + }, + { + "r": 200, + "g": 30, + "b": 60, + "a": 0, + "pos": 0.88 + } + ] + }, + { + "xPct": 22, + "yOffPx": -4, + "w": { + "base": 10, + "mult": "spike2" + }, + "h": { + "base": 35, + "mult": "h" + }, + "stops": [ + { + "r": 20, + "g": 150, + "b": 140, + "a": 1, + "pos": 0 + }, + { + "r": 20, + "g": 150, + "b": 140, + "a": 0.7, + "pos": 0.5 + }, + { + "r": 20, + "g": 150, + "b": 140, + "a": 0, + "pos": 0.95 + } + ] + }, + { + "xPct": 36, + "yOffPx": -3, + "w": { + "base": 2, + "mult": "inv-spike" + }, + "h": { + "base": 72, + "mult": "h" + }, + "stops": [ + { + "r": 80, + "g": 50, + "b": 200, + "a": 1, + "pos": 0 + }, + { + "r": 80, + "g": 50, + "b": 200, + "a": 0.8, + "pos": 0.4 + }, + { + "r": 80, + "g": 50, + "b": 200, + "a": 0, + "pos": 0.9 + } + ] + }, + { + "xPct": 50, + "yOffPx": -2, + "w": { + "base": 14, + "mult": "spike2" + }, + "h": { + "base": 28, + "mult": "h" + }, + "stops": [ + { + "r": 210, + "g": 130, + "b": 0, + "a": 0.7, + "pos": 0 + }, + { + "r": 210, + "g": 130, + "b": 0, + "a": 0.46, + "pos": 0.55 + }, + { + "r": 210, + "g": 130, + "b": 0, + "a": 0, + "pos": 0.96 + } + ] + }, + { + "xPct": 64, + "yOffPx": -4, + "w": { + "base": 1.2, + "mult": "inv-spike2" + }, + "h": { + "base": 85, + "mult": "h" + }, + "stops": [ + { + "r": 30, + "g": 160, + "b": 70, + "a": 1, + "pos": 0 + }, + { + "r": 30, + "g": 160, + "b": 70, + "a": 0.82, + "pos": 0.35 + }, + { + "r": 30, + "g": 160, + "b": 70, + "a": 0, + "pos": 0.89 + } + ] + }, + { + "xPct": 78, + "yOffPx": -2, + "w": { + "base": 7, + "mult": "spike" + }, + "h": { + "base": 45, + "mult": "h" + }, + "stops": [ + { + "r": 160, + "g": 30, + "b": 190, + "a": 1, + "pos": 0 + }, + { + "r": 160, + "g": 30, + "b": 190, + "a": 0.7, + "pos": 0.48 + }, + { + "r": 160, + "g": 30, + "b": 190, + "a": 0, + "pos": 0.94 + } + ] + }, + { + "xPct": 92, + "yOffPx": -3, + "w": { + "base": 1, + "mult": "inv-spike" + }, + "h": { + "base": 60, + "mult": "h" + }, + "stops": [ + { + "r": 30, + "g": 100, + "b": 200, + "a": 1, + "pos": 0 + }, + { + "r": 30, + "g": 100, + "b": 200, + "a": 0.78, + "pos": 0.42 + }, + { + "r": 30, + "g": 100, + "b": 200, + "a": 0, + "pos": 0.91 + } + ] + }, + { + "xPct": null, + "yOffPx": 0, + "w": { + "base": 50, + "mult": "w" + }, + "h": { + "base": 32, + "mult": "h" + }, + "stops": [ + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.5, + "pos": 0 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.18, + "pos": 0.3 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.03, + "pos": 0.6 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0, + "pos": 0.85 + } + ] + } + ] + }, + "mono": { + "dark": [ + { + "xPct": 8, + "yOffPx": -2, + "w": { + "base": 12, + "mult": "spike" + }, + "h": { + "base": 42, + "mult": "h" + }, + "stops": [ + { + "r": 200, + "g": 200, + "b": 200, + "a": 0.14, + "pos": 0 + }, + { + "r": 200, + "g": 200, + "b": 200, + "a": 0.09, + "pos": 0.3 + }, + { + "r": 200, + "g": 200, + "b": 200, + "a": 0, + "pos": 0.88 + } + ] + }, + { + "xPct": 22, + "yOffPx": -4, + "w": { + "base": 10, + "mult": "spike2" + }, + "h": { + "base": 35, + "mult": "h" + }, + "stops": [ + { + "r": 170, + "g": 170, + "b": 170, + "a": 0.12, + "pos": 0 + }, + { + "r": 170, + "g": 170, + "b": 170, + "a": 0.06, + "pos": 0.5 + }, + { + "r": 170, + "g": 170, + "b": 170, + "a": 0, + "pos": 0.95 + } + ] + }, + { + "xPct": 36, + "yOffPx": -3, + "w": { + "base": 14, + "mult": "inv-spike" + }, + "h": { + "base": 38, + "mult": "h" + }, + "stops": [ + { + "r": 200, + "g": 200, + "b": 200, + "a": 0.14, + "pos": 0 + }, + { + "r": 200, + "g": 200, + "b": 200, + "a": 0.098, + "pos": 0.4 + }, + { + "r": 200, + "g": 200, + "b": 200, + "a": 0, + "pos": 0.9 + } + ] + }, + { + "xPct": 50, + "yOffPx": -2, + "w": { + "base": 14, + "mult": "spike2" + }, + "h": { + "base": 28, + "mult": "h" + }, + "stops": [ + { + "r": 180, + "g": 180, + "b": 180, + "a": 0.0826, + "pos": 0 + }, + { + "r": 180, + "g": 180, + "b": 180, + "a": 0.0284, + "pos": 0.55 + }, + { + "r": 180, + "g": 180, + "b": 180, + "a": 0, + "pos": 0.96 + } + ] + }, + { + "xPct": 64, + "yOffPx": -4, + "w": { + "base": 12, + "mult": "inv-spike2" + }, + "h": { + "base": 40, + "mult": "h" + }, + "stops": [ + { + "r": 190, + "g": 190, + "b": 190, + "a": 0.14, + "pos": 0 + }, + { + "r": 190, + "g": 190, + "b": 190, + "a": 0.098, + "pos": 0.35 + }, + { + "r": 190, + "g": 190, + "b": 190, + "a": 0, + "pos": 0.89 + } + ] + }, + { + "xPct": 78, + "yOffPx": -2, + "w": { + "base": 7, + "mult": "spike" + }, + "h": { + "base": 45, + "mult": "h" + }, + "stops": [ + { + "r": 170, + "g": 170, + "b": 170, + "a": 0.1274, + "pos": 0 + }, + { + "r": 170, + "g": 170, + "b": 170, + "a": 0.0441, + "pos": 0.48 + }, + { + "r": 170, + "g": 170, + "b": 170, + "a": 0, + "pos": 0.94 + } + ] + }, + { + "xPct": 92, + "yOffPx": -3, + "w": { + "base": 10, + "mult": "inv-spike" + }, + "h": { + "base": 32, + "mult": "h" + }, + "stops": [ + { + "r": 185, + "g": 185, + "b": 185, + "a": 0.14, + "pos": 0 + }, + { + "r": 185, + "g": 185, + "b": 185, + "a": 0.098, + "pos": 0.42 + }, + { + "r": 185, + "g": 185, + "b": 185, + "a": 0, + "pos": 0.91 + } + ] + }, + { + "xPct": null, + "yOffPx": 1, + "w": { + "base": 21, + "mult": "spike" + }, + "h": { + "base": 15, + "mult": "spike2" + }, + "stops": [ + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.5, + "pos": 0 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.45, + "pos": 0.2 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.25, + "pos": 0.5 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0, + "pos": 1 + } + ] + }, + { + "xPct": null, + "yOffPx": 0, + "w": { + "base": 42, + "mult": "w" + }, + "h": { + "base": 40, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.15, + "pos": 0 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.06, + "pos": 0.25 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.015, + "pos": 0.55 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0, + "pos": 0.8 + } + ] + } + ], + "light": [ + { + "xPct": 8, + "yOffPx": -2, + "w": { + "base": 12, + "mult": "spike" + }, + "h": { + "base": 42, + "mult": "h" + }, + "stops": [ + { + "r": 80, + "g": 80, + "b": 80, + "a": 0.14, + "pos": 0 + }, + { + "r": 80, + "g": 80, + "b": 80, + "a": 0.11, + "pos": 0.3 + }, + { + "r": 80, + "g": 80, + "b": 80, + "a": 0, + "pos": 0.88 + } + ] + }, + { + "xPct": 22, + "yOffPx": -4, + "w": { + "base": 10, + "mult": "spike2" + }, + "h": { + "base": 35, + "mult": "h" + }, + "stops": [ + { + "r": 120, + "g": 120, + "b": 120, + "a": 0.12, + "pos": 0 + }, + { + "r": 120, + "g": 120, + "b": 120, + "a": 0.09, + "pos": 0.5 + }, + { + "r": 120, + "g": 120, + "b": 120, + "a": 0, + "pos": 0.95 + } + ] + }, + { + "xPct": 36, + "yOffPx": -3, + "w": { + "base": 14, + "mult": "inv-spike" + }, + "h": { + "base": 38, + "mult": "h" + }, + "stops": [ + { + "r": 80, + "g": 80, + "b": 80, + "a": 0.14, + "pos": 0 + }, + { + "r": 80, + "g": 80, + "b": 80, + "a": 0.0784, + "pos": 0.4 + }, + { + "r": 80, + "g": 80, + "b": 80, + "a": 0, + "pos": 0.9 + } + ] + }, + { + "xPct": 50, + "yOffPx": -2, + "w": { + "base": 14, + "mult": "spike2" + }, + "h": { + "base": 28, + "mult": "h" + }, + "stops": [ + { + "r": 100, + "g": 100, + "b": 100, + "a": 0.098, + "pos": 0 + }, + { + "r": 100, + "g": 100, + "b": 100, + "a": 0.0451, + "pos": 0.55 + }, + { + "r": 100, + "g": 100, + "b": 100, + "a": 0, + "pos": 0.96 + } + ] + }, + { + "xPct": 64, + "yOffPx": -4, + "w": { + "base": 12, + "mult": "inv-spike2" + }, + "h": { + "base": 40, + "mult": "h" + }, + "stops": [ + { + "r": 70, + "g": 70, + "b": 70, + "a": 0.14, + "pos": 0 + }, + { + "r": 70, + "g": 70, + "b": 70, + "a": 0.0804, + "pos": 0.35 + }, + { + "r": 70, + "g": 70, + "b": 70, + "a": 0, + "pos": 0.89 + } + ] + }, + { + "xPct": 78, + "yOffPx": -2, + "w": { + "base": 7, + "mult": "spike" + }, + "h": { + "base": 45, + "mult": "h" + }, + "stops": [ + { + "r": 90, + "g": 90, + "b": 90, + "a": 0.14, + "pos": 0 + }, + { + "r": 90, + "g": 90, + "b": 90, + "a": 0.0686, + "pos": 0.48 + }, + { + "r": 90, + "g": 90, + "b": 90, + "a": 0, + "pos": 0.94 + } + ] + }, + { + "xPct": 92, + "yOffPx": -3, + "w": { + "base": 12, + "mult": "inv-spike" + }, + "h": { + "base": 32, + "mult": "h" + }, + "stops": [ + { + "r": 85, + "g": 85, + "b": 85, + "a": 0.14, + "pos": 0 + }, + { + "r": 85, + "g": 85, + "b": 85, + "a": 0.0764, + "pos": 0.42 + }, + { + "r": 85, + "g": 85, + "b": 85, + "a": 0, + "pos": 0.91 + } + ] + }, + { + "xPct": null, + "yOffPx": 0, + "w": { + "base": 50, + "mult": "w" + }, + "h": { + "base": 32, + "mult": "h" + }, + "stops": [ + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.5, + "pos": 0 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.18, + "pos": 0.3 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.03, + "pos": 0.6 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0, + "pos": 0.85 + } + ] + } + ] + }, + "ocean": { + "dark": [ + { + "xPct": 8, + "yOffPx": -2, + "w": { + "base": 0.8, + "mult": "spike" + }, + "h": { + "base": 92, + "mult": "h" + }, + "stops": [ + { + "r": 100, + "g": 120, + "b": 255, + "a": 1, + "pos": 0 + }, + { + "r": 100, + "g": 120, + "b": 255, + "a": 1, + "pos": 0.3 + }, + { + "r": 100, + "g": 120, + "b": 255, + "a": 0, + "pos": 0.88 + } + ] + }, + { + "xPct": 22, + "yOffPx": -4, + "w": { + "base": 10, + "mult": "spike2" + }, + "h": { + "base": 35, + "mult": "h" + }, + "stops": [ + { + "r": 130, + "g": 100, + "b": 220, + "a": 0.98, + "pos": 0 + }, + { + "r": 130, + "g": 100, + "b": 220, + "a": 0.49, + "pos": 0.5 + }, + { + "r": 130, + "g": 100, + "b": 220, + "a": 0, + "pos": 0.95 + } + ] + }, + { + "xPct": 36, + "yOffPx": -3, + "w": { + "base": 2, + "mult": "inv-spike" + }, + "h": { + "base": 72, + "mult": "h" + }, + "stops": [ + { + "r": 100, + "g": 80, + "b": 255, + "a": 1, + "pos": 0 + }, + { + "r": 100, + "g": 80, + "b": 255, + "a": 1, + "pos": 0.4 + }, + { + "r": 100, + "g": 80, + "b": 255, + "a": 0, + "pos": 0.9 + } + ] + }, + { + "xPct": 50, + "yOffPx": -2, + "w": { + "base": 14, + "mult": "spike2" + }, + "h": { + "base": 28, + "mult": "h" + }, + "stops": [ + { + "r": 80, + "g": 130, + "b": 220, + "a": 0.59, + "pos": 0 + }, + { + "r": 80, + "g": 130, + "b": 220, + "a": 0.29, + "pos": 0.55 + }, + { + "r": 80, + "g": 130, + "b": 220, + "a": 0, + "pos": 0.96 + } + ] + }, + { + "xPct": 64, + "yOffPx": -4, + "w": { + "base": 1.2, + "mult": "inv-spike2" + }, + "h": { + "base": 85, + "mult": "h" + }, + "stops": [ + { + "r": 60, + "g": 100, + "b": 255, + "a": 1, + "pos": 0 + }, + { + "r": 60, + "g": 100, + "b": 255, + "a": 1, + "pos": 0.35 + }, + { + "r": 60, + "g": 100, + "b": 255, + "a": 0, + "pos": 0.89 + } + ] + }, + { + "xPct": 78, + "yOffPx": -2, + "w": { + "base": 7, + "mult": "spike" + }, + "h": { + "base": 45, + "mult": "h" + }, + "stops": [ + { + "r": 90, + "g": 120, + "b": 200, + "a": 0.91, + "pos": 0 + }, + { + "r": 90, + "g": 120, + "b": 200, + "a": 0.45, + "pos": 0.48 + }, + { + "r": 90, + "g": 120, + "b": 200, + "a": 0, + "pos": 0.94 + } + ] + }, + { + "xPct": 92, + "yOffPx": -3, + "w": { + "base": 0.6, + "mult": "inv-spike" + }, + "h": { + "base": 60, + "mult": "h" + }, + "stops": [ + { + "r": 120, + "g": 90, + "b": 255, + "a": 1, + "pos": 0 + }, + { + "r": 120, + "g": 90, + "b": 255, + "a": 1, + "pos": 0.42 + }, + { + "r": 120, + "g": 90, + "b": 255, + "a": 0, + "pos": 0.91 + } + ] + }, + { + "xPct": null, + "yOffPx": 1, + "w": { + "base": 21, + "mult": "spike" + }, + "h": { + "base": 15, + "mult": "spike2" + }, + "stops": [ + { + "r": 255, + "g": 255, + "b": 255, + "a": 1, + "pos": 0 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.9, + "pos": 0.2 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.5, + "pos": 0.5 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0, + "pos": 1 + } + ] + }, + { + "xPct": null, + "yOffPx": 0, + "w": { + "base": 42, + "mult": "w" + }, + "h": { + "base": 40, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.3, + "pos": 0 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.12, + "pos": 0.25 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.03, + "pos": 0.55 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0, + "pos": 0.8 + } + ] + } + ], + "light": [ + { + "xPct": 8, + "yOffPx": -2, + "w": { + "base": 0.8, + "mult": "spike" + }, + "h": { + "base": 92, + "mult": "h" + }, + "stops": [ + { + "r": 60, + "g": 60, + "b": 180, + "a": 1, + "pos": 0 + }, + { + "r": 60, + "g": 60, + "b": 180, + "a": 0.85, + "pos": 0.3 + }, + { + "r": 60, + "g": 60, + "b": 180, + "a": 0, + "pos": 0.88 + } + ] + }, + { + "xPct": 22, + "yOffPx": -4, + "w": { + "base": 10, + "mult": "spike2" + }, + "h": { + "base": 35, + "mult": "h" + }, + "stops": [ + { + "r": 80, + "g": 100, + "b": 200, + "a": 1, + "pos": 0 + }, + { + "r": 80, + "g": 100, + "b": 200, + "a": 0.7, + "pos": 0.5 + }, + { + "r": 80, + "g": 100, + "b": 200, + "a": 0, + "pos": 0.95 + } + ] + }, + { + "xPct": 36, + "yOffPx": -3, + "w": { + "base": 2, + "mult": "inv-spike" + }, + "h": { + "base": 72, + "mult": "h" + }, + "stops": [ + { + "r": 50, + "g": 40, + "b": 180, + "a": 1, + "pos": 0 + }, + { + "r": 50, + "g": 40, + "b": 180, + "a": 0.8, + "pos": 0.4 + }, + { + "r": 50, + "g": 40, + "b": 180, + "a": 0, + "pos": 0.9 + } + ] + }, + { + "xPct": 50, + "yOffPx": -2, + "w": { + "base": 14, + "mult": "spike2" + }, + "h": { + "base": 28, + "mult": "h" + }, + "stops": [ + { + "r": 40, + "g": 80, + "b": 200, + "a": 0.7, + "pos": 0 + }, + { + "r": 40, + "g": 80, + "b": 200, + "a": 0.46, + "pos": 0.55 + }, + { + "r": 40, + "g": 80, + "b": 200, + "a": 0, + "pos": 0.96 + } + ] + }, + { + "xPct": 64, + "yOffPx": -4, + "w": { + "base": 1.2, + "mult": "inv-spike2" + }, + "h": { + "base": 85, + "mult": "h" + }, + "stops": [ + { + "r": 30, + "g": 50, + "b": 190, + "a": 1, + "pos": 0 + }, + { + "r": 30, + "g": 50, + "b": 190, + "a": 0.82, + "pos": 0.35 + }, + { + "r": 30, + "g": 50, + "b": 190, + "a": 0, + "pos": 0.89 + } + ] + }, + { + "xPct": 78, + "yOffPx": -2, + "w": { + "base": 7, + "mult": "spike" + }, + "h": { + "base": 45, + "mult": "h" + }, + "stops": [ + { + "r": 60, + "g": 90, + "b": 180, + "a": 1, + "pos": 0 + }, + { + "r": 60, + "g": 90, + "b": 180, + "a": 0.7, + "pos": 0.48 + }, + { + "r": 60, + "g": 90, + "b": 180, + "a": 0, + "pos": 0.94 + } + ] + }, + { + "xPct": 92, + "yOffPx": -3, + "w": { + "base": 1, + "mult": "inv-spike" + }, + "h": { + "base": 60, + "mult": "h" + }, + "stops": [ + { + "r": 70, + "g": 60, + "b": 200, + "a": 1, + "pos": 0 + }, + { + "r": 70, + "g": 60, + "b": 200, + "a": 0.78, + "pos": 0.42 + }, + { + "r": 70, + "g": 60, + "b": 200, + "a": 0, + "pos": 0.91 + } + ] + }, + { + "xPct": null, + "yOffPx": 0, + "w": { + "base": 50, + "mult": "w" + }, + "h": { + "base": 32, + "mult": "h" + }, + "stops": [ + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.5, + "pos": 0 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.18, + "pos": 0.3 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.03, + "pos": 0.6 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0, + "pos": 0.85 + } + ] + } + ] + }, + "sunset": { + "dark": [ + { + "xPct": 8, + "yOffPx": -2, + "w": { + "base": 0.8, + "mult": "spike" + }, + "h": { + "base": 92, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 140, + "b": 80, + "a": 1, + "pos": 0 + }, + { + "r": 255, + "g": 140, + "b": 80, + "a": 1, + "pos": 0.3 + }, + { + "r": 255, + "g": 140, + "b": 80, + "a": 0, + "pos": 0.88 + } + ] + }, + { + "xPct": 22, + "yOffPx": -4, + "w": { + "base": 10, + "mult": "spike2" + }, + "h": { + "base": 35, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 100, + "b": 60, + "a": 0.98, + "pos": 0 + }, + { + "r": 255, + "g": 100, + "b": 60, + "a": 0.49, + "pos": 0.5 + }, + { + "r": 255, + "g": 100, + "b": 60, + "a": 0, + "pos": 0.95 + } + ] + }, + { + "xPct": 36, + "yOffPx": -3, + "w": { + "base": 2, + "mult": "inv-spike" + }, + "h": { + "base": 72, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 100, + "b": 80, + "a": 1, + "pos": 0 + }, + { + "r": 255, + "g": 100, + "b": 80, + "a": 1, + "pos": 0.4 + }, + { + "r": 255, + "g": 100, + "b": 80, + "a": 0, + "pos": 0.9 + } + ] + }, + { + "xPct": 50, + "yOffPx": -2, + "w": { + "base": 14, + "mult": "spike2" + }, + "h": { + "base": 28, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 150, + "b": 80, + "a": 0.59, + "pos": 0 + }, + { + "r": 255, + "g": 150, + "b": 80, + "a": 0.29, + "pos": 0.55 + }, + { + "r": 255, + "g": 150, + "b": 80, + "a": 0, + "pos": 0.96 + } + ] + }, + { + "xPct": 64, + "yOffPx": -4, + "w": { + "base": 1.2, + "mult": "inv-spike2" + }, + "h": { + "base": 85, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 80, + "b": 60, + "a": 1, + "pos": 0 + }, + { + "r": 255, + "g": 80, + "b": 60, + "a": 1, + "pos": 0.35 + }, + { + "r": 255, + "g": 80, + "b": 60, + "a": 0, + "pos": 0.89 + } + ] + }, + { + "xPct": 78, + "yOffPx": -2, + "w": { + "base": 7, + "mult": "spike" + }, + "h": { + "base": 45, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 120, + "b": 50, + "a": 0.91, + "pos": 0 + }, + { + "r": 255, + "g": 120, + "b": 50, + "a": 0.45, + "pos": 0.48 + }, + { + "r": 255, + "g": 120, + "b": 50, + "a": 0, + "pos": 0.94 + } + ] + }, + { + "xPct": 92, + "yOffPx": -3, + "w": { + "base": 0.6, + "mult": "inv-spike" + }, + "h": { + "base": 60, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 140, + "b": 70, + "a": 1, + "pos": 0 + }, + { + "r": 255, + "g": 140, + "b": 70, + "a": 1, + "pos": 0.42 + }, + { + "r": 255, + "g": 140, + "b": 70, + "a": 0, + "pos": 0.91 + } + ] + }, + { + "xPct": null, + "yOffPx": 1, + "w": { + "base": 21, + "mult": "spike" + }, + "h": { + "base": 15, + "mult": "spike2" + }, + "stops": [ + { + "r": 255, + "g": 255, + "b": 255, + "a": 1, + "pos": 0 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.9, + "pos": 0.2 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.5, + "pos": 0.5 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0, + "pos": 1 + } + ] + }, + { + "xPct": null, + "yOffPx": 0, + "w": { + "base": 42, + "mult": "w" + }, + "h": { + "base": 40, + "mult": "h" + }, + "stops": [ + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.3, + "pos": 0 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.12, + "pos": 0.25 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0.03, + "pos": 0.55 + }, + { + "r": 255, + "g": 255, + "b": 255, + "a": 0, + "pos": 0.8 + } + ] + } + ], + "light": [ + { + "xPct": 8, + "yOffPx": -2, + "w": { + "base": 0.8, + "mult": "spike" + }, + "h": { + "base": 92, + "mult": "h" + }, + "stops": [ + { + "r": 200, + "g": 80, + "b": 40, + "a": 1, + "pos": 0 + }, + { + "r": 200, + "g": 80, + "b": 40, + "a": 0.85, + "pos": 0.3 + }, + { + "r": 200, + "g": 80, + "b": 40, + "a": 0, + "pos": 0.88 + } + ] + }, + { + "xPct": 22, + "yOffPx": -4, + "w": { + "base": 10, + "mult": "spike2" + }, + "h": { + "base": 35, + "mult": "h" + }, + "stops": [ + { + "r": 220, + "g": 120, + "b": 30, + "a": 1, + "pos": 0 + }, + { + "r": 220, + "g": 120, + "b": 30, + "a": 0.7, + "pos": 0.5 + }, + { + "r": 220, + "g": 120, + "b": 30, + "a": 0, + "pos": 0.95 + } + ] + }, + { + "xPct": 36, + "yOffPx": -3, + "w": { + "base": 2, + "mult": "inv-spike" + }, + "h": { + "base": 72, + "mult": "h" + }, + "stops": [ + { + "r": 200, + "g": 60, + "b": 30, + "a": 1, + "pos": 0 + }, + { + "r": 200, + "g": 60, + "b": 30, + "a": 0.8, + "pos": 0.4 + }, + { + "r": 200, + "g": 60, + "b": 30, + "a": 0, + "pos": 0.9 + } + ] + }, + { + "xPct": 50, + "yOffPx": -2, + "w": { + "base": 14, + "mult": "spike2" + }, + "h": { + "base": 28, + "mult": "h" + }, + "stops": [ + { + "r": 220, + "g": 100, + "b": 20, + "a": 0.7, + "pos": 0 + }, + { + "r": 220, + "g": 100, + "b": 20, + "a": 0.46, + "pos": 0.55 + }, + { + "r": 220, + "g": 100, + "b": 20, + "a": 0, + "pos": 0.96 + } + ] + }, + { + "xPct": 64, + "yOffPx": -4, + "w": { + "base": 1.2, + "mult": "inv-spike2" + }, + "h": { + "base": 85, + "mult": "h" + }, + "stops": [ + { + "r": 180, + "g": 40, + "b": 20, + "a": 1, + "pos": 0 + }, + { + "r": 180, + "g": 40, + "b": 20, + "a": 0.82, + "pos": 0.35 + }, + { + "r": 180, + "g": 40, + "b": 20, + "a": 0, + "pos": 0.89 + } + ] + }, + { + "xPct": 78, + "yOffPx": -2, + "w": { + "base": 7, + "mult": "spike" + }, + "h": { + "base": 45, + "mult": "h" + }, + "stops": [ + { + "r": 210, + "g": 80, + "b": 10, + "a": 1, + "pos": 0 + }, + { + "r": 210, + "g": 80, + "b": 10, + "a": 0.7, + "pos": 0.48 + }, + { + "r": 210, + "g": 80, + "b": 10, + "a": 0, + "pos": 0.94 + } + ] + }, + { + "xPct": 92, + "yOffPx": -3, + "w": { + "base": 1, + "mult": "inv-spike" + }, + "h": { + "base": 60, + "mult": "h" + }, + "stops": [ + { + "r": 190, + "g": 70, + "b": 30, + "a": 1, + "pos": 0 + }, + { + "r": 190, + "g": 70, + "b": 30, + "a": 0.78, + "pos": 0.42 + }, + { + "r": 190, + "g": 70, + "b": 30, + "a": 0, + "pos": 0.91 + } + ] + }, + { + "xPct": null, + "yOffPx": 0, + "w": { + "base": 50, + "mult": "w" + }, + "h": { + "base": 32, + "mult": "h" + }, + "stops": [ + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.5, + "pos": 0 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.18, + "pos": 0.3 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0.03, + "pos": 0.6 + }, + { + "r": 0, + "g": 0, + "b": 0, + "a": 0, + "pos": 0.85 + } + ] + } + ] + } + }, + "monoBloomExtraBlurPx": 6, + "bloomBlurPx": 8 + }, + "pulse": { + "ringMap": [ + { + "region": 1, + "quad": "tl" + }, + { + "region": 2, + "quad": "tl" + }, + { + "region": 3, + "quad": "bl" + }, + { + "region": 1, + "quad": "bl" + }, + { + "region": 2, + "quad": "br" + }, + { + "region": 3, + "quad": "br" + }, + { + "region": 1, + "quad": "tr" + }, + { + "region": 2, + "quad": "tr" + }, + { + "region": 3, + "quad": "tr" + } + ], + "innerSizes": [ + [ + 65, + 35 + ], + [ + 55, + 30 + ], + [ + 35, + 65 + ], + [ + 15, + 30 + ], + [ + 173, + 28 + ], + [ + 80, + 22 + ], + [ + 69, + 28 + ], + [ + 22, + 38 + ], + [ + 47, + 44 + ] + ], + "innerBloom": [ + { + "ci": 0, + "region": 1, + "quad": "tl", + "w": 84, + "h": 48 + }, + { + "ci": 1, + "region": 2, + "quad": "tl", + "w": 72, + "h": 42 + }, + { + "ci": 2, + "region": 3, + "quad": "bl", + "w": 48, + "h": 84 + }, + { + "ci": 4, + "region": 2, + "quad": "br", + "w": 216, + "h": 38 + }, + { + "ci": 5, + "region": 3, + "quad": "br", + "w": 102, + "h": 31 + }, + { + "ci": 6, + "region": 1, + "quad": "tr", + "w": 89, + "h": 38 + }, + { + "ci": 8, + "region": 3, + "quad": "tr", + "w": 62, + "h": 58 + } + ], + "outerCore": [ + { + "ci": 0, + "region": 1, + "quad": "tl", + "w": 80, + "h": 19, + "x": "27%", + "y": "0%" + }, + { + "ci": 6, + "region": 2, + "quad": "tr", + "w": 74, + "h": 11, + "x": "73%", + "y": "-1%" + }, + { + "ci": 7, + "region": 3, + "quad": "tr", + "w": 15, + "h": 44, + "x": "100%", + "y": "33%" + }, + { + "ci": 8, + "region": 1, + "quad": "br", + "w": 19, + "h": 38, + "x": "101%", + "y": "72%" + }, + { + "ci": 4, + "region": 2, + "quad": "br", + "w": 84, + "h": 13, + "x": "67%", + "y": "100%" + }, + { + "ci": 1, + "region": 3, + "quad": "bl", + "w": 60, + "h": 21, + "x": "24%", + "y": "101%" + }, + { + "ci": 2, + "region": 1, + "quad": "bl", + "w": 17, + "h": 40, + "x": "0%", + "y": "60%" + }, + { + "ci": 3, + "region": 2, + "quad": "tl", + "w": 13, + "h": 32, + "x": "-1%", + "y": "28%" + } + ], + "outerBloom": [ + { + "ci": 0, + "region": 1, + "quad": "tl", + "w": 110, + "h": 30, + "x": "27%", + "y": "3%" + }, + { + "ci": 6, + "region": 2, + "quad": "tr", + "w": 100, + "h": 20, + "x": "73%", + "y": "1%" + }, + { + "ci": 7, + "region": 3, + "quad": "tr", + "w": 26, + "h": 62, + "x": "100%", + "y": "33%" + }, + { + "ci": 8, + "region": 1, + "quad": "br", + "w": 30, + "h": 56, + "x": "101%", + "y": "72%" + }, + { + "ci": 4, + "region": 2, + "quad": "br", + "w": 120, + "h": 22, + "x": "67%", + "y": "99%" + }, + { + "ci": 1, + "region": 3, + "quad": "bl", + "w": 88, + "h": 32, + "x": "24%", + "y": "99%" + }, + { + "ci": 2, + "region": 1, + "quad": "bl", + "w": 28, + "h": 58, + "x": "0%", + "y": "60%" + } + ], + "innerCornerAccent": { + "sizePx": 60, + "alpha": { + "dark": 0.18, + "light": 0.08 + }, + "fadeStop": 70 + }, + "inner": { + "dark": { + "params": { + "sp": 0.28, + "dr": 33, + "op": 0.48, + "gh": 0.34, + "bs": 1.9, + "ss": 2.6, + "ghs": 2.4, + "huePeriod": 16 + }, + "oscillators": [ + { + "prop": "bw1", + "a": 0.72, + "b": 1.308, + "period": 2.34, + "delay": 0, + "unit": "" + }, + { + "prop": "bh1", + "a": 1.252, + "b": 0.762, + "period": 3.276, + "delay": 0, + "unit": "" + }, + { + "prop": "bx1", + "a": -33, + "b": 29.7, + "period": 3.04, + "delay": 0, + "unit": "px" + }, + { + "prop": "by1", + "a": 18.15, + "b": -23.1, + "period": 3.04, + "delay": 0, + "unit": "px" + }, + { + "prop": "bw2", + "a": 1.28, + "b": 0.762, + "period": 2.86, + "delay": 0, + "unit": "" + }, + { + "prop": "bh2", + "a": 0.776, + "b": 1.294, + "period": 2.106, + "delay": 0, + "unit": "" + }, + { + "prop": "bx2", + "a": 26.4, + "b": -29.7, + "period": 3.572, + "delay": 0, + "unit": "px" + }, + { + "prop": "by2", + "a": -33, + "b": 21.45, + "period": 3.572, + "delay": 0, + "unit": "px" + }, + { + "prop": "bw3", + "a": 0.832, + "b": 1.322, + "period": 2.548, + "delay": 0, + "unit": "" + }, + { + "prop": "bh3", + "a": 1.21, + "b": 0.72, + "period": 3.64, + "delay": 0, + "unit": "" + }, + { + "prop": "bx3", + "a": -19.8, + "b": 33, + "period": 2.755, + "delay": 0, + "unit": "px" + }, + { + "prop": "by3", + "a": -28.05, + "b": 14.85, + "period": 2.755, + "delay": 0, + "unit": "px" + }, + { + "prop": "bgh", + "a": 0.66, + "b": 1.34, + "period": 2.4, + "delay": 0, + "unit": "" + }, + { + "prop": "bop-tl", + "a": 0.52, + "b": 1, + "period": 1.9, + "delay": 0, + "unit": "" + }, + { + "prop": "bop-tr", + "a": 0.52, + "b": 1, + "period": 2.508, + "delay": 0.532, + "unit": "" + }, + { + "prop": "bop-bl", + "a": 0.52, + "b": 1, + "period": 1.596, + "delay": 1.045, + "unit": "" + }, + { + "prop": "bop-br", + "a": 0.52, + "b": 1, + "period": 3.002, + "delay": 1.577, + "unit": "" + } + ], + "frozenBloomAlpha": 0.76 + }, + "light": { + "params": { + "sp": 0.28, + "dr": 40, + "op": 0.45, + "gh": 0.22, + "bs": 2.6, + "ss": 4.6, + "ghs": 5.5, + "huePeriod": 16 + }, + "oscillators": [ + { + "prop": "bw1", + "a": 0.72, + "b": 1.308, + "period": 4.14, + "delay": 0, + "unit": "" + }, + { + "prop": "bh1", + "a": 1.252, + "b": 0.762, + "period": 5.796, + "delay": 0, + "unit": "" + }, + { + "prop": "bx1", + "a": -40, + "b": 36, + "period": 4.16, + "delay": 0, + "unit": "px" + }, + { + "prop": "by1", + "a": 22, + "b": -28, + "period": 4.16, + "delay": 0, + "unit": "px" + }, + { + "prop": "bw2", + "a": 1.28, + "b": 0.762, + "period": 5.06, + "delay": 0, + "unit": "" + }, + { + "prop": "bh2", + "a": 0.776, + "b": 1.294, + "period": 3.726, + "delay": 0, + "unit": "" + }, + { + "prop": "bx2", + "a": 32, + "b": -36, + "period": 4.888, + "delay": 0, + "unit": "px" + }, + { + "prop": "by2", + "a": -40, + "b": 26, + "period": 4.888, + "delay": 0, + "unit": "px" + }, + { + "prop": "bw3", + "a": 0.832, + "b": 1.322, + "period": 4.508, + "delay": 0, + "unit": "" + }, + { + "prop": "bh3", + "a": 1.21, + "b": 0.72, + "period": 6.44, + "delay": 0, + "unit": "" + }, + { + "prop": "bx3", + "a": -24, + "b": 40, + "period": 3.77, + "delay": 0, + "unit": "px" + }, + { + "prop": "by3", + "a": -34, + "b": 18, + "period": 3.77, + "delay": 0, + "unit": "px" + }, + { + "prop": "bgh", + "a": 0.78, + "b": 1.22, + "period": 5.5, + "delay": 0, + "unit": "" + }, + { + "prop": "bop-tl", + "a": 0.55, + "b": 1, + "period": 2.6, + "delay": 0, + "unit": "" + }, + { + "prop": "bop-tr", + "a": 0.55, + "b": 1, + "period": 3.432, + "delay": 0.728, + "unit": "" + }, + { + "prop": "bop-bl", + "a": 0.55, + "b": 1, + "period": 2.184, + "delay": 1.43, + "unit": "" + }, + { + "prop": "bop-br", + "a": 0.55, + "b": 1, + "period": 4.108, + "delay": 2.158, + "unit": "" + } + ], + "frozenBloomAlpha": 0.775 + } + }, + "outside": { + "dark": { + "params": { + "sp": 0.28, + "dr": 14, + "op": 0.46, + "gh": 0.16, + "bs": 2.3, + "ss": 6.4, + "ghs": 2.4, + "huePeriod": 14 + }, + "oscillators": [ + { + "prop": "bw1", + "a": 0.72, + "b": 1.308, + "period": 5.76, + "delay": 0, + "unit": "" + }, + { + "prop": "bh1", + "a": 1.252, + "b": 0.762, + "period": 8.064, + "delay": 0, + "unit": "" + }, + { + "prop": "bx1", + "a": -14, + "b": 12.6, + "period": 3.68, + "delay": 0, + "unit": "px" + }, + { + "prop": "by1", + "a": 7.7, + "b": -9.8, + "period": 3.68, + "delay": 0, + "unit": "px" + }, + { + "prop": "bw2", + "a": 1.28, + "b": 0.762, + "period": 7.04, + "delay": 0, + "unit": "" + }, + { + "prop": "bh2", + "a": 0.776, + "b": 1.294, + "period": 5.184, + "delay": 0, + "unit": "" + }, + { + "prop": "bx2", + "a": 11.2, + "b": -12.6, + "period": 4.324, + "delay": 0, + "unit": "px" + }, + { + "prop": "by2", + "a": -14, + "b": 9.1, + "period": 4.324, + "delay": 0, + "unit": "px" + }, + { + "prop": "bw3", + "a": 0.832, + "b": 1.322, + "period": 6.272, + "delay": 0, + "unit": "" + }, + { + "prop": "bh3", + "a": 1.21, + "b": 0.72, + "period": 8.96, + "delay": 0, + "unit": "" + }, + { + "prop": "bx3", + "a": -8.4, + "b": 14, + "period": 3.335, + "delay": 0, + "unit": "px" + }, + { + "prop": "by3", + "a": -11.9, + "b": 6.3, + "period": 3.335, + "delay": 0, + "unit": "px" + }, + { + "prop": "bgh", + "a": 0.84, + "b": 1.16, + "period": 2.4, + "delay": 0, + "unit": "" + }, + { + "prop": "bop-tl", + "a": 0.54, + "b": 1, + "period": 2.3, + "delay": 0, + "unit": "" + }, + { + "prop": "bop-tr", + "a": 0.54, + "b": 1, + "period": 3.036, + "delay": 0.644, + "unit": "" + }, + { + "prop": "bop-bl", + "a": 0.54, + "b": 1, + "period": 1.932, + "delay": 1.265, + "unit": "" + }, + { + "prop": "bop-br", + "a": 0.54, + "b": 1, + "period": 3.634, + "delay": 1.909, + "unit": "" + } + ], + "frozenBloomAlpha": 0.77 + }, + "light": { + "params": { + "sp": 0.36, + "dr": 19, + "op": 0, + "gh": 0.58, + "bs": 3.7, + "ss": 4.6, + "ghs": 3.8, + "huePeriod": 14 + }, + "oscillators": [ + { + "prop": "bw1", + "a": 0.64, + "b": 1.396, + "period": 4.14, + "delay": 0, + "unit": "" + }, + { + "prop": "bh1", + "a": 1.324, + "b": 0.694, + "period": 5.796, + "delay": 0, + "unit": "" + }, + { + "prop": "bx1", + "a": -19, + "b": 17.1, + "period": 5.92, + "delay": 0, + "unit": "px" + }, + { + "prop": "by1", + "a": 10.45, + "b": -13.3, + "period": 5.92, + "delay": 0, + "unit": "px" + }, + { + "prop": "bw2", + "a": 1.36, + "b": 0.694, + "period": 5.06, + "delay": 0, + "unit": "" + }, + { + "prop": "bh2", + "a": 0.712, + "b": 1.378, + "period": 3.726, + "delay": 0, + "unit": "" + }, + { + "prop": "bx2", + "a": 15.2, + "b": -17.1, + "period": 6.956, + "delay": 0, + "unit": "px" + }, + { + "prop": "by2", + "a": -19, + "b": 12.35, + "period": 6.956, + "delay": 0, + "unit": "px" + }, + { + "prop": "bw3", + "a": 0.784, + "b": 1.414, + "period": 4.508, + "delay": 0, + "unit": "" + }, + { + "prop": "bh3", + "a": 1.27, + "b": 0.64, + "period": 6.44, + "delay": 0, + "unit": "" + }, + { + "prop": "bx3", + "a": -11.4, + "b": 19, + "period": 5.365, + "delay": 0, + "unit": "px" + }, + { + "prop": "by3", + "a": -16.15, + "b": 8.55, + "period": 5.365, + "delay": 0, + "unit": "px" + }, + { + "prop": "bgh", + "a": 0.42, + "b": 1.58, + "period": 3.8, + "delay": 0, + "unit": "" + }, + { + "prop": "bop-tl", + "a": 1, + "b": 1, + "period": 3.7, + "delay": 0, + "unit": "" + }, + { + "prop": "bop-tr", + "a": 1, + "b": 1, + "period": 4.884, + "delay": 1.036, + "unit": "" + }, + { + "prop": "bop-bl", + "a": 1, + "b": 1, + "period": 3.108, + "delay": 2.035, + "unit": "" + }, + { + "prop": "bop-br", + "a": 1, + "b": 1, + "period": 5.846, + "delay": 3.071, + "unit": "" + } + ], + "frozenBloomAlpha": 1 + } + }, + "huePeriod": { + "pulse-inner": 16, + "pulse-outside": 14 + }, + "outsideConstants": { + "glowScale": { + "x": 0.95, + "y": 0.9 + }, + "glowBlurPx": { + "dark": 3, + "light": 6 + }, + "bloomBlurPx": { + "dark": 22.5, + "light": 15 + }, + "coreInsetPx": 10, + "bloomInsetPx": 30, + "referenceSize": { + "w": 350, + "h": 140 + }, + "scaleClamp": { + "min": 0.35, + "max": 4 + }, + "hairline": { + "rgb": { + "dark": [ + 70, + 70, + 70 + ], + "light": [ + 0, + 0, + 0 + ] + } + } + }, + "innerBloomBlurPx": 8, + "oscillatorCurve": "cosinePingPong" + }, + "filters": { + "order": [ + "blur?", + "hue-rotate", + "brightness", + "saturate" + ], + "hueRotateModel": "css-fecolormatrix-huerotate-linearRGB-off" + } +} diff --git a/test/golden/beam_golden_test.dart b/test/golden/beam_golden_test.dart index 1ef9022..50dd5cc 100644 --- a/test/golden/beam_golden_test.dart +++ b/test/golden/beam_golden_test.dart @@ -1,13 +1,16 @@ @Tags(['golden']) library; -import 'package:border_beam/border_beam.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; import 'package:flutter_test/flutter_test.dart'; /// Golden scenes freeze the (fake) test clock at 1.3s after activation — /// past the 0.6s fade-in, mid-cycle for every variant — so each image -/// captures a representative animated frame deterministically. +/// captures a representative animated frame deterministically. The two +/// traveling variants are captured a second time at a later instant +/// (`_late`), because one frame of a sweep says nothing about where the +/// sweep is the rest of the time. /// /// Regenerate with: /// flutter test --update-goldens --tags golden @@ -32,20 +35,25 @@ void main() { ); } - Widget mockSurface(Brightness brightness, {double radius = 16}) => - DecoratedBox( - decoration: BoxDecoration( - color: brightness == Brightness.dark - ? const Color(0xFF1D1D1D) - : const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(radius), - border: Border.all( - color: brightness == Brightness.dark - ? const Color(0x14FFFFFF) - : const Color(0x14000000), - ), - ), - ); + // [radii] carries a per-corner radius when a scene needs one; otherwise + // every corner takes [radius]. + Widget mockSurface( + Brightness brightness, { + double radius = 16, + BorderRadius? radii, + }) => DecoratedBox( + decoration: BoxDecoration( + color: brightness == Brightness.dark + ? const Color(0xFF1D1D1D) + : const Color(0xFFFFFFFF), + borderRadius: radii ?? BorderRadius.circular(radius), + border: Border.all( + color: brightness == Brightness.dark + ? const Color(0x14FFFFFF) + : const Color(0x14000000), + ), + ), + ); Future capture( WidgetTester tester, @@ -54,6 +62,7 @@ void main() { required Brightness brightness, double width = 350, double height = 140, + Duration freeze = const Duration(milliseconds: 1300), }) async { await tester.pumpWidget( scene( @@ -64,7 +73,7 @@ void main() { ), ); await tester.pump(); - await tester.pump(const Duration(milliseconds: 1300)); + await tester.pump(freeze); await expectLater( find.byType(MaterialApp), matchesGoldenFile('goldens/$name.png'), @@ -80,6 +89,8 @@ void main() { for (final MapEntry(key: paletteName, value: colors) in { 'colorful': BeamColors.colorful, 'mono': BeamColors.mono, + 'ocean': BeamColors.ocean, + 'sunset': BeamColors.sunset, }.entries) { testWidgets('rotate $theme $paletteName', (tester) async { await capture( @@ -88,7 +99,7 @@ void main() { brightness: brightness, (b) => BorderBeam.rotate( colors: colors, - theme: beamTheme, + style: BeamStyle(theme: beamTheme), child: mockSurface(b), ), ); @@ -103,7 +114,7 @@ void main() { height: 36, (b) => BorderBeam.small( colors: colors, - theme: beamTheme, + style: BeamStyle(theme: beamTheme), child: mockSurface(b, radius: 32), ), ); @@ -116,7 +127,7 @@ void main() { brightness: brightness, (b) => BorderBeam.line( colors: colors, - theme: beamTheme, + style: BeamStyle(theme: beamTheme), child: mockSurface(b), ), ); @@ -129,7 +140,7 @@ void main() { brightness: brightness, (b) => BorderBeam.pulseInside( colors: colors, - theme: beamTheme, + style: BeamStyle(theme: beamTheme), child: mockSurface(b), ), ); @@ -142,13 +153,45 @@ void main() { brightness: brightness, (b) => BorderBeam.pulseOutside( colors: colors, - theme: beamTheme, + style: BeamStyle(theme: beamTheme), child: mockSurface(b), ), ); }); } + // Second freezes for the traveling variants. rotate's 1.96s cycle puts + // 2.3s at cycle fraction 0.17 (vs 0.66 at 1.3s) — the beam is on the + // opposite side of the border. line's 3.1s cycle puts 2.0s at fraction + // 0.645, still inside the [0.325, 0.675] window where `beam-edge-fade` + // holds at 1, so the beam is at full strength further along its travel + // (x 0.60 vs 0.45 at 1.3s). + testWidgets('rotate $theme colorful late', (tester) async { + await capture( + tester, + 'rotate_${theme}_colorful_late', + brightness: brightness, + freeze: const Duration(milliseconds: 2300), + (b) => BorderBeam.rotate( + style: BeamStyle(theme: beamTheme), + child: mockSurface(b), + ), + ); + }); + + testWidgets('line $theme colorful late', (tester) async { + await capture( + tester, + 'line_${theme}_colorful_late', + brightness: brightness, + freeze: const Duration(milliseconds: 2000), + (b) => BorderBeam.line( + style: BeamStyle(theme: beamTheme), + child: mockSurface(b), + ), + ); + }); + testWidgets('rotate $theme ocean superellipse', (tester) async { await capture( tester, @@ -156,8 +199,8 @@ void main() { brightness: brightness, (b) => BorderBeam.rotate( colors: BeamColors.ocean, - theme: beamTheme, - useSuperellipse: true, + style: BeamStyle(theme: beamTheme), + shape: const BeamShape(superellipse: true), borderRadius: 28, child: mockSurface(b, radius: 28), ), @@ -176,9 +219,72 @@ void main() { Color(0xFF00E5FF), Color(0xFFFFC400), ]), - theme: BeamTheme.dark, + style: const BeamStyle(theme: BeamTheme.dark), child: mockSurface(b), ), ); }); + + // Per-corner and stadium geometry, dark/colorful only: the shape is what + // these scenes are about, so one palette is enough. + const cornerRadii = BorderRadius.only( + topLeft: Radius.circular(40), + bottomRight: Radius.circular(40), + ); + + testWidgets('rotate dark colorful per-corner radii', (tester) async { + await capture( + tester, + 'rotate_dark_colorful_corners', + brightness: Brightness.dark, + (b) => BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark), + shape: const BeamShape(radius: cornerRadii), + child: mockSurface(b, radii: cornerRadii), + ), + ); + }); + + testWidgets('rotate dark colorful per-corner superellipse', (tester) async { + await capture( + tester, + 'rotate_dark_colorful_corners_squircle', + brightness: Brightness.dark, + (b) => BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark), + shape: const BeamShape(radius: cornerRadii, superellipse: true), + child: mockSurface(b, radii: cornerRadii), + ), + ); + }); + + // A 350x60 box: the stadium radius clamps to 30 on every corner, so the + // beam travels a pill and the surface matches it. + testWidgets('rotate dark colorful stadium', (tester) async { + await capture( + tester, + 'rotate_dark_colorful_stadium', + brightness: Brightness.dark, + height: 60, + (b) => BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark), + shape: const BeamShape.stadium(), + child: mockSurface(b, radius: 30), + ), + ); + }); + + testWidgets('line dark colorful stadium', (tester) async { + await capture( + tester, + 'line_dark_colorful_stadium', + brightness: Brightness.dark, + height: 60, + (b) => BorderBeam.line( + style: const BeamStyle(theme: BeamTheme.dark), + shape: const BeamShape.stadium(), + child: mockSurface(b, radius: 30), + ), + ); + }); } diff --git a/test/golden/goldens/line_dark_colorful_late.png b/test/golden/goldens/line_dark_colorful_late.png new file mode 100644 index 0000000..ca5d780 Binary files /dev/null and b/test/golden/goldens/line_dark_colorful_late.png differ diff --git a/test/golden/goldens/line_dark_colorful_stadium.png b/test/golden/goldens/line_dark_colorful_stadium.png new file mode 100644 index 0000000..f8a2fae Binary files /dev/null and b/test/golden/goldens/line_dark_colorful_stadium.png differ diff --git a/test/golden/goldens/line_dark_ocean.png b/test/golden/goldens/line_dark_ocean.png new file mode 100644 index 0000000..80d34ff Binary files /dev/null and b/test/golden/goldens/line_dark_ocean.png differ diff --git a/test/golden/goldens/line_dark_sunset.png b/test/golden/goldens/line_dark_sunset.png new file mode 100644 index 0000000..a4189ff Binary files /dev/null and b/test/golden/goldens/line_dark_sunset.png differ diff --git a/test/golden/goldens/line_light_colorful_late.png b/test/golden/goldens/line_light_colorful_late.png new file mode 100644 index 0000000..2a8d7fd Binary files /dev/null and b/test/golden/goldens/line_light_colorful_late.png differ diff --git a/test/golden/goldens/line_light_ocean.png b/test/golden/goldens/line_light_ocean.png new file mode 100644 index 0000000..543c62d Binary files /dev/null and b/test/golden/goldens/line_light_ocean.png differ diff --git a/test/golden/goldens/line_light_sunset.png b/test/golden/goldens/line_light_sunset.png new file mode 100644 index 0000000..85490f1 Binary files /dev/null and b/test/golden/goldens/line_light_sunset.png differ diff --git a/test/golden/goldens/motion_line_reverse.png b/test/golden/goldens/motion_line_reverse.png new file mode 100644 index 0000000..de172d5 Binary files /dev/null and b/test/golden/goldens/motion_line_reverse.png differ diff --git a/test/golden/goldens/motion_progress_0_5.png b/test/golden/goldens/motion_progress_0_5.png new file mode 100644 index 0000000..f7066a6 Binary files /dev/null and b/test/golden/goldens/motion_progress_0_5.png differ diff --git a/test/golden/goldens/motion_rotate_beamcount2.png b/test/golden/goldens/motion_rotate_beamcount2.png new file mode 100644 index 0000000..d191e36 Binary files /dev/null and b/test/golden/goldens/motion_rotate_beamcount2.png differ diff --git a/test/golden/goldens/motion_rotate_reverse.png b/test/golden/goldens/motion_rotate_reverse.png new file mode 100644 index 0000000..f4db45c Binary files /dev/null and b/test/golden/goldens/motion_rotate_reverse.png differ diff --git a/test/golden/goldens/palette_aurora.png b/test/golden/goldens/palette_aurora.png new file mode 100644 index 0000000..ac36fd7 Binary files /dev/null and b/test/golden/goldens/palette_aurora.png differ diff --git a/test/golden/goldens/palette_candy.png b/test/golden/goldens/palette_candy.png new file mode 100644 index 0000000..574a51f Binary files /dev/null and b/test/golden/goldens/palette_candy.png differ diff --git a/test/golden/goldens/palette_ember.png b/test/golden/goldens/palette_ember.png new file mode 100644 index 0000000..e642597 Binary files /dev/null and b/test/golden/goldens/palette_ember.png differ diff --git a/test/golden/goldens/palette_from_seed.png b/test/golden/goldens/palette_from_seed.png new file mode 100644 index 0000000..255f983 Binary files /dev/null and b/test/golden/goldens/palette_from_seed.png differ diff --git a/test/golden/goldens/palette_gold.png b/test/golden/goldens/palette_gold.png new file mode 100644 index 0000000..14329d7 Binary files /dev/null and b/test/golden/goldens/palette_gold.png differ diff --git a/test/golden/goldens/palette_holographic.png b/test/golden/goldens/palette_holographic.png new file mode 100644 index 0000000..2e74193 Binary files /dev/null and b/test/golden/goldens/palette_holographic.png differ diff --git a/test/golden/goldens/palette_ice.png b/test/golden/goldens/palette_ice.png new file mode 100644 index 0000000..64d26aa Binary files /dev/null and b/test/golden/goldens/palette_ice.png differ diff --git a/test/golden/goldens/palette_lerp_ocean_sunset.png b/test/golden/goldens/palette_lerp_ocean_sunset.png new file mode 100644 index 0000000..8927a17 Binary files /dev/null and b/test/golden/goldens/palette_lerp_ocean_sunset.png differ diff --git a/test/golden/goldens/palette_neon.png b/test/golden/goldens/palette_neon.png new file mode 100644 index 0000000..3e94e4b Binary files /dev/null and b/test/golden/goldens/palette_neon.png differ diff --git a/test/golden/goldens/pulse_inside_dark_ocean.png b/test/golden/goldens/pulse_inside_dark_ocean.png new file mode 100644 index 0000000..c9a4b3b Binary files /dev/null and b/test/golden/goldens/pulse_inside_dark_ocean.png differ diff --git a/test/golden/goldens/pulse_inside_dark_sunset.png b/test/golden/goldens/pulse_inside_dark_sunset.png new file mode 100644 index 0000000..ba8ed96 Binary files /dev/null and b/test/golden/goldens/pulse_inside_dark_sunset.png differ diff --git a/test/golden/goldens/pulse_inside_light_ocean.png b/test/golden/goldens/pulse_inside_light_ocean.png new file mode 100644 index 0000000..bf918af Binary files /dev/null and b/test/golden/goldens/pulse_inside_light_ocean.png differ diff --git a/test/golden/goldens/pulse_inside_light_sunset.png b/test/golden/goldens/pulse_inside_light_sunset.png new file mode 100644 index 0000000..e42dda0 Binary files /dev/null and b/test/golden/goldens/pulse_inside_light_sunset.png differ diff --git a/test/golden/goldens/pulse_outside_dark_ocean.png b/test/golden/goldens/pulse_outside_dark_ocean.png new file mode 100644 index 0000000..2001630 Binary files /dev/null and b/test/golden/goldens/pulse_outside_dark_ocean.png differ diff --git a/test/golden/goldens/pulse_outside_dark_sunset.png b/test/golden/goldens/pulse_outside_dark_sunset.png new file mode 100644 index 0000000..35d46c2 Binary files /dev/null and b/test/golden/goldens/pulse_outside_dark_sunset.png differ diff --git a/test/golden/goldens/pulse_outside_light_ocean.png b/test/golden/goldens/pulse_outside_light_ocean.png new file mode 100644 index 0000000..4db2818 Binary files /dev/null and b/test/golden/goldens/pulse_outside_light_ocean.png differ diff --git a/test/golden/goldens/pulse_outside_light_sunset.png b/test/golden/goldens/pulse_outside_light_sunset.png new file mode 100644 index 0000000..d8102d9 Binary files /dev/null and b/test/golden/goldens/pulse_outside_light_sunset.png differ diff --git a/test/golden/goldens/research_pulse_inside_inner_scale_0_6.png b/test/golden/goldens/research_pulse_inside_inner_scale_0_6.png new file mode 100644 index 0000000..0f61c20 Binary files /dev/null and b/test/golden/goldens/research_pulse_inside_inner_scale_0_6.png differ diff --git a/test/golden/goldens/research_pulse_outside_stock_dark.png b/test/golden/goldens/research_pulse_outside_stock_dark.png new file mode 100644 index 0000000..8e8162e Binary files /dev/null and b/test/golden/goldens/research_pulse_outside_stock_dark.png differ diff --git a/test/golden/goldens/research_pulse_outside_stock_light.png b/test/golden/goldens/research_pulse_outside_stock_light.png new file mode 100644 index 0000000..111d01f Binary files /dev/null and b/test/golden/goldens/research_pulse_outside_stock_light.png differ diff --git a/test/golden/goldens/research_rotate_large_default.png b/test/golden/goldens/research_rotate_large_default.png new file mode 100644 index 0000000..4377a9f Binary files /dev/null and b/test/golden/goldens/research_rotate_large_default.png differ diff --git a/test/golden/goldens/research_rotate_render_scale_0_5.png b/test/golden/goldens/research_rotate_render_scale_0_5.png new file mode 100644 index 0000000..8e1245e Binary files /dev/null and b/test/golden/goldens/research_rotate_render_scale_0_5.png differ diff --git a/test/golden/goldens/rotate_dark_colorful_corners.png b/test/golden/goldens/rotate_dark_colorful_corners.png new file mode 100644 index 0000000..765c6f5 Binary files /dev/null and b/test/golden/goldens/rotate_dark_colorful_corners.png differ diff --git a/test/golden/goldens/rotate_dark_colorful_corners_squircle.png b/test/golden/goldens/rotate_dark_colorful_corners_squircle.png new file mode 100644 index 0000000..cb82e9f Binary files /dev/null and b/test/golden/goldens/rotate_dark_colorful_corners_squircle.png differ diff --git a/test/golden/goldens/rotate_dark_colorful_late.png b/test/golden/goldens/rotate_dark_colorful_late.png new file mode 100644 index 0000000..a386550 Binary files /dev/null and b/test/golden/goldens/rotate_dark_colorful_late.png differ diff --git a/test/golden/goldens/rotate_dark_colorful_stadium.png b/test/golden/goldens/rotate_dark_colorful_stadium.png new file mode 100644 index 0000000..6bdaefc Binary files /dev/null and b/test/golden/goldens/rotate_dark_colorful_stadium.png differ diff --git a/test/golden/goldens/rotate_dark_ocean.png b/test/golden/goldens/rotate_dark_ocean.png new file mode 100644 index 0000000..5a3223b Binary files /dev/null and b/test/golden/goldens/rotate_dark_ocean.png differ diff --git a/test/golden/goldens/rotate_dark_sunset.png b/test/golden/goldens/rotate_dark_sunset.png new file mode 100644 index 0000000..6c25e60 Binary files /dev/null and b/test/golden/goldens/rotate_dark_sunset.png differ diff --git a/test/golden/goldens/rotate_light_colorful_late.png b/test/golden/goldens/rotate_light_colorful_late.png new file mode 100644 index 0000000..8307de2 Binary files /dev/null and b/test/golden/goldens/rotate_light_colorful_late.png differ diff --git a/test/golden/goldens/rotate_light_ocean.png b/test/golden/goldens/rotate_light_ocean.png new file mode 100644 index 0000000..727e24d Binary files /dev/null and b/test/golden/goldens/rotate_light_ocean.png differ diff --git a/test/golden/goldens/rotate_light_sunset.png b/test/golden/goldens/rotate_light_sunset.png new file mode 100644 index 0000000..a49d51e Binary files /dev/null and b/test/golden/goldens/rotate_light_sunset.png differ diff --git a/test/golden/goldens/segment_line_bottom_half.png b/test/golden/goldens/segment_line_bottom_half.png new file mode 100644 index 0000000..c8b7d70 Binary files /dev/null and b/test/golden/goldens/segment_line_bottom_half.png differ diff --git a/test/golden/goldens/segment_line_bottom_half_late.png b/test/golden/goldens/segment_line_bottom_half_late.png new file mode 100644 index 0000000..e182edb Binary files /dev/null and b/test/golden/goldens/segment_line_bottom_half_late.png differ diff --git a/test/golden/goldens/segment_line_wrap_corners.png b/test/golden/goldens/segment_line_wrap_corners.png new file mode 100644 index 0000000..f5f2b23 Binary files /dev/null and b/test/golden/goldens/segment_line_wrap_corners.png differ diff --git a/test/golden/goldens/segment_pulse_inside_bottom_half.png b/test/golden/goldens/segment_pulse_inside_bottom_half.png new file mode 100644 index 0000000..95c588c Binary files /dev/null and b/test/golden/goldens/segment_pulse_inside_bottom_half.png differ diff --git a/test/golden/goldens/segment_pulse_outside_bottom_half.png b/test/golden/goldens/segment_pulse_outside_bottom_half.png new file mode 100644 index 0000000..f460b23 Binary files /dev/null and b/test/golden/goldens/segment_pulse_outside_bottom_half.png differ diff --git a/test/golden/goldens/segment_rotate_bottom_half.png b/test/golden/goldens/segment_rotate_bottom_half.png new file mode 100644 index 0000000..6cba8fe Binary files /dev/null and b/test/golden/goldens/segment_rotate_bottom_half.png differ diff --git a/test/golden/goldens/segment_rotate_bottom_half_late.png b/test/golden/goldens/segment_rotate_bottom_half_late.png new file mode 100644 index 0000000..35aa876 Binary files /dev/null and b/test/golden/goldens/segment_rotate_bottom_half_late.png differ diff --git a/test/golden/goldens/segment_rotate_feather_0.png b/test/golden/goldens/segment_rotate_feather_0.png new file mode 100644 index 0000000..d839248 Binary files /dev/null and b/test/golden/goldens/segment_rotate_feather_0.png differ diff --git a/test/golden/goldens/segment_rotate_top_edge.png b/test/golden/goldens/segment_rotate_top_edge.png new file mode 100644 index 0000000..516c7a2 Binary files /dev/null and b/test/golden/goldens/segment_rotate_top_edge.png differ diff --git a/test/golden/goldens/segment_small_bottom_half.png b/test/golden/goldens/segment_small_bottom_half.png new file mode 100644 index 0000000..b8aff49 Binary files /dev/null and b/test/golden/goldens/segment_small_bottom_half.png differ diff --git a/test/golden/goldens/small_dark_ocean.png b/test/golden/goldens/small_dark_ocean.png new file mode 100644 index 0000000..458dff9 Binary files /dev/null and b/test/golden/goldens/small_dark_ocean.png differ diff --git a/test/golden/goldens/small_dark_sunset.png b/test/golden/goldens/small_dark_sunset.png new file mode 100644 index 0000000..b1ae6b1 Binary files /dev/null and b/test/golden/goldens/small_dark_sunset.png differ diff --git a/test/golden/goldens/small_light_ocean.png b/test/golden/goldens/small_light_ocean.png new file mode 100644 index 0000000..f968948 Binary files /dev/null and b/test/golden/goldens/small_light_ocean.png differ diff --git a/test/golden/goldens/small_light_sunset.png b/test/golden/goldens/small_light_sunset.png new file mode 100644 index 0000000..e9faefa Binary files /dev/null and b/test/golden/goldens/small_light_sunset.png differ diff --git a/test/golden/goldens/surface_line_edge_left.png b/test/golden/goldens/surface_line_edge_left.png new file mode 100644 index 0000000..85a5cc0 Binary files /dev/null and b/test/golden/goldens/surface_line_edge_left.png differ diff --git a/test/golden/goldens/surface_line_edge_top.png b/test/golden/goldens/surface_line_edge_top.png new file mode 100644 index 0000000..038438f Binary files /dev/null and b/test/golden/goldens/surface_line_edge_top.png differ diff --git a/test/golden/goldens/surface_pulse_outside_glowspread2.png b/test/golden/goldens/surface_pulse_outside_glowspread2.png new file mode 100644 index 0000000..700b0bc Binary files /dev/null and b/test/golden/goldens/surface_pulse_outside_glowspread2.png differ diff --git a/test/golden/goldens/surface_rotate_beamcount3.png b/test/golden/goldens/surface_rotate_beamcount3.png new file mode 100644 index 0000000..79ad658 Binary files /dev/null and b/test/golden/goldens/surface_rotate_beamcount3.png differ diff --git a/test/golden/goldens/surface_rotate_comet.png b/test/golden/goldens/surface_rotate_comet.png new file mode 100644 index 0000000..237813c Binary files /dev/null and b/test/golden/goldens/surface_rotate_comet.png differ diff --git a/test/golden/goldens/surface_rotate_contour_star.png b/test/golden/goldens/surface_rotate_contour_star.png new file mode 100644 index 0000000..a53fcf1 Binary files /dev/null and b/test/golden/goldens/surface_rotate_contour_star.png differ diff --git a/test/golden/goldens/surface_rotate_glowspread2.png b/test/golden/goldens/surface_rotate_glowspread2.png new file mode 100644 index 0000000..fa13810 Binary files /dev/null and b/test/golden/goldens/surface_rotate_glowspread2.png differ diff --git a/test/golden/goldens/surface_rotate_ringoffset8.png b/test/golden/goldens/surface_rotate_ringoffset8.png new file mode 100644 index 0000000..ec95db6 Binary files /dev/null and b/test/golden/goldens/surface_rotate_ringoffset8.png differ diff --git a/test/golden/goldens/surface_rotate_segments8.png b/test/golden/goldens/surface_rotate_segments8.png new file mode 100644 index 0000000..262ed3b Binary files /dev/null and b/test/golden/goldens/surface_rotate_segments8.png differ diff --git a/test/golden/goldens/surface_rotate_sparkle.png b/test/golden/goldens/surface_rotate_sparkle.png new file mode 100644 index 0000000..b9cb6cc Binary files /dev/null and b/test/golden/goldens/surface_rotate_sparkle.png differ diff --git a/test/golden/goldens/surface_rotate_tail2.png b/test/golden/goldens/surface_rotate_tail2.png new file mode 100644 index 0000000..371cd70 Binary files /dev/null and b/test/golden/goldens/surface_rotate_tail2.png differ diff --git a/test/golden/goldens/widgets_decoration_rotate.png b/test/golden/goldens/widgets_decoration_rotate.png new file mode 100644 index 0000000..583837f Binary files /dev/null and b/test/golden/goldens/widgets_decoration_rotate.png differ diff --git a/test/golden/goldens/widgets_focus_ring_active.png b/test/golden/goldens/widgets_focus_ring_active.png new file mode 100644 index 0000000..1e8bc77 Binary files /dev/null and b/test/golden/goldens/widgets_focus_ring_active.png differ diff --git a/test/golden/motion_golden_test.dart b/test/golden/motion_golden_test.dart new file mode 100644 index 0000000..e5b88dc --- /dev/null +++ b/test/golden/motion_golden_test.dart @@ -0,0 +1,111 @@ +@Tags(['golden']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Golden scenes for the motion options — travel direction, several beams on +/// one contour, and a sweep driven from a value instead of the clock. +/// +/// They share the frozen-clock convention of `beam_golden_test.dart` (dark +/// theme, colorful palette, t = 1.3s) so each image can be read against the +/// plain `rotate_dark_colorful` / `line_dark_colorful` frame it varies. +/// +/// Regenerate with: +/// flutter test --update-goldens --tags golden +void main() { + Widget scene({ + required Widget beam, + double width = 350, + double height = 140, + }) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: Brightness.dark), + home: ColoredBox( + // The demo backdrop of the source library. + color: const Color(0xFF070707), + child: Center( + child: SizedBox(width: width, height: height, child: beam), + ), + ), + ); + + Widget mockSurface({double radius = 16}) => DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF1D1D1D), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: const Color(0x14FFFFFF)), + ), + ); + + Future capture( + WidgetTester tester, + String name, + Widget beam, { + Duration freeze = const Duration(milliseconds: 1300), + }) async { + await tester.pumpWidget(scene(beam: beam)); + await tester.pump(); + await tester.pump(freeze); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/$name.png'), + ); + } + + // The mirror of `rotate_dark_colorful`: same instant, same hue, the head + // the same distance round the border — the other way. + testWidgets('rotate reverse', (tester) async { + await capture( + tester, + 'motion_rotate_reverse', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark), + timing: const BeamTiming(direction: BeamDirection.reverse), + child: mockSurface(), + ), + ); + }); + + // Two beams half a cycle apart: opposite sides of the border at every + // instant. + testWidgets('rotate with two beams', (tester) async { + await capture( + tester, + 'motion_rotate_beamcount2', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark), + timing: const BeamTiming(beamCount: 2), + child: mockSurface(), + ), + ); + }); + + // A driven sweep: the beam sits at half its travel no matter how long the + // clock has run, which is what makes rotate readable as a progress ring. + testWidgets('rotate driven to half progress', (tester) async { + await capture( + tester, + 'motion_progress_0_5', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark), + progress: 0.5, + child: mockSurface(), + ), + ); + }); + + // The line beam travelling right-to-left, against `line_dark_colorful`. + testWidgets('line reverse', (tester) async { + await capture( + tester, + 'motion_line_reverse', + BorderBeam.line( + style: const BeamStyle(theme: BeamTheme.dark), + timing: const BeamTiming(direction: BeamDirection.reverse), + child: mockSurface(), + ), + ); + }); +} diff --git a/test/golden/palette_golden_test.dart b/test/golden/palette_golden_test.dart new file mode 100644 index 0000000..36516ae --- /dev/null +++ b/test/golden/palette_golden_test.dart @@ -0,0 +1,92 @@ +@Tags(['golden']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// One scene per palette the Flutter port adds on top of the React source's +/// four, plus the two derivation entry points ([BeamColors.fromSeed] and +/// [BeamColors.lerp]). +/// +/// Every scene uses the same frame the variant goldens do — rotate, dark +/// theme, clock frozen at 1.3s — so the only thing that varies between these +/// images is the palette, which is what they are here to pin. +/// +/// Regenerate with: +/// flutter test --update-goldens --tags golden +void main() { + Widget scene(Widget beam) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: Brightness.dark), + home: ColoredBox( + // The demo backdrop of the source library. + color: const Color(0xFF070707), + child: Center(child: SizedBox(width: 350, height: 140, child: beam)), + ), + ); + + Widget mockSurface() => DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF1D1D1D), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0x14FFFFFF)), + ), + ); + + Future capture( + WidgetTester tester, + String name, + BeamColors colors, + ) async { + await tester.pumpWidget( + scene( + BorderBeam.rotate( + colors: colors, + style: const BeamStyle(theme: BeamTheme.dark), + child: mockSurface(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/palette_$name.png'), + ); + } + + for (final MapEntry(key: name, value: colors) in const { + 'aurora': BeamColors.aurora, + 'neon': BeamColors.neon, + 'candy': BeamColors.candy, + 'ember': BeamColors.ember, + 'ice': BeamColors.ice, + 'gold': BeamColors.gold, + 'holographic': BeamColors.holographic, + }.entries) { + testWidgets('rotate dark $name', (tester) async { + await capture(tester, name, colors); + }); + } + + // The brand blue of the package's own demo, spread by the default + // (analogous) harmony. + testWidgets('rotate dark fromSeed', (tester) async { + await capture( + tester, + 'from_seed', + const BeamColors.fromSeed(Color(0xFF18A8F0)), + ); + }); + + // Halfway between two source presets: neither end's colors, both ends' + // geometry (they share it). + testWidgets('rotate dark lerp ocean to sunset', (tester) async { + await capture( + tester, + 'lerp_ocean_sunset', + const BeamColors.lerp(BeamColors.ocean, BeamColors.sunset, 0.5), + ); + }); +} diff --git a/test/golden/research_golden_test.dart b/test/golden/research_golden_test.dart new file mode 100644 index 0000000..6e2a9f7 --- /dev/null +++ b/test/golden/research_golden_test.dart @@ -0,0 +1,140 @@ +@Tags(['golden']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Golden scenes for the options ported from the reference implementations: +/// the stock pulse-outside look, the pulse-inside wash scale, and the +/// render-scale magnification. +/// +/// They share the frozen-clock convention of `beam_golden_test.dart` +/// (colorful palette, t = 1.3s), so each image can be read against the plain +/// frame it varies — `pulse_outside_*_colorful` for the stock pair, +/// `pulse_inside_dark_colorful` for the wash scale, and the large default +/// captured here for the render scale. +/// +/// Regenerate with: +/// flutter test --update-goldens --tags golden +void main() { + Widget scene({ + required Widget beam, + required Brightness brightness, + double width = 350, + double height = 140, + }) { + final isDark = brightness == Brightness.dark; + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: brightness), + home: ColoredBox( + // The demo backdrops of the source library. + color: isDark ? const Color(0xFF070707) : const Color(0xFFFDFDFD), + child: Center( + child: SizedBox(width: width, height: height, child: beam), + ), + ), + ); + } + + Widget mockSurface(Brightness brightness, {double radius = 16}) => + DecoratedBox( + decoration: BoxDecoration( + color: brightness == Brightness.dark + ? const Color(0xFF1D1D1D) + : const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: brightness == Brightness.dark + ? const Color(0x14FFFFFF) + : const Color(0x14000000), + ), + ), + ); + + Future capture( + WidgetTester tester, + String name, + Widget beam, { + Brightness brightness = Brightness.dark, + double width = 350, + double height = 140, + }) async { + await tester.pumpWidget( + scene(beam: beam, brightness: brightness, width: width, height: height), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/$name.png'), + ); + } + + // The React library's own pulse-outside defaults: a tighter, dimmer halo + // than the demo recipe every other pulse-outside golden captures. + for (final brightness in Brightness.values) { + final theme = brightness == Brightness.dark ? 'dark' : 'light'; + testWidgets('pulse-outside stock $theme', (tester) async { + await capture( + tester, + 'research_pulse_outside_stock_$theme', + BorderBeam.pulseOutside( + style: BeamStyle.pulseOutsideStock.copyWith( + theme: brightness == Brightness.dark + ? BeamTheme.dark + : BeamTheme.light, + ), + child: mockSurface(brightness), + ), + brightness: brightness, + ); + }); + } + + // The inward wash at 0.6: the same border, less flood. + testWidgets('pulse-inside inner scale 0.6', (tester) async { + await capture( + tester, + 'research_pulse_inside_inner_scale_0_6', + BorderBeam.pulseInside( + style: const BeamStyle(theme: BeamTheme.dark, innerSizeScale: 0.6), + child: mockSurface(Brightness.dark), + ), + ); + }); + + // The render-scale pair, on a box twice the width the palettes were drawn + // for: the default frame is the reference, the scaled one is the same beam + // painted at half size and magnified back. + testWidgets('rotate on a large box', (tester) async { + await capture( + tester, + 'research_rotate_large_default', + BorderBeam.rotate( + borderRadius: 24, + style: const BeamStyle(theme: BeamTheme.dark), + child: mockSurface(Brightness.dark, radius: 24), + ), + width: 700, + height: 280, + ); + }); + + testWidgets('rotate on a large box at render scale 0.5', (tester) async { + await capture( + tester, + 'research_rotate_render_scale_0_5', + BorderBeam.rotate( + // The radius is a box-relative length: scaledBy halves it for the + // small canvas, so it still lands on the child's 24 once magnified. + borderRadius: 24, + style: const BeamStyle(theme: BeamTheme.dark, renderScale: 0.5), + child: mockSurface(Brightness.dark, radius: 24), + ), + width: 700, + height: 280, + ); + }); +} diff --git a/test/golden/segment_golden_test.dart b/test/golden/segment_golden_test.dart new file mode 100644 index 0000000..277f527 --- /dev/null +++ b/test/golden/segment_golden_test.dart @@ -0,0 +1,182 @@ +@Tags(['golden']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Golden scenes for partial-perimeter beams and corner-wrapped line travel. +/// +/// Regenerate on the pinned macOS Flutter SDK with: +/// flutter test --update-goldens --tags golden +void main() { + Widget surface() => DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF1D1D1D), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0x14FFFFFF)), + ), + ); + + Widget scene(Widget beam) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: Brightness.dark), + home: ColoredBox( + color: const Color(0xFF070707), + child: Center(child: SizedBox.square(dimension: 200, child: beam)), + ), + ); + + Future capture( + WidgetTester tester, + String name, + Widget beam, { + Duration freeze = const Duration(milliseconds: 1300), + }) async { + await tester.pumpWidget(scene(beam)); + await tester.pump(); + await tester.pump(freeze); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/$name.png'), + ); + } + + const style = BeamStyle(theme: BeamTheme.dark); + BeamShape shape(BeamSegment segment) => BeamShape( + radius: const BorderRadius.all(Radius.circular(24)), + segment: segment, + ); + + testWidgets('rotate bottom half', (tester) async { + await capture( + tester, + 'segment_rotate_bottom_half', + BorderBeam.rotate( + style: style, + shape: shape(BeamSegment.bottomHalf), + child: surface(), + ), + ); + }); + + testWidgets('rotate bottom half late', (tester) async { + await capture( + tester, + 'segment_rotate_bottom_half_late', + BorderBeam.rotate( + style: style, + shape: shape(BeamSegment.bottomHalf), + child: surface(), + ), + freeze: const Duration(milliseconds: 2300), + ); + }); + + testWidgets('small bottom half', (tester) async { + await capture( + tester, + 'segment_small_bottom_half', + BorderBeam.small( + style: style, + shape: shape(BeamSegment.bottomHalf), + child: surface(), + ), + ); + }); + + testWidgets('line bottom half', (tester) async { + await capture( + tester, + 'segment_line_bottom_half', + BorderBeam.line( + style: style, + shape: shape(BeamSegment.bottomHalf), + child: surface(), + ), + ); + }); + + testWidgets('line bottom half late', (tester) async { + await capture( + tester, + 'segment_line_bottom_half_late', + BorderBeam.line( + style: style, + shape: shape(BeamSegment.bottomHalf), + child: surface(), + ), + freeze: const Duration(milliseconds: 2000), + ); + }); + + testWidgets('line wraps bottom corners', (tester) async { + await capture( + tester, + 'segment_line_wrap_corners', + BorderBeam.line( + style: style, + shape: const BeamShape( + radius: BorderRadius.all(Radius.circular(24)), + edge: BeamEdge.bottom, + wrapCorners: true, + ), + child: surface(), + ), + ); + }); + + testWidgets('pulse inside bottom half', (tester) async { + await capture( + tester, + 'segment_pulse_inside_bottom_half', + BorderBeam.pulseInside( + style: style, + shape: shape(BeamSegment.bottomHalf), + child: surface(), + ), + ); + }); + + testWidgets('pulse outside bottom half', (tester) async { + await capture( + tester, + 'segment_pulse_outside_bottom_half', + BorderBeam.pulseOutside( + style: style, + shape: shape(BeamSegment.bottomHalf), + child: surface(), + ), + ); + }); + + testWidgets('rotate top edge', (tester) async { + await capture( + tester, + 'segment_rotate_top_edge', + BorderBeam.rotate( + style: style, + shape: shape(BeamSegment.topEdge), + child: surface(), + ), + ); + }); + + testWidgets('rotate hard segment edge', (tester) async { + await capture( + tester, + 'segment_rotate_feather_0', + BorderBeam.rotate( + style: style, + shape: shape( + const BeamSegment( + start: BeamAnchor.rightCenter, + end: BeamAnchor.leftCenter, + feather: 0, + ), + ), + child: surface(), + ), + ); + }); +} diff --git a/test/golden/surface_golden_test.dart b/test/golden/surface_golden_test.dart new file mode 100644 index 0000000..167fdc4 --- /dev/null +++ b/test/golden/surface_golden_test.dart @@ -0,0 +1,212 @@ +@Tags(['golden']) +library; + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Golden scenes for the surface options — the shape, travel, and glow +/// controls that change what a beam looks like without changing which variant +/// it is. +/// +/// Same recipe as the variant goldens: the fake clock is frozen 1.3s after +/// activation, past the fade-in and mid-cycle. Every scene is the dark theme +/// with the colorful palette unless its name says otherwise, so the option +/// under test is the only thing that moves between images. +/// +/// Regenerate with: +/// flutter test --update-goldens --tags golden +void main() { + Widget scene({ + required Widget beam, + double width = 350, + double height = 140, + }) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: Brightness.dark), + home: ColoredBox( + // The demo backdrop of the source library. + color: const Color(0xFF070707), + child: Center( + child: SizedBox(width: width, height: height, child: beam), + ), + ), + ); + + Widget mockSurface({double radius = 16}) => DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF1D1D1D), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: const Color(0x14FFFFFF)), + ), + ); + + Future capture( + WidgetTester tester, + String name, + Widget beam, { + double width = 350, + double height = 140, + }) async { + await tester.pumpWidget(scene(beam: beam, width: width, height: height)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/$name.png'), + ); + } + + testWidgets('line on the top edge', (tester) async { + await capture( + tester, + 'surface_line_edge_top', + BorderBeam.line( + style: const BeamStyle(theme: BeamTheme.dark), + shape: const BeamShape(edge: BeamEdge.top), + child: mockSurface(), + ), + ); + }); + + testWidgets('line on the left edge', (tester) async { + await capture( + tester, + 'surface_line_edge_left', + BorderBeam.line( + style: const BeamStyle(theme: BeamTheme.dark), + shape: const BeamShape(edge: BeamEdge.left), + child: mockSurface(), + ), + ); + }); + + testWidgets('rotate with three beams', (tester) async { + await capture( + tester, + 'surface_rotate_beamcount3', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark), + timing: const BeamTiming(beamCount: 3), + child: mockSurface(), + ), + ); + }); + + testWidgets('rotate with a double-length tail', (tester) async { + await capture( + tester, + 'surface_rotate_tail2', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark, tailLength: 2), + child: mockSurface(), + ), + ); + }); + + testWidgets('rotate with a comet halo', (tester) async { + await capture( + tester, + 'surface_rotate_comet', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark, comet: true), + child: mockSurface(), + ), + ); + }); + + testWidgets('rotate with sparkles', (tester) async { + await capture( + tester, + 'surface_rotate_sparkle', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark, sparkle: 1), + child: mockSurface(), + ), + ); + }); + + testWidgets('rotate on a dashed ring', (tester) async { + await capture( + tester, + 'surface_rotate_segments8', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark, segments: 8), + child: mockSurface(), + ), + ); + }); + + testWidgets('rotate with a wider glow', (tester) async { + await capture( + tester, + 'surface_rotate_glowspread2', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark, glowSpread: 2), + child: mockSurface(), + ), + ); + }); + + testWidgets('rotate with the ring pushed out', (tester) async { + await capture( + tester, + 'surface_rotate_ringoffset8', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark), + shape: const BeamShape( + ringOffset: 8, + radius: BorderRadius.all(Radius.circular(16)), + ), + child: mockSurface(), + ), + ); + }); + + // A square box, so the inscribed star is as large as it can be, and no + // surface behind it: the contour is the only thing the beam draws. + testWidgets('rotate around a star contour', (tester) async { + await capture( + tester, + 'surface_rotate_contour_star', + BorderBeam.rotate( + style: const BeamStyle(theme: BeamTheme.dark, tailLength: 2), + shape: BeamShape(contour: _star), + child: const SizedBox.expand(), + ), + width: 260, + height: 260, + ); + }); + + testWidgets('pulse-outside with a wider halo', (tester) async { + await capture( + tester, + 'surface_pulse_outside_glowspread2', + BorderBeam.pulseOutside( + style: const BeamStyle(theme: BeamTheme.dark, glowSpread: 2), + child: mockSurface(), + ), + ); + }); +} + +/// A five-pointed star inscribed in the beam's bounds. +final _star = BeamPathContour((rect) { + final path = Path(); + final centre = rect.center; + final outer = rect.shortestSide / 2; + for (var i = 0; i < 10; i++) { + final r = i.isEven ? outer : outer * 0.5; + final a = -math.pi / 2 + i * math.pi / 5; + final p = centre + Offset(math.cos(a) * r, math.sin(a) * r); + if (i == 0) { + path.moveTo(p.dx, p.dy); + } else { + path.lineTo(p.dx, p.dy); + } + } + return path..close(); +}, key: 'golden-star'); diff --git a/test/golden/widgets_golden_test.dart b/test/golden/widgets_golden_test.dart new file mode 100644 index 0000000..3534068 --- /dev/null +++ b/test/golden/widgets_golden_test.dart @@ -0,0 +1,97 @@ +@Tags(['golden']) +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Golden scenes for the surface and interaction wrappers, frozen at the same +/// 1.3s instant the variant goldens use (past the 0.6s fade-in, mid-cycle). +/// They pin what the wrappers *compose* — the decoration form painting the +/// same beam a widget does, and the focus ring lit — not the beam frames +/// themselves, which `beam_golden_test.dart` owns. +/// +/// Regenerate with: +/// flutter test --update-goldens --tags golden +void main() { + const surfaceRadius = 16.0; + + Widget scene(Widget body) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: Brightness.dark), + home: ColoredBox( + // The demo backdrop of the source library. + color: const Color(0xFF070707), + child: Center(child: body), + ), + ); + + BoxDecoration surface({double radius = surfaceRadius}) => BoxDecoration( + color: const Color(0xFF1D1D1D), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: const Color(0x14FFFFFF)), + ); + + testWidgets('decoration rotate', (tester) async { + await tester.pumpWidget( + scene( + Container( + width: 350, + height: 140, + decoration: surface(), + // rotate paints over its child, so the beam takes the foreground + // slot and the surface takes the background one. + foregroundDecoration: const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + style: BeamStyle(theme: BeamTheme.dark), + borderRadius: surfaceRadius, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/widgets_decoration_rotate.png'), + ); + }); + + testWidgets('focus ring active', (tester) async { + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; + addTearDown(() { + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.automatic; + }); + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget( + scene( + BeamFocusRing( + borderRadius: 32, + style: const BeamStyle(theme: BeamTheme.dark), + child: Focus( + focusNode: node, + child: Container( + width: 220, + height: 56, + decoration: surface(radius: 32), + ), + ), + ), + ), + ); + node.requestFocus(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('goldens/widgets_focus_ring_active.png'), + ); + }); +} diff --git a/test/models/beam_colors_test.dart b/test/models/beam_colors_test.dart new file mode 100644 index 0000000..a9400a3 --- /dev/null +++ b/test/models/beam_colors_test.dart @@ -0,0 +1,829 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/constants/palettes.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _blobA = BeamBlob( + color: Color(0xFFFF3264), + position: Offset(0.33, -0.074), + size: Size(70, 40), +); +const _blobB = BeamBlob( + color: Color(0xFF288CFF), + position: Offset(0.12, -0.05), + size: Size(60, 35), +); +const _pink = Color(0xFFFF0080); +const _cyan = Color(0xFF00E5FF); +const _brand = Color(0xFF18A8F0); + +const _lineBlob = LineBlob( + color: Color(0xFFFF3264), + sizeW: 120, + sizeH: 30, + offsetX: 0, + offsetY: 2, +); + +Widget _host(Widget child) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Scaffold( + body: Center(child: SizedBox(width: 350, height: 140, child: child)), + ), +); + +BeamPainter _beamPainter(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .first; + +void main() { + group('BeamBlob equality', () { + test('compares color, position and size', () { + expect( + _blobA, + const BeamBlob( + color: Color(0xFFFF3264), + position: Offset(0.33, -0.074), + size: Size(70, 40), + ), + ); + expect( + _blobA.hashCode, + _blobA.withColor(const Color(0xFFFF3264)).hashCode, + ); + expect(_blobA, isNot(_blobB)); + expect(_blobA, isNot(_blobA.withColor(const Color(0xFF000000)))); + expect(_blobA.toString(), contains('BeamBlob')); + }); + + test('LineBlob compares every geometry field', () { + expect( + _lineBlob, + const LineBlob( + color: Color(0xFFFF3264), + sizeW: 120, + sizeH: 30, + offsetX: 0, + offsetY: 2, + ), + ); + expect(_lineBlob.hashCode, isNot(0)); + expect( + _lineBlob, + isNot( + const LineBlob( + color: Color(0xFFFF3264), + sizeW: 120, + sizeH: 30, + offsetX: 1, + offsetY: 2, + ), + ), + ); + expect(_lineBlob.toString(), contains('LineBlob')); + }); + }); + + group('BeamColors equality', () { + test('equal custom lists are == with equal hashCodes', () { + final a = BeamColors.custom(const [Color(0xFFFF00AA), Color(0xFF00FFEE)]); + final b = BeamColors.custom([ + const Color(0xFFFF00AA), + const Color(0xFF00FFEE), + ]); + expect(identical(a, b), isFalse, reason: 'distinct instances'); + expect(a, b); + expect(a.hashCode, b.hashCode); + }); + + test('custom order and length are significant', () { + final a = BeamColors.custom(const [Color(0xFFFF00AA), Color(0xFF00FFEE)]); + final reordered = BeamColors.custom(const [ + Color(0xFF00FFEE), + Color(0xFFFF00AA), + ]); + final shorter = BeamColors.custom(const [Color(0xFFFF00AA)]); + expect(a, isNot(reordered)); + expect(a, isNot(shorter)); + }); + + test('presets compare by identity of their table and mono flag', () { + expect(BeamColors.ocean, BeamColors.ocean); + expect(BeamColors.ocean.hashCode, BeamColors.ocean.hashCode); + expect(BeamColors.ocean, isNot(BeamColors.sunset)); + expect(BeamColors.mono, isNot(BeamColors.colorful)); + expect( + BeamColors.colorful, + isNot(BeamColors.custom(const [Color(0xFFFF00AA)])), + ); + }); + + test('spec compares blob tables element-wise', () { + const a = BeamColors.spec(border: [_blobA, _blobB]); + const b = BeamColors.spec(border: [_blobA, _blobB]); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(const BeamColors.spec(border: [_blobB, _blobA]))); + expect( + a, + isNot(const BeamColors.spec(border: [_blobA, _blobB], lineBlobs: [])), + ); + expect( + const BeamColors.spec(border: [_blobA], smallBorder: [_blobB]), + const BeamColors.spec(border: [_blobA], smallBorder: [_blobB]), + ); + expect( + const BeamColors.spec(border: [_blobA], smallBorder: [_blobB]), + isNot(const BeamColors.spec(border: [_blobA])), + ); + expect( + const BeamColors.spec(border: [_blobA], lineBlobs: [_lineBlob]), + const BeamColors.spec(border: [_blobA], lineBlobs: [_lineBlob]), + ); + }); + }); + + group('BeamColors.resolve', () { + test('memoizes per instance', () { + final custom = BeamColors.custom(const [Color(0xFFFF00AA)]); + expect(identical(custom.resolve(), custom.resolve()), isTrue); + expect( + identical(BeamColors.ocean.resolve(), BeamColors.ocean.resolve()), + isTrue, + ); + const spec = BeamColors.spec(border: [_blobA]); + expect(identical(spec.resolve(), spec.resolve()), isTrue); + }); + + test('mutable custom inputs cannot corrupt the value cache', () { + final colors = [const Color(0xFFFF0000)]; + final custom = BeamColors.custom(colors); + final red = custom.resolve(); + + colors[0] = const Color(0xFF0000FF); + final blue = custom.resolve(); + + expect(identical(blue, red), isFalse); + expect(blue.data.border.first.color, const Color(0xFF0000FF)); + expect( + identical(blue, BeamColors.custom(const [Color(0xFF0000FF)]).resolve()), + isTrue, + reason: 'the mutated value should use a new stable cache key', + ); + }); + + test('mutable spec inputs cannot corrupt the value cache', () { + final border = [_blobA]; + final spec = BeamColors.spec(border: border); + final first = spec.resolve(); + + border[0] = _blobB; + final second = spec.resolve(); + + expect(identical(second, first), isFalse); + expect(second.data.border, [_blobB]); + expect( + identical(second, const BeamColors.spec(border: [_blobB]).resolve()), + isTrue, + ); + }); + + test('an empty color table is rejected in release too', () { + expect( + () => BeamColors.custom([]).resolve(), + throwsA(isA()), + ); + expect( + () => BeamColors.spec(border: []).resolve(), + throwsA(isA()), + ); + }); + + test('a resolved spec palette does not alias the caller lists', () { + final border = [_blobA]; + final small = [_blobA]; + final lines = [_lineBlob]; + final palette = BeamColors.spec( + border: border, + smallBorder: small, + lineBlobs: lines, + ).resolve(); + + border[0] = _blobB; + small[0] = _blobB; + lines.clear(); + + expect(palette.data.border, [_blobA]); + expect(palette.data.smallBorder, [_blobA]); + expect(palette.data.lineDark, [_lineBlob]); + expect(palette.data.lineLight, [_lineBlob]); + expect( + () => palette.data.border.add(_blobB), + throwsUnsupportedError, + reason: 'the resolved tables are snapshots, not the caller list', + ); + }); + + test('custom distributes colors and preserves preset alpha', () { + final palette = BeamColors.custom(const [Color(0xFFFF00AA)]).resolve(); + final reference = BeamColors.colorful.resolve(); + for (final (i, blob) in palette.data.smallInner.indexed) { + expect(blob.color.r, closeTo(1, 1e-6)); + expect(blob.color.g, closeTo(0, 1e-6)); + expect( + blob.color.a, + closeTo(reference.data.smallInner[i].color.a, 1e-6), + ); + expect(blob.position, reference.data.smallInner[i].position); + } + expect(palette.data.border.length, reference.data.border.length); + expect(palette.data.lineBloomDark.length, 5); + expect(palette.forcesStaticColors, isFalse); + }); + + test('mono preset carries the mono modifiers', () { + final mono = BeamColors.mono.resolve(); + expect(mono.forcesStaticColors, isTrue); + expect(mono.opacityMultiplier, 0.5); + expect(mono.monoTreatment, isTrue); + }); + + test('spec keeps the given tables and derives the rest', () { + const spec = BeamColors.spec( + border: [_blobA, _blobB], + smallBorder: [_blobA], + ); + final palette = spec.resolve(); + final derived = BeamColors.custom(const [ + Color(0xFFFF3264), + Color(0xFF288CFF), + ]).resolve(); + expect(palette.data.border, const [_blobA, _blobB]); + expect(palette.data.smallBorder, const [_blobA]); + // smallInner is derived from the given smallBorder at 45% alpha. + expect( + palette.data.smallInner.single.color.a, + closeTo(_blobA.color.a * 0.45, 1e-6), + ); + // Untouched tables cycle the border colors over the default geometry. + expect(palette.data.lineInner, derived.data.lineInner); + expect(palette.data.lineBloomDark, derived.data.lineBloomDark); + expect(palette.data.spike, derived.data.spike); + }); + + test('spec without a smallBorder derives one', () { + const spec = BeamColors.spec(border: [_blobA]); + final palette = spec.resolve(); + final derived = BeamColors.custom(const [Color(0xFFFF3264)]).resolve(); + expect(palette.data.smallBorder, derived.data.smallBorder); + expect(palette.data.smallInner, derived.data.smallInner); + expect(palette.data.lineDark, derived.data.lineDark); + }); + }); + + group('BeamColors.custom base', () { + test('base selects which tables supply geometry and alpha', () { + final overColorful = BeamColors.custom(const [_pink]).resolve(); + final overMono = BeamColors.custom(const [ + _pink, + ], base: BeamColors.mono).resolve(); + // mono's inner-glow table is half as opaque as colorful's, and that + // alpha structure is what `base` selects. + expect(overColorful.data.smallInner[0].color.a, closeTo(0.5, 1e-6)); + expect(overMono.data.smallInner[0].color.a, closeTo(0.25, 1e-6)); + // The hue still comes from the caller's list either way. + expect(overMono.data.smallInner[0].color.r, closeTo(_pink.r, 1e-6)); + expect(overMono.data.smallInner[0].color.g, closeTo(_pink.g, 1e-6)); + }); + + test('a mono base does not make the result mono', () { + final palette = BeamColors.custom(const [ + _pink, + ], base: BeamColors.mono).resolve(); + expect(palette.forcesStaticColors, isFalse); + expect(palette.opacityMultiplier, 1.0); + expect(palette.monoTreatment, isFalse); + }); + + test('base participates in equality', () { + expect( + BeamColors.custom(const [_pink]), + BeamColors.custom(const [_pink], base: BeamColors.colorful), + ); + expect( + BeamColors.custom(const [_pink]).hashCode, + BeamColors.custom(const [_pink], base: BeamColors.colorful).hashCode, + ); + expect( + BeamColors.custom(const [_pink]), + isNot(BeamColors.custom(const [_pink], base: BeamColors.mono)), + ); + expect( + BeamColors.custom(const [_pink], base: BeamColors.ocean), + BeamColors.custom(const [_pink], base: BeamColors.ocean), + ); + expect( + BeamColors.custom(const [_pink], base: BeamColors.ocean).hashCode, + BeamColors.custom(const [_pink], base: BeamColors.ocean).hashCode, + ); + }); + + test('a custom base nests', () { + final nested = BeamColors.custom(const [ + _cyan, + ], base: BeamColors.custom(const [_pink], base: BeamColors.mono)); + final palette = nested.resolve(); + // Alpha structure still comes from mono, two levels down. + expect(palette.data.smallInner[0].color.a, closeTo(0.25, 1e-6)); + // Colors come from the outermost list. + expect(palette.data.smallInner[0].color.b, closeTo(_cyan.b, 1e-6)); + }); + }); + + group('BeamColors.fromSeed', () { + const seeds = { + 'black': Color(0xFF000000), + 'white': Color(0xFFFFFFFF), + 'pure red': Color(0xFFFF0000), + 'brand blue': Color(0xFF18A8F0), + 'mid gray': Color(0xFF808080), + }; + + for (final MapEntry(key: name, value: seed) in seeds.entries) { + for (final harmony in BeamSeedHarmony.values) { + test('$name / ${harmony.name} gives 3-4 distinct in-band colors', () { + final palette = BeamColors.fromSeed(seed, harmony: harmony).resolve(); + // The 9-slot border table cycles the derived colors, so its + // distinct set is exactly the derived palette. + final derived = {for (final b in palette.data.border) b.color}; + expect(derived.length, inInclusiveRange(3, 4), reason: '$derived'); + for (final c in derived) { + final hsl = HSLColor.fromColor(c); + expect( + hsl.lightness, + inInclusiveRange(0.54, 0.71), + reason: '$c is outside the glow lightness band', + ); + // 0.55 floor, times monochrome's 0.7 lowest saturation step. + expect(hsl.saturation, greaterThanOrEqualTo(0.37), reason: '$c'); + expect(c.a, 1.0); + } + }); + } + } + + test('geometry comes from the colorful preset', () { + final palette = const BeamColors.fromSeed(_brand).resolve(); + for (final (i, blob) in palette.data.border.indexed) { + expect(blob.position, colorfulPreset.border[i].position); + expect(blob.size, colorfulPreset.border[i].size); + } + expect(palette.data.smallInner, hasLength(8)); + expect(palette.data.lineBloomDark, hasLength(5)); + // Preset alpha survives the substitution. + expect(palette.data.smallInner[0].color.a, closeTo(0.5, 1e-6)); + }); + + test('resolves without mono modifiers', () { + final palette = const BeamColors.fromSeed(_brand).resolve(); + expect(palette.forcesStaticColors, isFalse); + expect(palette.opacityMultiplier, 1.0); + expect(palette.monoTreatment, isFalse); + }); + + test('the default harmony is analogous', () { + expect( + const BeamColors.fromSeed(_brand), + const BeamColors.fromSeed(_brand, harmony: BeamSeedHarmony.analogous), + ); + }); + + test('equality covers seed and harmony', () { + expect( + BeamColors.fromSeed(const Color(0xFF18A8F0)), + BeamColors.fromSeed(const Color(0xFF18A8F0)), + ); + expect( + BeamColors.fromSeed(const Color(0xFF18A8F0)).hashCode, + BeamColors.fromSeed(const Color(0xFF18A8F0)).hashCode, + ); + expect( + const BeamColors.fromSeed(_brand), + isNot(const BeamColors.fromSeed(Color(0xFFF018A8))), + ); + expect( + const BeamColors.fromSeed(_brand), + isNot( + const BeamColors.fromSeed(_brand, harmony: BeamSeedHarmony.triadic), + ), + ); + expect( + const BeamColors.fromSeed(_brand), + isNot(BeamColors.custom(const [_brand])), + ); + }); + + test('different harmonies produce different palettes', () { + final tables = { + for (final h in BeamSeedHarmony.values) + h: BeamColors.fromSeed(_brand, harmony: h).resolve().data.border[1], + }; + expect(tables.values.map((b) => b.color).toSet(), hasLength(4)); + }); + + test('analogous harmony wraps hues below zero', () { + final palette = const BeamColors.fromSeed(Color(0xFFFF0000)).resolve(); + expect(palette.data.border, hasLength(9)); + expect( + HSLColor.fromColor(palette.data.border[2].color).hue, + closeTo(335, 1), + ); + }); + + test('triadic spreads the seed hue by 120 degrees', () { + final palette = const BeamColors.fromSeed( + _brand, + harmony: BeamSeedHarmony.triadic, + ).resolve(); + final hues = [ + for (var i = 0; i < 3; i++) + HSLColor.fromColor(palette.data.border[i].color).hue, + ]; + expect((hues[1] - hues[0]) % 360, closeTo(120, 1)); + expect((hues[2] - hues[1]) % 360, closeTo(120, 1)); + }); + }); + + group('BeamColors.fromScheme', () { + test('uses primary, secondary and tertiary', () { + const scheme = ColorScheme.dark( + primary: Color(0xFFFF0000), + secondary: Color(0xFF00FF00), + tertiary: Color(0xFF0000FF), + ); + expect( + BeamColors.fromScheme(scheme), + BeamColors.custom(const [ + Color(0xFFFF0000), + Color(0xFF00FF00), + Color(0xFF0000FF), + ]), + ); + }); + + test('drops near-duplicate roles', () { + const scheme = ColorScheme.dark( + primary: Color(0xFFFF0000), + secondary: Color(0xFFFF0102), + tertiary: Color(0xFF0000FF), + ); + expect( + BeamColors.fromScheme(scheme), + BeamColors.custom(const [Color(0xFFFF0000), Color(0xFF0000FF)]), + ); + }); + + test('an all-identical scheme collapses to one color', () { + const scheme = ColorScheme.dark( + primary: _pink, + secondary: _pink, + tertiary: _pink, + ); + expect(BeamColors.fromScheme(scheme), BeamColors.custom(const [_pink])); + expect(BeamColors.fromScheme(scheme).resolve().data.border, hasLength(9)); + }); + + test('value-equal on the three roles only', () { + const a = ColorScheme.dark( + primary: Color(0xFFFF0000), + secondary: Color(0xFF00FF00), + tertiary: Color(0xFF0000FF), + surface: Color(0xFF111111), + ); + const b = ColorScheme.dark( + primary: Color(0xFFFF0000), + secondary: Color(0xFF00FF00), + tertiary: Color(0xFF0000FF), + surface: Color(0xFF222222), + ); + expect(BeamColors.fromScheme(a), BeamColors.fromScheme(b)); + expect( + BeamColors.fromScheme(a).hashCode, + BeamColors.fromScheme(b).hashCode, + ); + expect( + BeamColors.fromScheme(a), + isNot( + BeamColors.fromScheme( + const ColorScheme.dark( + primary: Color(0xFF00FF00), + secondary: Color(0xFFFF0000), + tertiary: Color(0xFF0000FF), + ), + ), + ), + ); + }); + + test('geometry comes from the colorful preset', () { + const scheme = ColorScheme.dark( + primary: Color(0xFFFF0000), + secondary: Color(0xFF00FF00), + tertiary: Color(0xFF0000FF), + ); + final palette = BeamColors.fromScheme(scheme).resolve(); + for (final (i, blob) in palette.data.border.indexed) { + expect(blob.position, colorfulPreset.border[i].position); + expect(blob.size, colorfulPreset.border[i].size); + } + }); + }); + + group('BeamColors.lerp', () { + const ocean = BeamColors.ocean; + const sunset = BeamColors.sunset; + + test('t = 0 reproduces the tables of a', () { + expect( + const BeamColors.lerp(ocean, sunset, 0).resolve().data, + ocean.resolve().data, + ); + }); + + test('t = 1 reproduces the tables of b', () { + expect( + const BeamColors.lerp(ocean, sunset, 1).resolve().data, + sunset.resolve().data, + ); + }); + + test('the midpoint is the per-entry Color.lerp of both ends', () { + final a = ocean.resolve().data; + final b = sunset.resolve().data; + final mid = const BeamColors.lerp(ocean, sunset, 0.5).resolve().data; + for (final (i, blob) in mid.border.indexed) { + expect( + blob.color, + Color.lerp(a.border[i].color, b.border[i].color, .5), + ); + expect(blob.position, a.border[i].position); + expect(blob.size, a.border[i].size); + } + for (final (i, blob) in mid.lineInner.indexed) { + expect( + blob.color, + Color.lerp(a.lineInner[i].color, b.lineInner[i].color, .5), + ); + expect(blob.sizeW, a.lineInner[i].sizeW); + expect(blob.offsetX, a.lineInner[i].offsetX); + } + expect( + mid.spike.primary, + Color.lerp(a.spike.primary, b.spike.primary, .5), + ); + expect( + mid.spikeLt.secondary, + Color.lerp(a.spikeLt.secondary, b.spikeLt.secondary, .5), + ); + expect( + mid.lineBloomDark[2].color2, + Color.lerp(a.lineBloomDark[2].color2, b.lineBloomDark[2].color2, .5), + ); + expect(mid.smallInner, hasLength(8)); + }); + + test('the mono modifiers come from the nearer end', () { + final nearMono = const BeamColors.lerp( + BeamColors.mono, + ocean, + 0.2, + ).resolve(); + expect(nearMono.forcesStaticColors, isTrue); + expect(nearMono.monoTreatment, isTrue); + expect(nearMono.opacityMultiplier, closeTo(0.6, 1e-9)); + + final nearOcean = const BeamColors.lerp( + BeamColors.mono, + ocean, + 0.8, + ).resolve(); + expect(nearOcean.forcesStaticColors, isFalse); + expect(nearOcean.monoTreatment, isFalse); + expect(nearOcean.opacityMultiplier, closeTo(0.9, 1e-9)); + }); + + test('a shorter b table cycles', () { + const spec = BeamColors.spec(border: [_blobA]); + final lerped = const BeamColors.lerp(ocean, spec, 1).resolve().data; + expect(lerped.border, hasLength(9)); + expect(lerped.border.map((b) => b.color).toSet(), hasLength(1)); + // Geometry still comes from a. + expect(lerped.border[4].size, ocean.resolve().data.border[4].size); + }); + + test('equality covers both ends and t', () { + expect( + const BeamColors.lerp(ocean, sunset, 0.25), + const BeamColors.lerp(ocean, sunset, 0.25), + ); + expect( + const BeamColors.lerp(ocean, sunset, 0.25).hashCode, + const BeamColors.lerp(ocean, sunset, 0.25).hashCode, + ); + expect( + const BeamColors.lerp(ocean, sunset, 0.25), + isNot(const BeamColors.lerp(ocean, sunset, 0.75)), + ); + expect( + const BeamColors.lerp(ocean, sunset, 0.25), + isNot(const BeamColors.lerp(sunset, ocean, 0.25)), + ); + expect(const BeamColors.lerp(ocean, sunset, 0), isNot(ocean)); + }); + + test('extrapolates past 1', () { + final past = const BeamColors.lerp(ocean, sunset, 2).resolve().data; + final a = ocean.resolve().data; + final b = sunset.resolve().data; + expect( + past.border[0].color, + Color.lerp(a.border[0].color, b.border[0].color, 2), + ); + }); + }); + + group('BeamColors.scaleAlpha', () { + test('multiplies every table entry alpha and keeps the hue', () { + final base = BeamColors.colorful.resolve().data; + final dim = BeamColors.colorful.scaleAlpha(0.5).resolve().data; + for (final (i, blob) in dim.smallInner.indexed) { + expect(blob.color.a, closeTo(base.smallInner[i].color.a * 0.5, 1e-6)); + expect(blob.color.r, closeTo(base.smallInner[i].color.r, 1e-6)); + expect(blob.position, base.smallInner[i].position); + expect(blob.size, base.smallInner[i].size); + } + expect(dim.border.first.color.a, closeTo(0.5, 1e-6)); + expect(dim.spike.primary.a, closeTo(base.spike.primary.a * 0.5, 1e-6)); + expect( + dim.lineBloomDark[0].color2.a, + closeTo(base.lineBloomDark[0].color2.a * 0.5, 1e-6), + ); + expect(dim.lineInner[3].sizeW, base.lineInner[3].sizeW); + }); + + test('clamps the product at 1', () { + final bright = BeamColors.colorful.scaleAlpha(4).resolve().data; + for (final blob in [...bright.border, ...bright.smallInner]) { + expect(blob.color.a, lessThanOrEqualTo(1.0)); + } + // colorful's smallInner[0] is 0.5 -> 2.0 before the clamp. + expect(bright.smallInner[0].color.a, 1.0); + expect(bright.border.first.color.a, 1.0); + }); + + test('a zero factor makes every entry transparent', () { + final gone = BeamColors.colorful.scaleAlpha(0).resolve().data; + for (final blob in gone.border) { + expect(blob.color.a, 0.0); + } + expect(gone.spike.primary.a, 0.0); + expect(gone.lineBloomLight[4].color1.a, 0.0); + }); + + test('keeps the source palette modifiers', () { + final dim = BeamColors.mono.scaleAlpha(0.5).resolve(); + expect(dim.forcesStaticColors, isTrue); + expect(dim.opacityMultiplier, 0.5); + expect(dim.monoTreatment, isTrue); + expect( + BeamColors.gold.scaleAlpha(0.5).resolve().forcesStaticColors, + isTrue, + ); + }); + + test('equality covers the source and the factor', () { + expect( + BeamColors.ocean.scaleAlpha(0.5), + BeamColors.ocean.scaleAlpha(0.5), + ); + expect( + BeamColors.ocean.scaleAlpha(0.5).hashCode, + BeamColors.ocean.scaleAlpha(0.5).hashCode, + ); + expect( + BeamColors.ocean.scaleAlpha(0.5), + isNot(BeamColors.ocean.scaleAlpha(0.6)), + ); + expect( + BeamColors.ocean.scaleAlpha(0.5), + isNot(BeamColors.sunset.scaleAlpha(0.5)), + ); + expect(BeamColors.ocean.scaleAlpha(1), isNot(BeamColors.ocean)); + }); + + test('stacks', () { + final twice = BeamColors.colorful.scaleAlpha(0.5).scaleAlpha(0.5); + expect(twice.resolve().data.border.first.color.a, closeTo(0.25, 1e-6)); + }); + + test('rejects a negative factor', () { + expect( + () => BeamColors.colorful.scaleAlpha(-1).resolve(), + throwsA(isA()), + ); + }); + }); + + group('Flutter-only presets', () { + test('gold pins the hue but keeps full layer opacity', () { + final gold = BeamColors.gold.resolve(); + expect(gold.forcesStaticColors, isTrue); + expect(gold.opacityMultiplier, 1.0); + expect(gold.monoTreatment, isFalse); + }); + + test('every other new preset animates its hue', () { + for (final colors in const [ + BeamColors.aurora, + BeamColors.neon, + BeamColors.candy, + BeamColors.ember, + BeamColors.ice, + BeamColors.holographic, + ]) { + final palette = colors.resolve(); + expect(palette.forcesStaticColors, isFalse, reason: '$colors'); + expect(palette.opacityMultiplier, 1.0, reason: '$colors'); + expect(palette.monoTreatment, isFalse, reason: '$colors'); + } + }); + + test('the new presets are all distinct from each other', () { + const all = [ + BeamColors.aurora, + BeamColors.neon, + BeamColors.candy, + BeamColors.ember, + BeamColors.ice, + BeamColors.gold, + BeamColors.holographic, + BeamColors.colorful, + BeamColors.mono, + BeamColors.ocean, + BeamColors.sunset, + ]; + expect(all.toSet(), hasLength(all.length)); + expect( + all.map((c) => c.resolve().data.border[0].color).toSet(), + hasLength(all.length), + ); + }); + }); + + group('BeamBlob.size semantics', () { + test('a spec blob keeps the radii it was given', () { + // `size` is the ellipse RADII, not its diameters: painters pass + // `size.width`/`size.height` straight through as radiusX/radiusY, so a + // 70x40 blob spans 140x80 logical pixels. Nothing in the resolve path + // may halve or double it. + const blob = BeamBlob( + color: Color(0xFFFF0080), + position: Offset(0.33, -0.074), + size: Size(70, 40), + ); + final palette = const BeamColors.spec(border: [blob]).resolve(); + expect(palette.data.border.single.size, const Size(70, 40)); + expect(palette.data.border.single.position, const Offset(0.33, -0.074)); + // The derived tables keep the source library's radii too. + expect(palette.data.border.single.size, colorfulPreset.border[0].size); + }); + }); + + testWidgets('equal custom colors do not re-resolve the config', ( + tester, + ) async { + Widget build() => _host( + BorderBeam.rotate( + colors: BeamColors.custom([ + const Color(0xFFFF00AA), + const Color(0xFF00FFEE), + ]), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build()); + final first = _beamPainter(tester).config; + // A fresh widget carrying a fresh — but equal — BeamColors instance. + await tester.pumpWidget(build()); + expect(identical(_beamPainter(tester).config, first), isTrue); + }); +} diff --git a/test/models/beam_config_test.dart b/test/models/beam_config_test.dart new file mode 100644 index 0000000..a6ac8c3 --- /dev/null +++ b/test/models/beam_config_test.dart @@ -0,0 +1,528 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_clock.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _style = BeamStyle( + strength: 0.8, + brightness: 1.1, + saturation: 1.4, + hueRange: 20, + hueBase: 5, + staticColors: false, + strokeOpacityFactor: 0.9, + innerOpacityFactor: 0.8, + bloomOpacityFactor: 0.7, + glowBoost: 1.2, + coreBlur: 3, + bloomBlur: 4, + glowBrightness: 1.5, + glowSaturation: 1.6, +); + +const _shape = BeamShape( + radius: BorderRadius.all(Radius.circular(10)), + borderWidth: 2, + superellipse: false, +); + +const _timing = BeamTiming( + cycle: Duration(seconds: 2), + cycleGap: Duration(seconds: 1), + speed: 1, + huePeriod: Duration(seconds: 5), + bloomHuePeriod: Duration(seconds: 6), + breatheFactor: 1.1, + spikeFactor: 1.2, + spike2Factor: 1.3, +); + +BeamConfig _resolve({ + BeamVariant variant = BeamVariant.rotate, + BeamColors colors = BeamColors.colorful, + Brightness brightness = Brightness.dark, + BeamStyle style = _style, + BeamShape shape = _shape, + BeamTiming timing = _timing, + TextDirection textDirection = TextDirection.ltr, +}) => BeamConfig.resolve( + variant: variant, + palette: colors.resolve(), + brightness: brightness, + style: style, + shape: shape, + timing: timing, + textDirection: textDirection, +); + +BeamPainter _beamPainter(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .first; + +Widget _host(Widget child) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Scaffold( + body: Center(child: SizedBox(width: 350, height: 140, child: child)), + ), +); + +/// `BeamConfig` is the value the painter compares: two resolutions of the +/// same inputs must be `==` (so a rebuilt-inline style never repaints), and +/// every input that reaches a painted value must break that equality. +void main() { + test('resolution rejects invalid durations in release builds', () { + final zero = Duration.zero; + final negative = const Duration(microseconds: -1); + + expect( + () => _resolve(timing: BeamTiming(cycle: zero)), + throwsArgumentError, + ); + expect( + () => _resolve(timing: BeamTiming(cycleGap: negative)), + throwsArgumentError, + ); + expect( + () => _resolve(timing: BeamTiming(huePeriod: zero)), + throwsArgumentError, + ); + expect( + () => _resolve(timing: BeamTiming(bloomHuePeriod: zero)), + throwsArgumentError, + ); + }); + + group('equality', () { + test('two resolutions of the same inputs are equal', () { + final a = _resolve(); + final b = _resolve(); + expect(identical(a, b), isFalse); + expect(a, b); + expect(a.hashCode, b.hashCode); + }); + + // Every input the painter reads, one at a time. + final mutations = { + 'variant': () => _resolve(variant: BeamVariant.small), + 'palette': () => _resolve(colors: BeamColors.ocean), + 'brightness': () => _resolve(brightness: Brightness.light), + 'style.strength': () => _resolve(style: _style.copyWith(strength: 0.81)), + 'style.brightness': () => + _resolve(style: _style.copyWith(brightness: 1.11)), + 'style.saturation': () => + _resolve(style: _style.copyWith(saturation: 1.41)), + 'style.hueRange': () => _resolve(style: _style.copyWith(hueRange: 21)), + 'style.hueBase': () => _resolve(style: _style.copyWith(hueBase: 6)), + 'style.staticColors': () => + _resolve(style: _style.copyWith(staticColors: true)), + 'style.strokeOpacityFactor': () => + _resolve(style: _style.copyWith(strokeOpacityFactor: 0.91)), + 'style.innerOpacityFactor': () => + _resolve(style: _style.copyWith(innerOpacityFactor: 0.81)), + 'style.bloomOpacityFactor': () => + _resolve(style: _style.copyWith(bloomOpacityFactor: 0.71)), + 'style.glowBoost': () => _resolve(style: _style.copyWith(glowBoost: 1.3)), + 'style.coreBlur': () => _resolve(style: _style.copyWith(coreBlur: 3.5)), + 'style.bloomBlur': () => _resolve(style: _style.copyWith(bloomBlur: 4.5)), + 'style.glowBrightness': () => + _resolve(style: _style.copyWith(glowBrightness: 1.51)), + 'style.glowSaturation': () => + _resolve(style: _style.copyWith(glowSaturation: 1.61)), + 'style.themeConfig': () => _resolve( + style: _style.copyWith( + themeConfig: BeamThemeConfig.presetFor( + BeamVariant.rotate, + Brightness.dark, + ).copyWith(bloomOpacity: 0.99), + ), + ), + 'shape.radius': () => + _resolve(shape: _shape.copyWith(radius: BorderRadius.circular(11))), + 'shape.radius (one corner)': () => _resolve( + shape: _shape.copyWith( + radius: const BorderRadius.only(topLeft: Radius.circular(10)), + ), + ), + 'shape.borderWidth': () => + _resolve(shape: _shape.copyWith(borderWidth: 2.5)), + 'shape.superellipse': () => + _resolve(shape: _shape.copyWith(superellipse: true)), + 'timing.cycle': () => _resolve( + timing: _timing.copyWith(cycle: const Duration(milliseconds: 2001)), + ), + 'timing.cycleGap': () => _resolve( + timing: _timing.copyWith(cycleGap: const Duration(milliseconds: 1001)), + ), + 'timing.huePeriod': () => _resolve( + timing: _timing.copyWith(huePeriod: const Duration(seconds: 6)), + ), + 'timing.bloomHuePeriod': () => _resolve( + timing: _timing.copyWith(bloomHuePeriod: const Duration(seconds: 7)), + ), + 'timing.breatheFactor': () => + _resolve(timing: _timing.copyWith(breatheFactor: 1.15)), + 'timing.spikeFactor': () => + _resolve(timing: _timing.copyWith(spikeFactor: 1.25)), + 'timing.spike2Factor': () => + _resolve(timing: _timing.copyWith(spike2Factor: 1.35)), + 'style.hueMode': () => + _resolve(style: _style.copyWith(hueMode: BeamHueMode.continuous)), + 'style.tailLength': () => + _resolve(style: _style.copyWith(tailLength: 1.5)), + 'style.glowSpread': () => + _resolve(style: _style.copyWith(glowSpread: 1.5)), + 'style.comet': () => _resolve(style: _style.copyWith(comet: true)), + 'style.sparkle': () => _resolve(style: _style.copyWith(sparkle: 0.5)), + 'style.segments': () => _resolve(style: _style.copyWith(segments: 6)), + 'shape.edge': () => _resolve(shape: _shape.copyWith(edge: BeamEdge.top)), + 'shape.ringOffset': () => _resolve(shape: _shape.copyWith(ringOffset: 4)), + 'shape.segment': () => + _resolve(shape: _shape.copyWith(segment: BeamSegment.bottomHalf)), + 'shape.wrapCorners': () => + _resolve(shape: _shape.copyWith(wrapCorners: true)), + 'shape.contour': () => _resolve( + shape: _shape.copyWith( + contour: BeamPathContour( + (rect) => Path()..addOval(rect), + key: 'oval', + ), + ), + ), + 'timing.direction': () => + _resolve(timing: _timing.copyWith(direction: BeamDirection.reverse)), + 'timing.phaseOffset': () => + _resolve(timing: _timing.copyWith(phaseOffset: 0.5)), + 'timing.beamCount': () => + _resolve(timing: _timing.copyWith(beamCount: 2)), + 'style.innerSizeScale': () => + _resolve(style: _style.copyWith(innerSizeScale: 0.6)), + 'style.renderScale': () => + _resolve(style: _style.copyWith(renderScale: 0.5)), + 'style.pulseOutsideTuning': () => _resolve( + style: _style.copyWith( + pulseOutsideTuning: BeamPulseOutsideTuning.stock, + ), + ), + }; + + for (final MapEntry(key: name, value: mutate) in mutations.entries) { + test('$name breaks equality', () { + final base = _resolve(); + final mutated = mutate(); + expect(mutated, isNot(base), reason: name); + expect(mutated.hashCode, isNot(base.hashCode), reason: name); + }); + } + + test('textDirection breaks equality for a directional radius', () { + const shape = BeamShape( + radius: BorderRadiusDirectional.only(topStart: Radius.circular(20)), + ); + final ltr = _resolve(shape: shape); + final rtl = _resolve(shape: shape, textDirection: TextDirection.rtl); + expect(rtl, isNot(ltr)); + expect(rtl.borderRadius.topRight, const Radius.circular(20)); + expect( + _resolve(textDirection: TextDirection.rtl), + _resolve(), + reason: 'a non-directional radius resolves the same either way', + ); + }); + + test('two contours with the same key resolve equal', () { + BeamShape shaped(String key) => _shape.copyWith( + contour: BeamPathContour((rect) => Path()..addOval(rect), key: key), + ); + expect(_resolve(shape: shaped('oval')), _resolve(shape: shaped('oval'))); + expect( + _resolve(shape: shaped('oval')), + isNot(_resolve(shape: shaped('notch'))), + ); + }); + + test('speed is not part of the painted value', () { + expect( + _resolve(timing: _timing.copyWith(speed: 4)), + _resolve(), + reason: 'the clock owns the rate; the painter never reads it', + ); + }); + }); + + group('defaults', () { + BeamConfig bare(BeamVariant variant) => BeamConfig.resolve( + variant: variant, + palette: BeamColors.colorful.resolve(), + brightness: Brightness.dark, + ); + + test('the hue mode follows the variant family', () { + for (final variant in BeamVariant.values) { + expect( + bare(variant).hueMode, + variant.isPulse ? BeamHueMode.continuous : BeamHueMode.pingPong, + reason: '$variant', + ); + } + }); + + test('an explicit hue mode overrides the family default', () { + expect( + BeamConfig.resolve( + variant: BeamVariant.rotate, + palette: BeamColors.colorful.resolve(), + brightness: Brightness.dark, + style: const BeamStyle(hueMode: BeamHueMode.continuous), + ).hueMode, + BeamHueMode.continuous, + ); + }); + + test('the hue periods come from the variant accessors', () { + for (final variant in BeamVariant.values) { + final config = bare(variant); + expect( + config.huePeriodSeconds, + variant.defaultHuePeriod.inMicroseconds / + Duration.microsecondsPerSecond, + reason: '$variant', + ); + expect( + config.bloomHuePeriodSeconds, + variant.defaultBloomHuePeriod.inMicroseconds / + Duration.microsecondsPerSecond, + reason: '$variant', + ); + } + }); + + test('every new field resolves to a render-neutral default', () { + for (final variant in BeamVariant.values) { + final config = bare(variant); + expect(config.tailLength, 1, reason: '$variant'); + expect(config.glowSpread, 1, reason: '$variant'); + expect(config.comet, isFalse, reason: '$variant'); + expect(config.sparkle, 0, reason: '$variant'); + expect(config.segments, isNull, reason: '$variant'); + expect(config.edge, BeamEdge.bottom, reason: '$variant'); + expect(config.ringOffset, 0, reason: '$variant'); + expect(config.contour, isNull, reason: '$variant'); + expect(config.segment, isNull, reason: '$variant'); + expect(config.wrapCorners, isFalse, reason: '$variant'); + expect(config.direction, BeamDirection.forward, reason: '$variant'); + expect(config.phaseOffset, 0, reason: '$variant'); + expect(config.beamCount, 1, reason: '$variant'); + expect(config.innerSizeScale, 1, reason: '$variant'); + expect(config.renderScale, 1, reason: '$variant'); + expect( + config.pulseOutsideTuning, + BeamPulseOutsideTuning.demo, + reason: '$variant', + ); + } + }); + + test('renderScale is clamped to the 0.25-1 window', () { + BeamConfig withScale(double scale) => BeamConfig.resolve( + variant: BeamVariant.rotate, + palette: BeamColors.colorful.resolve(), + brightness: Brightness.dark, + style: BeamStyle(renderScale: scale), + ); + expect(withScale(0).renderScale, 0.25); + expect(withScale(0.5).renderScale, 0.5); + expect(withScale(3).renderScale, 1); + }); + + test('sparkle is clamped to 0-1', () { + BeamConfig withSparkle(double sparkle) => BeamConfig.resolve( + variant: BeamVariant.rotate, + palette: BeamColors.colorful.resolve(), + brightness: Brightness.dark, + style: BeamStyle(sparkle: sparkle), + ); + expect(withSparkle(-1).sparkle, 0); + expect(withSparkle(2).sparkle, 1); + expect(withSparkle(0.5).sparkle, 0.5); + }); + + test('scaledBy scales segment feather with other geometry lengths', () { + final config = _resolve( + shape: const BeamShape( + ringOffset: 6, + segment: BeamSegment( + start: BeamAnchor.topCenter, + end: BeamAnchor.bottomCenter, + feather: 20, + ), + wrapCorners: true, + ), + ); + final scaled = config.scaledBy(0.5); + expect(scaled.ringOffset, 3); + expect(scaled.segment?.feather, 10); + expect(scaled.segment?.start, BeamAnchor.topCenter); + expect(scaled.wrapCorners, isTrue); + }); + }); + + group('BeamPainter.shouldRepaint', () { + late BeamClock clock; + + setUp(() { + clock = BeamClock(createTicker: Ticker.new); + }); + tearDown(() => clock.dispose()); + + BeamPainter painter(BeamConfig config, {bool staticMode = false}) => + BeamPainter( + clock: clock, + config: config, + resolver: BeamPhaseResolver(config), + strategy: strategyFor(config.variant), + behind: false, + staticMode: staticMode, + ); + + test('is false for an equal config', () { + final a = painter(_resolve()); + final b = painter(_resolve()); + expect(b.shouldRepaint(a), isFalse); + }); + + test('is true for any differing config', () { + final base = painter(_resolve()); + expect( + painter( + _resolve(style: _style.copyWith(strength: 0.5)), + ).shouldRepaint(base), + isTrue, + ); + expect( + painter(_resolve(variant: BeamVariant.line)).shouldRepaint(base), + isTrue, + ); + }); + + test('is true when the pass, the static mode, or the clock changes', () { + final config = _resolve(); + final base = painter(config); + expect(painter(config, staticMode: true).shouldRepaint(base), isTrue); + + final behind = BeamPainter( + clock: clock, + config: config, + resolver: BeamPhaseResolver(config), + strategy: strategyFor(config.variant), + behind: true, + staticMode: false, + ); + expect(behind.shouldRepaint(base), isTrue); + + final otherClock = BeamClock(createTicker: Ticker.new); + addTearDown(otherClock.dispose); + final rebound = BeamPainter( + clock: otherClock, + config: config, + resolver: BeamPhaseResolver(config), + strategy: strategyFor(config.variant), + behind: false, + staticMode: false, + ); + expect(rebound.shouldRepaint(base), isTrue); + }); + }); + + group("the widget's config cache", () { + testWidgets('an equal style rebuilt inline keeps the same config object', ( + tester, + ) async { + // A runtime value, so each build allocates a fresh BeamStyle rather + // than reusing a canonicalized const one. + Widget build(double strength) => _host( + BorderBeam.rotate( + style: BeamStyle(strength: strength), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(0.5)); + final first = _beamPainter(tester).config; + + await tester.pumpWidget(build(0.5)); + expect( + identical(_beamPainter(tester).config, first), + isTrue, + reason: 'equal value objects must not re-resolve', + ); + }); + + testWidgets('a differing style resolves a new config object', ( + tester, + ) async { + Widget build(double strength) => _host( + BorderBeam.rotate( + style: BeamStyle(strength: strength), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(0.5)); + final first = _beamPainter(tester).config; + + await tester.pumpWidget(build(0.6)); + final second = _beamPainter(tester).config; + expect(identical(second, first), isFalse); + expect(second, isNot(first)); + expect(second.strength, 0.6); + }); + + testWidgets('an equal shape and timing rebuilt inline keep the config', ( + tester, + ) async { + Widget build(double radius, int cycleMs) => _host( + BorderBeam.rotate( + shape: BeamShape.circular(radius, superellipse: true), + timing: BeamTiming( + cycle: Duration(milliseconds: cycleMs), + cycleGap: const Duration(milliseconds: 400), + ), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(24, 1500)); + final first = _beamPainter(tester).config; + await tester.pumpWidget(build(24, 1500)); + expect(identical(_beamPainter(tester).config, first), isTrue); + }); + + testWidgets('an unrelated rebuild keeps the config and the resolver', ( + tester, + ) async { + Widget build(String label) => _host( + BorderBeam.rotate(colors: BeamColors.ocean, child: Text(label)), + ); + + await tester.pumpWidget(build('a')); + final painter = _beamPainter(tester); + + await tester.pumpWidget(build('b')); + final next = _beamPainter(tester); + expect(identical(next.config, painter.config), isTrue); + expect(identical(next.resolver, painter.resolver), isTrue); + expect(next.shouldRepaint(painter), isFalse); + }); + }); +} diff --git a/test/models/beam_palette_test.dart b/test/models/beam_palette_test.dart new file mode 100644 index 0000000..26e0273 --- /dev/null +++ b/test/models/beam_palette_test.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/src/constants/palettes.dart'; +import 'package:flutter_border_beam/src/models/beam_blob.dart'; +import 'package:flutter_border_beam/src/models/beam_colors.dart'; +import 'package:flutter_border_beam/src/models/beam_palette.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Builds a palette that differs from [colorfulPreset] only in the field the +/// caller replaces, so each equality test isolates one table. +BeamPresetData _presetWith({ + List? border, + SpikeColors? spike, + SpikeColors? spikeLt, + List? smallBorder, + List? smallInner, + List? lineDark, + List? lineLight, + List? lineInner, + List? lineBloomDark, + List? lineBloomLight, +}) => BeamPresetData( + border: border ?? colorfulPreset.border, + spike: spike ?? colorfulPreset.spike, + spikeLt: spikeLt ?? colorfulPreset.spikeLt, + smallBorder: smallBorder ?? colorfulPreset.smallBorder, + smallInner: smallInner ?? colorfulPreset.smallInner, + lineDark: lineDark ?? colorfulPreset.lineDark, + lineLight: lineLight ?? colorfulPreset.lineLight, + lineInner: lineInner ?? colorfulPreset.lineInner, + lineBloomDark: lineBloomDark ?? colorfulPreset.lineBloomDark, + lineBloomLight: lineBloomLight ?? colorfulPreset.lineBloomLight, +); + +/// A copy of a blob list with one entry recolored — enough to break equality. +List _recolored(List source) => [ + source.first.withColor(const Color(0xFF010203)), + ...source.skip(1), +]; + +void main() { + group('BeamPresetData equality', () { + test('a field-by-field copy of a preset is ==', () { + final copy = _presetWith(); + expect(copy, colorfulPreset); + expect(copy.hashCode, colorfulPreset.hashCode); + }); + + test('a rebuilt table with equal values is ==', () { + // A fresh list of fresh blobs: nothing here is identical to the preset. + final rebuilt = _presetWith( + border: [for (final b in colorfulPreset.border) b.withColor(b.color)], + ); + expect(identical(rebuilt.border, colorfulPreset.border), isFalse); + expect(rebuilt, colorfulPreset); + expect(rebuilt.hashCode, colorfulPreset.hashCode); + }); + + test('every table participates', () { + final variants = { + 'border': _presetWith(border: _recolored(colorfulPreset.border)), + 'spike': _presetWith( + spike: const SpikeColors( + primary: Color(0xFF010203), + secondary: Color(0xFF040506), + ), + ), + 'spikeLt': _presetWith( + spikeLt: const SpikeColors( + primary: Color(0xFF010203), + secondary: Color(0xFF040506), + ), + ), + 'smallBorder': _presetWith( + smallBorder: _recolored(colorfulPreset.smallBorder), + ), + 'smallInner': _presetWith( + smallInner: _recolored(colorfulPreset.smallInner), + ), + 'lineDark': _presetWith( + lineDark: [ + colorfulPreset.lineDark.first.withColor(const Color(0xFF010203)), + ...colorfulPreset.lineDark.skip(1), + ], + ), + 'lineLight': _presetWith( + lineLight: [ + colorfulPreset.lineLight.first.withColor(const Color(0xFF010203)), + ...colorfulPreset.lineLight.skip(1), + ], + ), + 'lineInner': _presetWith( + lineInner: [ + colorfulPreset.lineInner.first.withColor(const Color(0xFF010203)), + ...colorfulPreset.lineInner.skip(1), + ], + ), + 'lineBloomDark': _presetWith( + lineBloomDark: [ + const SpikePair(Color(0xFF010203), Color(0xFF040506)), + ...colorfulPreset.lineBloomDark.skip(1), + ], + ), + 'lineBloomLight': _presetWith( + lineBloomLight: [ + const SpikePair(Color(0xFF010203), Color(0xFF040506)), + ...colorfulPreset.lineBloomLight.skip(1), + ], + ), + }; + for (final MapEntry(key: field, value: data) in variants.entries) { + expect(data, isNot(colorfulPreset), reason: '$field ignored by =='); + } + // Each variant differs from every other, not just from the preset. + expect(variants.values.toSet(), hasLength(variants.length)); + }); + + test('a table of different length is not equal', () { + expect( + _presetWith(border: colorfulPreset.border.take(8).toList()), + isNot(colorfulPreset), + ); + }); + + test('the presets differ from one another', () { + expect({ + colorfulPreset, + monoPreset, + oceanPreset, + sunsetPreset, + }, hasLength(4)); + }); + }); + + group('BeamPalette equality', () { + test('compares tables and every paint-time modifier', () { + const a = BeamPalette(data: colorfulPreset); + const b = BeamPalette(data: colorfulPreset); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect( + a, + isNot( + const BeamPalette(data: colorfulPreset, forcesStaticColors: true), + ), + ); + expect( + a, + isNot(const BeamPalette(data: colorfulPreset, opacityMultiplier: 0.5)), + ); + expect( + a, + isNot(const BeamPalette(data: colorfulPreset, monoTreatment: true)), + ); + expect(a, isNot(const BeamPalette(data: oceanPreset))); + expect(a.toString(), contains('BeamPalette')); + }); + + test('a palette rebuilt from equal tables is ==', () { + final rebuilt = BeamPalette(data: _presetWith()); + expect(rebuilt, const BeamPalette(data: colorfulPreset)); + expect( + rebuilt.hashCode, + const BeamPalette(data: colorfulPreset).hashCode, + ); + }); + }); + + group('the resolve memo', () { + test('equal color choices resolve to the identical palette', () { + // Two separately built — never identical — but equal instances. + final a = BeamColors.custom([ + const Color(0xFF123456), + const Color(0xFF654321), + ]); + final b = BeamColors.custom([ + const Color(0xFF123456), + const Color(0xFF654321), + ]); + expect(identical(a, b), isFalse); + expect(identical(a.resolve(), b.resolve()), isTrue); + }); + + test('every factory shares the memo', () { + expect( + identical( + BeamColors.fromSeed(const Color(0xFF18A8F0)).resolve(), + BeamColors.fromSeed(const Color(0xFF18A8F0)).resolve(), + ), + isTrue, + ); + expect( + identical( + BeamColors.lerp(BeamColors.ocean, BeamColors.sunset, 0.5).resolve(), + BeamColors.lerp(BeamColors.ocean, BeamColors.sunset, 0.5).resolve(), + ), + isTrue, + ); + expect( + identical( + BeamColors.ocean.scaleAlpha(0.5).resolve(), + BeamColors.ocean.scaleAlpha(0.5).resolve(), + ), + isTrue, + ); + expect( + identical( + BeamColors.fromScheme(_scheme).resolve(), + BeamColors.fromScheme(_scheme).resolve(), + ), + isTrue, + ); + }); + + test('unequal color choices resolve to different palettes', () { + final a = BeamColors.custom(const [Color(0xFF123456)]); + final b = BeamColors.custom(const [Color(0xFF654321)]); + expect(identical(a.resolve(), b.resolve()), isFalse); + expect(a.resolve(), isNot(b.resolve())); + expect( + identical( + const BeamColors.fromSeed( + Color(0xFF18A8F0), + harmony: BeamSeedHarmony.triadic, + ).resolve(), + const BeamColors.fromSeed(Color(0xFF18A8F0)).resolve(), + ), + isFalse, + ); + }); + + test('presets keep one palette for the life of the isolate', () { + final ocean = BeamColors.ocean.resolve(); + // Flood the value cache well past its bound; a preset must survive it. + for (var i = 0; i < 200; i++) { + BeamColors.custom([Color(0xFF000000 | i)]).resolve(); + } + expect(identical(BeamColors.ocean.resolve(), ocean), isTrue); + }); + + test('the value cache is bounded and evicts least-recently-used first', () { + // Distinct from every other key used in this file. + BeamColors key(int i) => BeamColors.custom([Color(0xFF7F0000 | i)]); + + final oldest = key(0); + final oldestPalette = oldest.resolve(); + final newest = key(32); + // 32 further distinct palettes: the capacity, so `oldest` — never + // touched again — falls off the front. + for (var i = 1; i <= 32; i++) { + key(i).resolve(); + } + expect( + identical(oldest.resolve(), oldestPalette), + isFalse, + reason: 'the least recently used entry should have been evicted', + ); + // An evicted palette rebuilds to an equal one, so nothing observable + // changes for a painter comparing configs. + expect(oldest.resolve(), oldestPalette); + // The most recent insert is still cached. + expect(identical(newest.resolve(), key(32).resolve()), isTrue); + }); + + test('a hit refreshes the entry, so a re-read survives eviction', () { + BeamColors key(int i) => BeamColors.custom([Color(0xFF3F0000 | i)]); + final kept = key(0); + final keptPalette = kept.resolve(); + for (var i = 1; i <= 24; i++) { + key(i).resolve(); + // Touching `kept` moves it back to the most-recent end. + kept.resolve(); + } + expect(identical(kept.resolve(), keptPalette), isTrue); + }); + }); +} + +const ColorScheme _scheme = ColorScheme.dark( + primary: Color(0xFFFF0000), + secondary: Color(0xFF00FF00), + tertiary: Color(0xFF0000FF), +); diff --git a/test/models/beam_segment_test.dart b/test/models/beam_segment_test.dart new file mode 100644 index 0000000..5b4433b --- /dev/null +++ b/test/models/beam_segment_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/painting/ring_geometry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final perimeter = BeamPerimeter( + Path()..addRect(const Rect.fromLTWH(0, 0, 100, 100)), + const Rect.fromLTWH(0, 0, 100, 100), + radii: BorderRadius.zero, + ); + + test('anchors and segments compare by value', () { + expect(const BeamAnchor.fraction(0.25), const BeamAnchor.fraction(0.25)); + expect( + const BeamAnchor.edge(BeamEdge.right), + const BeamAnchor.edge(BeamEdge.right, 0.5), + ); + expect( + const BeamSegment( + start: BeamAnchor.topCenter, + end: BeamAnchor.bottomCenter, + ), + BeamSegment.rightHalf, + ); + expect(BeamSegment.rightHalf, isNot(BeamSegment.leftHalf)); + expect( + BeamSegment.rightHalf.hashCode, + isNot(BeamSegment.leftHalf.hashCode), + ); + }); + + test('fraction anchors wrap modulo one when resolved', () { + expect( + const BeamAnchor.fraction(1.25).resolve(perimeter), + closeTo(0.25, 1e-9), + ); + expect( + const BeamAnchor.fraction(-0.25).resolve(perimeter), + closeTo(0.75, 1e-9), + ); + }); + + test('half presets use the documented clockwise anchors', () { + expect(BeamSegment.bottomHalf.start, BeamAnchor.rightCenter); + expect(BeamSegment.bottomHalf.end, BeamAnchor.leftCenter); + expect(BeamSegment.topHalf.start, BeamAnchor.leftCenter); + expect(BeamSegment.topHalf.end, BeamAnchor.rightCenter); + expect(BeamSegment.leftHalf.start, BeamAnchor.bottomCenter); + expect(BeamSegment.leftHalf.end, BeamAnchor.topCenter); + expect(BeamSegment.rightHalf.start, BeamAnchor.topCenter); + expect(BeamSegment.rightHalf.end, BeamAnchor.bottomCenter); + }); + + test('edge presets include both adjoining corner arcs', () { + expect( + BeamSegment.bottomEdge.start, + const BeamAnchor.corner(BeamCorner.bottomRight, 0), + ); + expect( + BeamSegment.bottomEdge.end, + const BeamAnchor.corner(BeamCorner.bottomLeft, 1), + ); + expect( + BeamSegment.topEdge.start, + const BeamAnchor.corner(BeamCorner.topLeft, 0), + ); + expect( + BeamSegment.rightEdge.end, + const BeamAnchor.corner(BeamCorner.bottomRight, 1), + ); + }); + + test('toString exposes the complete value', () { + expect( + const BeamSegment( + start: BeamAnchor.fraction(0.1), + end: BeamAnchor.edge(BeamEdge.bottom), + feather: 8, + ).toString(), + 'BeamSegment(start: BeamAnchor.fraction(0.1), end: ' + 'BeamAnchor.edge(BeamEdge.bottom, 0.5), feather: 8.0)', + ); + }); +} diff --git a/test/models/beam_variant_test.dart b/test/models/beam_variant_test.dart new file mode 100644 index 0000000..33df3e8 --- /dev/null +++ b/test/models/beam_variant_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// `BeamVariant` carries every per-variant default a beam resolves against, +/// so tooling reads them from the enum rather than transcribing them out of +/// doc comments. +void main() { + group('BeamVariant', () { + test('isPulse splits the two families', () { + expect(BeamVariant.rotate.isPulse, isFalse); + expect(BeamVariant.small.isPulse, isFalse); + expect(BeamVariant.line.isPulse, isFalse); + expect(BeamVariant.pulseInside.isPulse, isTrue); + expect(BeamVariant.pulseOutside.isPulse, isTrue); + }); + + test('cycle, radius, and border width match the source presets', () { + expect( + BeamVariant.rotate.defaultCycleDuration, + const Duration(milliseconds: 1960), + ); + expect( + BeamVariant.small.defaultCycleDuration, + const Duration(milliseconds: 1960), + ); + expect( + BeamVariant.line.defaultCycleDuration, + const Duration(milliseconds: 3100), + ); + expect( + BeamVariant.pulseInside.defaultCycleDuration, + const Duration(milliseconds: 2300), + ); + expect( + BeamVariant.pulseOutside.defaultCycleDuration, + const Duration(milliseconds: 2300), + ); + expect(BeamVariant.small.defaultBorderRadius, 32); + for (final variant in BeamVariant.values) { + if (variant != BeamVariant.small) { + expect(variant.defaultBorderRadius, 16, reason: '$variant'); + } + expect(variant.defaultBorderWidth, 1, reason: '$variant'); + } + }); + + test('the traveling variants share the 12s hue period', () { + for (final variant in [ + BeamVariant.rotate, + BeamVariant.small, + BeamVariant.line, + ]) { + expect( + variant.defaultHuePeriod, + const Duration(seconds: 12), + reason: '$variant', + ); + } + }); + + test('the pulse variants carry their own hue periods', () { + expect( + BeamVariant.pulseInside.defaultHuePeriod, + const Duration(seconds: 16), + ); + expect( + BeamVariant.pulseOutside.defaultHuePeriod, + const Duration(seconds: 14), + ); + }); + + test('the bloom hue period is 8s for every variant', () { + for (final variant in BeamVariant.values) { + expect( + variant.defaultBloomHuePeriod, + const Duration(seconds: 8), + reason: '$variant', + ); + } + }); + }); +} diff --git a/test/models/value_objects_test.dart b/test/models/value_objects_test.dart new file mode 100644 index 0000000..f3ed929 --- /dev/null +++ b/test/models/value_objects_test.dart @@ -0,0 +1,698 @@ +import 'dart:ui' show Brightness, Path, Rect; + +import 'package:flutter/animation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The four beam value objects and the theme data share one contract: every +/// field is nullable and means *inherit*, `merge` layers the argument over +/// the receiver, `copyWith` keeps what it is not given, and equality is by +/// value so a rebuilt-inline object never re-resolves a config. +void main() { + group('BeamStyle', () { + test('merge lets the argument win field by field', () { + const base = BeamStyle( + colors: BeamColors.ocean, + strength: 0.5, + hueBase: 10, + ); + const over = BeamStyle(colors: BeamColors.sunset, glowBoost: 2); + final merged = base.merge(over); + expect(merged.colors, BeamColors.sunset, reason: 'argument wins'); + expect(merged.strength, 0.5, reason: 'null inherits'); + expect(merged.hueBase, 10); + expect(merged.glowBoost, 2); + }); + + test('merge(null) returns the receiver unchanged', () { + const base = BeamStyle(strength: 0.25); + expect(base.merge(null), base); + }); + + test('copyWith keeps unnamed fields', () { + const base = BeamStyle(colors: BeamColors.mono, saturation: 2); + final copy = base.copyWith(saturation: 3); + expect(copy.colors, BeamColors.mono); + expect(copy.saturation, 3); + }); + + test('equality and hashCode are by value', () { + const a = BeamStyle(colors: BeamColors.ocean, strength: 0.4); + const b = BeamStyle(colors: BeamColors.ocean, strength: 0.4); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(const BeamStyle(colors: BeamColors.ocean))); + }); + + test('themeConfig is part of the value', () { + final preset = BeamThemeConfig.presetFor( + BeamVariant.rotate, + Brightness.dark, + ); + expect( + BeamStyle(themeConfig: preset), + BeamStyle(themeConfig: preset.copyWith()), + ); + expect( + BeamStyle(themeConfig: preset), + isNot(BeamStyle(themeConfig: preset.copyWith(bloomOpacity: 0.1))), + ); + }); + + test('the beam-shaping fields follow the inherit contract', () { + const base = BeamStyle( + hueMode: BeamHueMode.continuous, + tailLength: 2, + glowSpread: 1.5, + comet: true, + sparkle: 0.4, + segments: 8, + ); + final merged = base.merge( + const BeamStyle(tailLength: 3, comet: false, segments: 12), + ); + expect(merged.hueMode, BeamHueMode.continuous, reason: 'null inherits'); + expect(merged.tailLength, 3, reason: 'argument wins'); + expect(merged.glowSpread, 1.5); + expect(merged.comet, isFalse, reason: 'false is a value, not a null'); + expect(merged.sparkle, 0.4); + expect(merged.segments, 12); + + final copy = base.copyWith(sparkle: 0.9); + expect(copy.sparkle, 0.9); + expect(copy.hueMode, BeamHueMode.continuous); + expect(copy.tailLength, 2); + expect(copy.glowSpread, 1.5); + expect(copy.comet, isTrue); + expect(copy.segments, 8); + }); + + test('every beam-shaping field is part of the value', () { + const base = BeamStyle(); + const variants = [ + BeamStyle(hueMode: BeamHueMode.continuous), + BeamStyle(tailLength: 2), + BeamStyle(glowSpread: 2), + BeamStyle(comet: true), + BeamStyle(sparkle: 0.5), + BeamStyle(segments: 6), + ]; + for (final variant in variants) { + expect(variant, isNot(base), reason: '$variant'); + expect(variant.hashCode, isNot(base.hashCode), reason: '$variant'); + } + expect( + const BeamStyle(tailLength: 2, comet: true), + const BeamStyle(tailLength: 2, comet: true), + ); + }); + + test('toString lists only the fields that are set', () { + expect( + const BeamStyle( + hueMode: BeamHueMode.pingPong, + tailLength: 2, + glowSpread: 1.5, + comet: true, + sparkle: 0.25, + segments: 4, + ).toString(), + 'BeamStyle(hueMode: BeamHueMode.pingPong, tailLength: 2.0, ' + 'glowSpread: 1.5, comet: true, sparkle: 0.25, segments: 4)', + ); + expect(const BeamStyle().toString(), 'BeamStyle()'); + }); + }); + + test('the reference-port fields follow the inherit contract', () { + const base = BeamStyle( + innerSizeScale: 0.6, + renderScale: 0.5, + pulseOutsideTuning: BeamPulseOutsideTuning.stock, + ); + final merged = base.merge(const BeamStyle(renderScale: 0.25)); + expect(merged.innerSizeScale, 0.6, reason: 'null inherits'); + expect(merged.renderScale, 0.25, reason: 'argument wins'); + expect(merged.pulseOutsideTuning, BeamPulseOutsideTuning.stock); + + final copy = base.copyWith(innerSizeScale: 1.4); + expect(copy.innerSizeScale, 1.4); + expect(copy.renderScale, 0.5); + expect(copy.pulseOutsideTuning, BeamPulseOutsideTuning.stock); + }); + + test('every reference-port field is part of the value', () { + const base = BeamStyle(); + const variants = [ + BeamStyle(innerSizeScale: 0.6), + BeamStyle(renderScale: 0.5), + BeamStyle(pulseOutsideTuning: BeamPulseOutsideTuning.stock), + ]; + for (final variant in variants) { + expect(variant, isNot(base), reason: '$variant'); + expect(variant.hashCode, isNot(base.hashCode), reason: '$variant'); + } + expect( + const BeamStyle(innerSizeScale: 0.6, renderScale: 0.5), + const BeamStyle(innerSizeScale: 0.6, renderScale: 0.5), + ); + expect( + const BeamStyle(pulseOutsideTuning: BeamPulseOutsideTuning.demo), + isNot(const BeamStyle(pulseOutsideTuning: BeamPulseOutsideTuning.stock)), + ); + }); + + test('toString lists the reference-port fields', () { + expect( + const BeamStyle( + innerSizeScale: 0.6, + renderScale: 0.5, + pulseOutsideTuning: BeamPulseOutsideTuning.stock, + ).toString(), + 'BeamStyle(innerSizeScale: 0.6, renderScale: 0.5, ' + 'pulseOutsideTuning: BeamPulseOutsideTuning.stock)', + ); + }); + + test('pulseOutsideStock cancels the demo tuning it can reach', () { + const stock = BeamStyle.pulseOutsideStock; + expect(stock.pulseOutsideTuning, BeamPulseOutsideTuning.stock); + expect(stock.glowBoost, 1); + expect(stock.strokeOpacityFactor, closeTo(1 / 1.71, 1e-12)); + expect(stock.innerOpacityFactor, stock.strokeOpacityFactor); + expect(stock.bloomOpacityFactor, stock.strokeOpacityFactor); + expect(stock.glowBrightness, 1.3); + expect(stock.glowSaturation, 1.2); + expect( + stock.merge(const BeamStyle(glowBrightness: 2)).glowBrightness, + 2, + reason: 'it is a starting point, not a lock', + ); + }); + + group('BeamShape', () { + test('circular fills all four corners', () { + final shape = BeamShape.circular(12, superellipse: true); + expect(shape.radius, BorderRadius.circular(12)); + expect(shape.superellipse, isTrue); + expect(shape.borderWidth, isNull); + }); + + test('stadium is an infinite radius the geometry clamps', () { + const shape = BeamShape.stadium(); + expect( + shape.radius, + const BorderRadius.all(Radius.circular(double.infinity)), + ); + }); + + test('merge and copyWith follow the inherit contract', () { + const base = BeamShape(borderWidth: 2); + final merged = base.merge( + const BeamShape(radius: BorderRadius.zero, superellipse: true), + ); + expect(merged.borderWidth, 2); + expect(merged.radius, BorderRadius.zero); + expect(merged.superellipse, isTrue); + expect(base.copyWith(superellipse: false).borderWidth, 2); + }); + + test('equality is by value', () { + expect( + const BeamShape(radius: BorderRadius.zero, borderWidth: 1), + const BeamShape(radius: BorderRadius.zero, borderWidth: 1), + ); + expect( + const BeamShape(radius: BorderRadius.zero, borderWidth: 1).hashCode, + const BeamShape(radius: BorderRadius.zero, borderWidth: 1).hashCode, + ); + expect(BeamShape.circular(4), isNot(BeamShape.circular(5))); + }); + + test('all is the const path to a uniform radius', () { + const shape = BeamShape.all(24, superellipse: true); + expect(shape.radius, BorderRadius.circular(24)); + expect(shape.superellipse, isTrue); + expect(shape.borderWidth, isNull); + expect( + identical(shape, const BeamShape.all(24, superellipse: true)), + isTrue, + reason: 'const-canonicalized, so a theme holding one stays const', + ); + }); + + test('all and circular are the same value', () { + expect(const BeamShape.all(24), BeamShape.circular(24)); + expect(const BeamShape.all(24).hashCode, BeamShape.circular(24).hashCode); + expect( + const BeamShape.all(24, borderWidth: 2), + BeamShape.circular(24, borderWidth: 2), + ); + expect(const BeamShape.all(24), isNot(BeamShape.circular(25))); + expect( + const BeamShape.all(24), + const BeamShape(radius: BorderRadius.all(Radius.circular(24))), + ); + expect( + const BeamShape.all(24).toString(), + BeamShape.circular(24).toString(), + ); + }); + + test('copyWith and merge carry a uniform radius through', () { + const base = BeamShape.all(24); + expect(base.copyWith(borderWidth: 2).radius, BorderRadius.circular(24)); + expect( + base.merge(const BeamShape(superellipse: true)).radius, + BorderRadius.circular(24), + ); + expect( + base.copyWith(radius: BorderRadius.zero).radius, + BorderRadius.zero, + reason: 'an explicit radius replaces the uniform one', + ); + expect( + const BeamShape( + radius: BorderRadius.zero, + ).merge(const BeamShape.all(8)), + const BeamShape.all(8), + ); + }); + + test('the geometry fields follow the inherit contract', () { + final contour = BeamPathContour( + (rect) => Path()..addRect(rect), + key: 'rect', + ); + final base = BeamShape( + edge: BeamEdge.top, + ringOffset: 4, + contour: contour, + segment: BeamSegment.bottomHalf, + wrapCorners: true, + ); + final merged = base.merge(const BeamShape(edge: BeamEdge.left)); + expect(merged.edge, BeamEdge.left, reason: 'argument wins'); + expect(merged.ringOffset, 4, reason: 'null inherits'); + expect(merged.contour, contour); + expect(merged.segment, BeamSegment.bottomHalf); + expect(merged.wrapCorners, isTrue); + expect(base.copyWith(ringOffset: -2).edge, BeamEdge.top); + expect(base.copyWith(ringOffset: -2).ringOffset, -2); + }); + + test('every geometry field is part of the value', () { + const base = BeamShape(); + final variants = [ + const BeamShape(edge: BeamEdge.top), + const BeamShape(ringOffset: 3), + const BeamShape(segment: BeamSegment.bottomHalf), + const BeamShape(wrapCorners: true), + BeamShape( + contour: BeamPathContour( + (rect) => Path()..addOval(rect), + key: 'oval', + ), + ), + ]; + for (final variant in variants) { + expect(variant, isNot(base), reason: '$variant'); + expect(variant.hashCode, isNot(base.hashCode), reason: '$variant'); + } + }); + + test('toString lists only the fields that are set', () { + expect( + const BeamShape( + edge: BeamEdge.right, + ringOffset: 2, + segment: BeamSegment.bottomHalf, + wrapCorners: true, + ).toString(), + 'BeamShape(edge: BeamEdge.right, ringOffset: 2.0, segment: ' + 'BeamSegment(start: BeamAnchor.edge(BeamEdge.right, 0.5), end: ' + 'BeamAnchor.edge(BeamEdge.left, 0.5), feather: 32.0), ' + 'wrapCorners: true)', + ); + expect(const BeamShape().toString(), 'BeamShape()'); + }); + }); + + group('BeamTiming', () { + test('rejects values that would make phase arithmetic invalid', () { + final zero = double.parse('0'); + final nan = double.parse('NaN'); + + expect(() => BeamTiming(speed: zero), throwsAssertionError); + expect(() => BeamTiming(speed: nan), throwsAssertionError); + expect(() => BeamTiming(phaseOffset: -0.01), throwsAssertionError); + expect(() => BeamTiming(phaseOffset: 1.01), throwsAssertionError); + expect(() => BeamTiming(beamCount: 0), throwsAssertionError); + expect(() => BeamTiming(breatheFactor: zero), throwsAssertionError); + expect(() => BeamTiming(spikeFactor: nan), throwsAssertionError); + expect(() => BeamTiming(spike2Factor: -1), throwsAssertionError); + }); + + test('merge lets the argument win field by field', () { + const base = BeamTiming(cycle: Duration(seconds: 2), speed: 2); + const over = BeamTiming(cycleGap: Duration(seconds: 1)); + final merged = base.merge(over); + expect(merged.cycle, const Duration(seconds: 2)); + expect(merged.speed, 2); + expect(merged.cycleGap, const Duration(seconds: 1)); + expect(merged.huePeriod, isNull); + }); + + test('copyWith keeps unnamed fields', () { + const base = BeamTiming(breatheFactor: 1.5, spikeFactor: 2); + final copy = base.copyWith(spikeFactor: 3); + expect(copy.breatheFactor, 1.5); + expect(copy.spikeFactor, 3); + }); + + test('equality is by value', () { + expect( + const BeamTiming(cycle: Duration(seconds: 1)), + const BeamTiming(cycle: Duration(seconds: 1)), + ); + expect( + const BeamTiming(cycle: Duration(seconds: 1)).hashCode, + const BeamTiming(cycle: Duration(seconds: 1)).hashCode, + ); + expect( + const BeamTiming(cycle: Duration(seconds: 1)), + isNot(const BeamTiming(cycle: Duration(seconds: 2))), + ); + }); + + test('the travel fields follow the inherit contract', () { + const base = BeamTiming( + direction: BeamDirection.reverse, + phaseOffset: 0.25, + beamCount: 3, + ); + final merged = base.merge(const BeamTiming(beamCount: 5)); + expect(merged.direction, BeamDirection.reverse); + expect(merged.phaseOffset, 0.25); + expect(merged.beamCount, 5); + + final copy = base.copyWith(phaseOffset: 0.75); + expect(copy.phaseOffset, 0.75); + expect(copy.direction, BeamDirection.reverse); + expect(copy.beamCount, 3); + }); + + test('every travel field is part of the value', () { + const base = BeamTiming(); + const variants = [ + BeamTiming(direction: BeamDirection.bounce), + BeamTiming(phaseOffset: 0.5), + BeamTiming(beamCount: 2), + ]; + for (final variant in variants) { + expect(variant, isNot(base), reason: '$variant'); + expect(variant.hashCode, isNot(base.hashCode), reason: '$variant'); + } + expect( + const BeamTiming(direction: BeamDirection.bounce, beamCount: 2), + const BeamTiming(direction: BeamDirection.bounce, beamCount: 2), + ); + }); + + test('toString lists only the fields that are set', () { + expect( + const BeamTiming( + direction: BeamDirection.reverse, + phaseOffset: 0.5, + beamCount: 2, + ).toString(), + 'BeamTiming(direction: BeamDirection.reverse, phaseOffset: 0.5, ' + 'beamCount: 2)', + ); + expect(const BeamTiming().toString(), 'BeamTiming()'); + }); + }); + + group('BeamPlayback', () { + test('merge lets the argument win field by field', () { + const base = BeamPlayback(active: false, autoPlay: false); + const over = BeamPlayback(active: true, duration: Duration(seconds: 3)); + final merged = base.merge(over); + expect(merged.active, isTrue); + expect(merged.autoPlay, isFalse); + expect(merged.duration, const Duration(seconds: 3)); + expect(merged.startAfter, isNull); + }); + + test('copyWith keeps unnamed fields', () { + const base = BeamPlayback( + active: false, + reducedMotion: BeamReducedMotion.animate, + ); + expect( + base.copyWith(active: true).reducedMotion, + BeamReducedMotion.animate, + ); + }); + + test('repeat and reducedMotion follow the inherit contract', () { + const base = BeamPlayback( + repeat: BeamRepeat.count(3), + reducedMotion: BeamReducedMotion.hide, + ); + final merged = base.merge( + const BeamPlayback(reducedMotion: BeamReducedMotion.slow), + ); + expect(merged.repeat, const BeamRepeat.count(3), reason: 'null inherits'); + expect(merged.reducedMotion, BeamReducedMotion.slow); + expect(base.copyWith(active: false).repeat, const BeamRepeat.count(3)); + }); + + test('repeat and reducedMotion are part of the value', () { + const base = BeamPlayback(); + const variants = [ + BeamPlayback(repeat: BeamRepeat.once()), + BeamPlayback(reducedMotion: BeamReducedMotion.staticFrame), + ]; + for (final variant in variants) { + expect(variant, isNot(base), reason: '$variant'); + expect(variant.hashCode, isNot(base.hashCode), reason: '$variant'); + } + expect( + const BeamPlayback(repeat: BeamRepeat.count(2)), + isNot(const BeamPlayback(repeat: BeamRepeat.count(3))), + ); + expect( + const BeamPlayback(repeat: BeamRepeat.once()), + const BeamPlayback(repeat: BeamRepeat.once()), + ); + }); + + test('toString lists only the fields that are set', () { + expect( + const BeamPlayback( + repeat: BeamRepeat.count(2), + reducedMotion: BeamReducedMotion.hide, + ).toString(), + 'BeamPlayback(repeat: BeamRepeat.count(2), ' + 'reducedMotion: BeamReducedMotion.hide)', + ); + expect(const BeamPlayback().toString(), 'BeamPlayback()'); + }); + + test('equality is by value', () { + expect( + const BeamPlayback(active: false), + const BeamPlayback(active: false), + ); + expect( + const BeamPlayback(active: false).hashCode, + const BeamPlayback(active: false).hashCode, + ); + expect( + const BeamPlayback(active: false), + isNot(const BeamPlayback(active: true)), + ); + }); + }); + + test('the playback-only fields follow the inherit contract', () { + const base = BeamPlayback( + pauseWhenOffscreen: false, + fadeCurve: BeamPlayback.cssEase, + debugFrozenAt: Duration(milliseconds: 1300), + ); + final merged = base.merge( + const BeamPlayback(debugFrozenAt: Duration(seconds: 2)), + ); + expect(merged.pauseWhenOffscreen, isFalse, reason: 'null inherits'); + expect(merged.fadeCurve, BeamPlayback.cssEase); + expect(merged.debugFrozenAt, const Duration(seconds: 2)); + + final copy = base.copyWith(active: true); + expect(copy.pauseWhenOffscreen, isFalse); + expect(copy.fadeCurve, BeamPlayback.cssEase); + expect(copy.debugFrozenAt, const Duration(milliseconds: 1300)); + }); + + test('every playback-only field is part of the value', () { + const base = BeamPlayback(); + const variants = [ + BeamPlayback(pauseWhenOffscreen: false), + BeamPlayback(fadeCurve: BeamPlayback.cssEase), + BeamPlayback(debugFrozenAt: Duration(seconds: 1)), + ]; + for (final variant in variants) { + expect(variant, isNot(base), reason: '$variant'); + expect(variant.hashCode, isNot(base.hashCode), reason: '$variant'); + } + expect( + const BeamPlayback(debugFrozenAt: Duration(seconds: 1)), + const BeamPlayback(debugFrozenAt: Duration(seconds: 1)), + ); + expect( + const BeamPlayback(debugFrozenAt: Duration(seconds: 1)), + isNot(const BeamPlayback(debugFrozenAt: Duration(seconds: 2))), + ); + }); + + test('toString lists the playback-only fields', () { + expect( + const BeamPlayback( + pauseWhenOffscreen: false, + debugFrozenAt: Duration(milliseconds: 1300), + ).toString(), + 'BeamPlayback(pauseWhenOffscreen: false, ' + 'debugFrozenAt: 0:00:01.300000)', + ); + }); + + test('cssEase is the web easing, and compares by value', () { + expect(BeamPlayback.cssEase, const Cubic(0.25, 0.1, 0.25, 1)); + expect(BeamPlayback.cssEase.transform(0), 0); + expect(BeamPlayback.cssEase.transform(1), 1); + }); + + group('BeamRepeat', () { + test('forever carries no cycle count', () { + expect(const BeamRepeat.forever().cycles, isNull); + expect(const BeamRepeat.forever().toString(), 'BeamRepeat.forever()'); + }); + + test('once is one cycle', () { + expect(const BeamRepeat.once().cycles, 1); + expect(const BeamRepeat.once(), const BeamRepeat.count(1)); + }); + + test('count keeps its cycle count and rejects zero', () { + expect(const BeamRepeat.count(4).cycles, 4); + expect(const BeamRepeat.count(4).toString(), 'BeamRepeat.count(4)'); + expect(() => BeamRepeat.count(0), throwsAssertionError); + expect(() => BeamRepeat.count(-1), throwsAssertionError); + }); + + test('equality is by cycle count', () { + expect(const BeamRepeat.count(2), const BeamRepeat.count(2)); + expect( + const BeamRepeat.count(2).hashCode, + const BeamRepeat.count(2).hashCode, + ); + expect(const BeamRepeat.count(2), isNot(const BeamRepeat.count(3))); + expect(const BeamRepeat.count(2), isNot(const BeamRepeat.forever())); + expect(const BeamRepeat.forever(), const BeamRepeat.forever()); + }); + }); + + group('BeamPathContour', () { + Path rectBuilder(Rect rect) => Path()..addRect(rect); + + test('build delegates to the builder', () { + const rect = Rect.fromLTWH(0, 0, 10, 20); + final contour = BeamPathContour(rectBuilder, key: 'rect'); + expect(contour.build(rect).getBounds(), rect); + }); + + test('equality is by key, not by builder identity', () { + expect( + BeamPathContour((rect) => Path()..addRect(rect), key: 'rect'), + BeamPathContour((rect) => Path()..addOval(rect), key: 'rect'), + reason: 'two closures are never equal; the key is the value', + ); + expect( + BeamPathContour(rectBuilder, key: 'rect').hashCode, + BeamPathContour(rectBuilder, key: 'rect').hashCode, + ); + expect( + BeamPathContour(rectBuilder, key: 'rect'), + isNot(BeamPathContour(rectBuilder, key: 'oval')), + ); + }); + + test('a record key compares by its fields', () { + expect( + BeamPathContour(rectBuilder, key: ('notch', 12.0)), + BeamPathContour(rectBuilder, key: ('notch', 12.0)), + ); + expect( + BeamPathContour(rectBuilder, key: ('notch', 12.0)), + isNot(BeamPathContour(rectBuilder, key: ('notch', 13.0))), + ); + }); + + test('toString names the key', () { + expect( + BeamPathContour(rectBuilder, key: 'oval').toString(), + 'BeamPathContour(oval)', + ); + }); + }); + + group('BorderBeamThemeData', () { + test('merge composes each slot field by field', () { + const outer = BorderBeamThemeData( + style: BeamStyle(colors: BeamColors.ocean, strength: 0.5), + timing: BeamTiming(cycle: Duration(seconds: 4)), + ); + const inner = BorderBeamThemeData( + style: BeamStyle(strength: 0.9), + shape: BeamShape(superellipse: true), + ); + final merged = outer.merge(inner); + expect(merged.style?.colors, BeamColors.ocean, reason: 'outer survives'); + expect(merged.style?.strength, 0.9, reason: 'inner wins'); + expect(merged.shape?.superellipse, isTrue); + expect(merged.timing?.cycle, const Duration(seconds: 4)); + expect(merged.playback, isNull); + }); + + test('merge takes the argument slot when the receiver has none', () { + const inner = BorderBeamThemeData(playback: BeamPlayback(active: false)); + expect( + const BorderBeamThemeData().merge(inner).playback, + const BeamPlayback(active: false), + ); + }); + + test('merge(null) returns the receiver unchanged', () { + const data = BorderBeamThemeData(shape: BeamShape(borderWidth: 3)); + expect(data.merge(null), data); + }); + + test('copyWith and equality are by value', () { + const data = BorderBeamThemeData(style: BeamStyle(hueBase: 5)); + expect( + data.copyWith(shape: const BeamShape(borderWidth: 2)), + const BorderBeamThemeData( + style: BeamStyle(hueBase: 5), + shape: BeamShape(borderWidth: 2), + ), + ); + expect( + data.hashCode, + const BorderBeamThemeData(style: BeamStyle(hueBase: 5)).hashCode, + ); + expect(data, isNot(const BorderBeamThemeData())); + }); + }); +} diff --git a/test/painting/beam_perimeter_test.dart b/test/painting/beam_perimeter_test.dart new file mode 100644 index 0000000..22e2f63 --- /dev/null +++ b/test/painting/beam_perimeter_test.dart @@ -0,0 +1,132 @@ +import 'dart:math' as math; + +import 'package:flutter/painting.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/painting/ring_geometry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const rect = Rect.fromLTWH(0, 0, 200, 100); + const radii = BorderRadius.all(Radius.circular(20)); + + BeamPerimeter rounded({bool superellipse = false}) { + final geometry = BeamRingGeometry( + rect: rect, + radius: radii, + borderWidth: 1, + useSuperellipse: superellipse, + ); + return geometry.perimeter; + } + + test('measures and aligns a rounded rectangle clockwise', () { + final perimeter = rounded(); + final analytic = 2 * (160 + 60) + 2 * math.pi * 20; + expect(perimeter.length, closeTo(analytic, analytic * 0.01)); + expect(perimeter.pointAt(0), within(distance: 0.5, from: rect.topCenter)); + expect( + perimeter.pointAt(0.5), + within(distance: 0.5, from: rect.bottomCenter), + ); + expect(perimeter.pointAt(0.25).dx, closeTo(rect.right, 1)); + expect(perimeter.pointAt(0.25).dy, inInclusiveRange(20, 80)); + expect(perimeter.pointAt(0.75).dx, closeTo(rect.left, 1)); + expect(perimeter.tangentAt(0).dx, closeTo(1, 0.02)); + expect(perimeter.normalAt(0).dy, closeTo(-1, 0.02)); + expect(perimeter.offsetPointAt(0, 5).dy, closeTo(5, 0.5)); + }); + + test('resolves straight edges and corner arcs', () { + final perimeter = rounded(); + expect(perimeter.fractionOfEdge(BeamEdge.right, 0.5), closeTo(0.25, 0.01)); + expect(perimeter.fractionOfEdge(BeamEdge.bottom, 0.5), closeTo(0.5, 0.01)); + expect(perimeter.fractionOfEdge(BeamEdge.left, 0.5), closeTo(0.75, 0.01)); + final corner = perimeter.fractionOfCorner(BeamCorner.topRight, 0.5); + final topEnd = perimeter.fractionOfEdge(BeamEdge.top, 1); + final rightStart = perimeter.fractionOfEdge(BeamEdge.right, 0); + expect(corner, greaterThan(topEnd)); + expect(corner, lessThan(rightStart)); + }); + + test('nearestFraction recovers sampled points', () { + final perimeter = rounded(); + for (final fraction in [0.03, 0.2, 0.49, 0.72, 0.94]) { + expect( + perimeter.nearestFraction(perimeter.pointAt(fraction)), + closeTo(fraction, 0.002), + ); + } + }); + + test('alignment works for square, wide, and superellipse contours', () { + final squareRect = const Rect.fromLTWH(10, 20, 100, 100); + final square = BeamPerimeter( + Path()..addRRect( + RRect.fromRectAndRadius(squareRect, const Radius.circular(12)), + ), + squareRect, + radii: const BorderRadius.all(Radius.circular(12)), + ); + expect( + square.pointAt(0), + within(distance: 0.5, from: squareRect.topCenter), + ); + expect(rounded().pointAt(0.25).dx, closeTo(rect.right, 1)); + expect( + rounded(superellipse: true).pointAt(0), + within(distance: 0.7, from: rect.topCenter), + ); + }); + + test('counter-clockwise source paths are flipped', () { + final path = Path() + ..moveTo(rect.topCenter.dx, rect.topCenter.dy) + ..lineTo(rect.left, rect.top) + ..lineTo(rect.left, rect.bottom) + ..lineTo(rect.right, rect.bottom) + ..lineTo(rect.right, rect.top) + ..close(); + final perimeter = BeamPerimeter(path, rect, radii: BorderRadius.zero); + expect(perimeter.pointAt(0), within(distance: 0.5, from: rect.topCenter)); + expect(perimeter.tangentAt(0).dx, closeTo(1, 0.01)); + expect(perimeter.pointAt(0.25).dx, closeTo(rect.right, 1)); + }); + + test('bands select lower and wrapping upper halves', () { + final perimeter = rounded(); + final lower = perimeter.band(from: 0.25, to: 0.75, inward: 4, outward: 4); + final upper = perimeter.band(from: 0.75, to: 0.25, inward: 4, outward: 4); + expect(lower.getBounds().top, greaterThan(40)); + expect(lower.getBounds().bottom, greaterThan(rect.bottom)); + expect(upper.getBounds().top, lessThan(rect.top)); + expect(upper.getBounds().bottom, lessThan(60)); + }); + + test('weightAt feathers, wraps, and clamps overlapping ramps', () { + final perimeter = rounded(); + double weight(double f) => + perimeter.weightAt(f, from: 0.25, to: 0.75, featherFraction: 0.1); + expect(weight(0.1), 0); + expect(weight(0.5), 1); + expect(weight(0.3), closeTo(0.5, 1e-9)); + expect(perimeter.weightAt(0, from: 0.75, to: 0.25, featherFraction: 0), 1); + expect( + perimeter.weightAt(0.225, from: 0.2, to: 0.3, featherFraction: 0.2), + closeTo(0.5, 1e-9), + ); + }); + + test('zero-length paths are safe', () { + final perimeter = BeamPerimeter(Path(), rect); + expect(perimeter.length, 0); + expect(perimeter.pointAt(0.5), rect.topCenter); + expect(perimeter.tangentAt(0.5), Offset.zero); + expect(perimeter.normalAt(0.5), Offset.zero); + expect(perimeter.nearestFraction(const Offset(20, 20)), 0); + expect(perimeter.featherFractionFor(20), 0); + expect( + perimeter.band(from: 0, to: 1, inward: 1, outward: 1).getBounds().isEmpty, + isTrue, + ); + }); +} diff --git a/test/painting/boundary_test.dart b/test/painting/boundary_test.dart new file mode 100644 index 0000000..dee5333 --- /dev/null +++ b/test/painting/boundary_test.dart @@ -0,0 +1,220 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Degenerate geometry and out-of-range parameters. Everything here is a +/// value a consumer can legitimately pass (or a layout can legitimately +/// produce), so no combination may throw — and `strength: 0` must paint +/// literally nothing. +void main() { + const normal = ui.Size(350, 140); + // Post fade-in samples spread over the cycle; the line variant paints + // nothing at the cycle edges, so pixel checks aggregate over all of them. + const samples = [0.0, 0.5, 1.3, 7.9]; + + BeamConfig configFor( + BeamVariant variant, { + double? borderRadius, + double? borderWidth, + double strength = 1, + double hueRange = 30, + bool staticColors = false, + Duration? cycleDuration, + ui.Brightness brightness = ui.Brightness.dark, + }) => BeamConfig.resolve( + variant: variant, + palette: BeamColors.colorful.resolve(), + brightness: brightness, + style: BeamStyle( + strength: strength, + hueRange: hueRange, + staticColors: staticColors, + ), + shape: BeamShape( + radius: borderRadius == null ? null : BorderRadius.circular(borderRadius), + borderWidth: borderWidth, + ), + timing: BeamTiming(cycle: cycleDuration), + ); + + /// Runs both passes across the samples, letting any exception escape. + void paintFrames(BeamVariant variant, BeamConfig config, ui.Size size) { + final resolver = BeamPhaseResolver(config); + final strategy = strategyFor(variant); + for (final t in samples) { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + final phases = resolver.sample(t, 1); + strategy.paintBehind(canvas, size, config, phases); + strategy.paintAbove(canvas, size, config, phases); + recorder.endRecording().dispose(); + } + } + + /// Whether any sample of the requested pass leaves a non-transparent pixel + /// inside [size]. + Future paintsPixels( + BeamVariant variant, + BeamConfig config, { + ui.Size size = normal, + bool behind = true, + bool above = true, + }) async { + final resolver = BeamPhaseResolver(config); + final strategy = strategyFor(variant); + for (final t in samples) { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + final phases = resolver.sample(t, 1); + if (behind) strategy.paintBehind(canvas, size, config, phases); + if (above) strategy.paintAbove(canvas, size, config, phases); + final picture = recorder.endRecording(); + final image = await picture.toImage( + size.width.toInt(), + size.height.toInt(), + ); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + picture.dispose(); + image.dispose(); + for (var i = 3; i < bytes!.lengthInBytes; i += 4) { + if (bytes.getUint8(i) != 0) return true; + } + } + return false; + } + + group('degenerate sizes', () { + const sizes = { + 'zero': ui.Size.zero, + '1x1': ui.Size(1, 1), + 'wide sliver': ui.Size(2000, 2), + 'tall sliver': ui.Size(2, 2000), + }; + for (final variant in BeamVariant.values) { + for (final MapEntry(key: name, value: size) in sizes.entries) { + test('$variant paints a $name box', () { + paintFrames(variant, configFor(variant), size); + }); + } + } + }); + + group('geometry parameters', () { + for (final variant in BeamVariant.values) { + test('$variant: radius larger than the shortest side', () async { + // 400 > 140: the ring geometry clamps it to half the short side. + final config = configFor(variant, borderRadius: 400); + paintFrames(variant, config, normal); + expect(await paintsPixels(variant, config), isTrue); + }); + + test('$variant: border wider than the radius', () async { + final config = configFor(variant, borderRadius: 4, borderWidth: 40); + paintFrames(variant, config, normal); + expect(await paintsPixels(variant, config), isTrue); + }); + + test('$variant: zero border width', () async { + // The ring collapses to an empty path — stroke layers paint nothing, + // but the inner/glow layers still do. + final config = configFor(variant, borderWidth: 0); + paintFrames(variant, config, normal); + expect(await paintsPixels(variant, config), isTrue); + }); + } + }); + + group('animation parameters', () { + for (final variant in BeamVariant.values) { + test('$variant: a 1ms cycle', () { + paintFrames( + variant, + configFor(variant, cycleDuration: const Duration(milliseconds: 1)), + normal, + ); + }); + + test('$variant: a one-hour cycle', () { + paintFrames( + variant, + configFor(variant, cycleDuration: const Duration(hours: 1)), + normal, + ); + }); + + test('$variant: hueRange 0', () async { + final config = configFor(variant, hueRange: 0); + paintFrames(variant, config, normal); + expect(await paintsPixels(variant, config), isTrue); + }); + + test('$variant: staticColors', () async { + final config = configFor(variant, staticColors: true); + paintFrames(variant, config, normal); + expect(await paintsPixels(variant, config), isTrue); + }); + } + }); + + group('strength 0', () { + for (final variant in BeamVariant.values) { + test('$variant paints nothing at all', () async { + final config = configFor(variant, strength: 0); + paintFrames(variant, config, normal); + expect( + await paintsPixels(variant, config, above: false), + isFalse, + reason: 'the behind-child pass must be fully suppressed', + ); + expect( + await paintsPixels(variant, config, behind: false), + isFalse, + reason: 'the above-child pass must be fully suppressed', + ); + }); + } + }); + + group('degenerate layouts', () { + Widget host(Widget child) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Scaffold(body: Center(child: child)), + ); + + testWidgets('a beam collapsed to zero size renders', (tester) async { + await tester.pumpWidget( + host( + const SizedBox.shrink( + child: BorderBeam.rotate(child: SizedBox.shrink()), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + expect(tester.takeException(), isNull); + }); + + testWidgets('a beam around a zero-height child in a Column renders', ( + tester, + ) async { + await tester.pumpWidget( + host( + Column( + mainAxisSize: MainAxisSize.min, + children: const [ + BorderBeam.rotate(child: SizedBox(width: 120, height: 0)), + ], + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/painting/color_matrix_test.dart b/test/painting/color_matrix_test.dart index b3f1e7a..c3cf52a 100644 --- a/test/painting/color_matrix_test.dart +++ b/test/painting/color_matrix_test.dart @@ -1,7 +1,6 @@ -import 'dart:ui'; - -import 'package:border_beam/src/painting/color_matrix.dart'; -import 'package:border_beam/src/painting/ring_geometry.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_border_beam/src/painting/color_matrix.dart'; +import 'package:flutter_border_beam/src/painting/ring_geometry.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -88,7 +87,7 @@ void main() { test('ring excludes the content box for rounded rects', () { final g = BeamRingGeometry( rect: const Rect.fromLTWH(0, 0, 100, 60), - radius: 16, + radius: BorderRadius.circular(16), borderWidth: 1, useSuperellipse: false, ); @@ -104,7 +103,7 @@ void main() { test('superellipse ring behaves the same', () { final g = BeamRingGeometry( rect: const Rect.fromLTWH(0, 0, 100, 60), - radius: 16, + radius: BorderRadius.circular(16), borderWidth: 1, useSuperellipse: true, ); @@ -117,15 +116,137 @@ void main() { expect(g.outer.contains(const Offset(8, 8)), isTrue); }); + test('a box thinner than twice the border has no content box', () { + for (final superellipse in [false, true]) { + final g = BeamRingGeometry( + rect: const Rect.fromLTWH(0, 0, 1, 1), + radius: BorderRadius.circular(16), + borderWidth: 1, + useSuperellipse: superellipse, + ); + expect(g.inner.getBounds().isEmpty, isTrue); + // Nothing is carved out: the ring is the whole box. The ring is + // `Path.combine`d rather than added directly, and combining + // re-flattens the contour — a superellipse corner comes back a few + // ten-thousandths of a pixel off the contour it was built from, by a + // margin that varies with the engine's flattening tolerance. Compare + // edge by edge with a tolerance instead of asking two Rects to be + // bit-identical. + final ring = g.ring.getBounds(); + final outer = g.outer.getBounds(); + expect(ring.left, closeTo(outer.left, 1e-3)); + expect(ring.top, closeTo(outer.top, 1e-3)); + expect(ring.right, closeTo(outer.right, 1e-3)); + expect(ring.bottom, closeTo(outer.bottom, 1e-3)); + expect(g.ring.contains(const Offset(0.5, 0.5)), isTrue); + } + }); + + test('an empty box has no contours at all', () { + for (final rect in const [ + Rect.zero, + Rect.fromLTWH(0, 0, 40, 0), + Rect.fromLTWH(0, 0, 0, 40), + ]) { + final g = BeamRingGeometry( + rect: rect, + radius: BorderRadius.circular(16), + borderWidth: 1, + useSuperellipse: false, + ); + expect(g.outer.getBounds().isEmpty, isTrue); + expect(g.inner.getBounds().isEmpty, isTrue); + expect(g.ring.getBounds().isEmpty, isTrue); + expect( + g.shapeContour(rect, BorderRadius.circular(16)).getBounds().isEmpty, + isTrue, + ); + } + }); + test('radius clamps to half the shortest side', () { final g = BeamRingGeometry( rect: const Rect.fromLTWH(0, 0, 40, 10), - radius: 32, + radius: BorderRadius.circular(32), borderWidth: 1, useSuperellipse: false, ); // Must not throw and must produce a valid ring. expect(g.ring.getBounds().isEmpty, isFalse); }); + + test('per-corner radii round only the corners they name', () { + final g = BeamRingGeometry( + rect: const Rect.fromLTWH(0, 0, 100, 60), + radius: const BorderRadius.only(topLeft: Radius.circular(20)), + borderWidth: 1, + useSuperellipse: false, + ); + // The rounded corner cuts its own square corner away… + expect(g.outer.contains(const Offset(1, 1)), isFalse); + // …while the other three stay right angles. + expect(g.outer.contains(const Offset(99, 1)), isTrue); + expect(g.outer.contains(const Offset(1, 59)), isTrue); + expect(g.outer.contains(const Offset(99, 59)), isTrue); + }); + + test('adjacent radii that overflow a side scale down together', () { + // 40 + 40 across a 60px-wide box: both top corners scale by 60/80. + final g = BeamRingGeometry( + rect: const Rect.fromLTWH(0, 0, 60, 200), + radius: const BorderRadius.vertical(top: Radius.circular(40)), + borderWidth: 1, + useSuperellipse: false, + ); + // At the scaled radius (30) the top edge is straight from x=30 on… + expect(g.outer.contains(const Offset(30, 0.5)), isTrue); + // …the corner is still cut away… + expect(g.outer.contains(const Offset(1, 1)), isFalse); + // …and the bottom corners were never rounded. + expect(g.outer.contains(const Offset(1, 199)), isTrue); + }); + + test('a stadium radius rounds to half the shortest side', () { + const rect = Rect.fromLTWH(0, 0, 120, 40); + final stadium = BeamRingGeometry( + rect: rect, + radius: const BorderRadius.all(Radius.circular(double.infinity)), + borderWidth: 1, + useSuperellipse: false, + ); + final pill = BeamRingGeometry( + rect: rect, + radius: BorderRadius.circular(20), + borderWidth: 1, + useSuperellipse: false, + ); + // The same shape as an explicit half-shortest-side radius. + expect(stadium.outer.getBounds(), pill.outer.getBounds()); + for (final probe in const [ + Offset(2, 2), + Offset(20, 0.5), + Offset(60, 0.5), + Offset(118, 38), + ]) { + expect( + stadium.outer.contains(probe), + pill.outer.contains(probe), + reason: 'probe $probe', + ); + } + }); + + test('a stadium square rounds to a circle', () { + final g = BeamRingGeometry( + rect: const Rect.fromLTWH(0, 0, 40, 40), + radius: const BorderRadius.all(Radius.circular(double.infinity)), + borderWidth: 1, + useSuperellipse: false, + ); + // Inside the inscribed circle, outside the square's corners. + expect(g.outer.contains(const Offset(20, 1)), isTrue); + expect(g.outer.contains(const Offset(2, 2)), isFalse); + expect(g.outer.contains(const Offset(38, 38)), isFalse); + }); }); } diff --git a/test/painting/paint_smoke_test.dart b/test/painting/paint_smoke_test.dart index 28a84dd..ff75d52 100644 --- a/test/painting/paint_smoke_test.dart +++ b/test/painting/paint_smoke_test.dart @@ -1,10 +1,11 @@ import 'dart:ui' as ui; -import 'package:border_beam/src/animation/beam_phases.dart'; -import 'package:border_beam/src/models/beam_colors.dart'; -import 'package:border_beam/src/models/beam_config.dart'; -import 'package:border_beam/src/models/beam_variant.dart'; -import 'package:border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/models/beam_colors.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/models/beam_shape.dart'; +import 'package:flutter_border_beam/src/models/beam_variant.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -17,6 +18,7 @@ void main() { 'colorful': BeamColors.colorful, 'mono': BeamColors.mono, 'ocean': BeamColors.ocean, + 'sunset': BeamColors.sunset, 'custom': const BeamColors.custom([ui.Color(0xFFFF0080)]), }; @@ -30,7 +32,7 @@ void main() { variant: variant, palette: colors.resolve(), brightness: brightness, - useSuperellipse: superellipse, + shape: BeamShape(superellipse: superellipse), ); final resolver = BeamPhaseResolver(config); final strategy = strategyFor(variant); diff --git a/test/painting/ring_geometry_test.dart b/test/painting/ring_geometry_test.dart new file mode 100644 index 0000000..e8cba59 --- /dev/null +++ b/test/painting/ring_geometry_test.dart @@ -0,0 +1,420 @@ +import 'dart:math' as math; +import 'dart:ui' show Brightness; + +import 'package:flutter/painting.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/painting/ring_geometry.dart'; +import 'package:flutter_border_beam/src/painting/variant_strategy.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Per-corner ring geometry: the beam contour takes a full [BorderRadius], +/// clamps it the way `RRect.scaleRadii` does, resolves an infinite radius +/// ([BeamShape.stadium]) to half the shortest side, and derives the inner +/// contour by shrinking every corner by the border width. +/// +/// Corner size is probed rather than read back: each test walks inward along +/// a corner's 45° diagonal and finds where the path starts containing +/// points. For a circular corner of radius r that entry sits at +/// `r * (sqrt2 - 1)` — the distance from the corner to the arc — which makes +/// the probe an exact measurement of the radius the geometry actually used. +void main() { + // Distance from the corner to the shape's boundary, along the inward 45° + // diagonal. + double diagonalEntry(Path path, Offset corner, Offset inward, double reach) { + expect( + path.contains(corner + inward * reach), + isTrue, + reason: 'the probe must end inside the shape', + ); + var outside = 0.0; + var inside = reach; + for (var i = 0; i < 60; i++) { + final mid = (outside + inside) / 2; + if (path.contains(corner + inward * mid)) { + inside = mid; + } else { + outside = mid; + } + } + return inside; + } + + // The four corners as (point, inward unit vector) pairs. + final diagonal = 1 / math.sqrt2; + Map cornersOf(Rect r) => { + 'topLeft': (r.topLeft, Offset(diagonal, diagonal)), + 'topRight': (r.topRight, Offset(-diagonal, diagonal)), + 'bottomLeft': (r.bottomLeft, Offset(diagonal, -diagonal)), + 'bottomRight': (r.bottomRight, Offset(-diagonal, -diagonal)), + }; + + // What `diagonalEntry` reads back for a circular corner of radius [r]. + double circularEntry(double r) => r * (math.sqrt2 - 1); + + BeamRingGeometry geometry( + Rect rect, + BorderRadius radius, { + double borderWidth = 1, + bool superellipse = false, + }) => BeamRingGeometry( + rect: rect, + radius: radius, + borderWidth: borderWidth, + useSuperellipse: superellipse, + ); + + // How far a flattened `RSuperellipse` contour may sit from the rect it was + // built on. The engine approximates the squircle with curves rather than + // tracing it exactly, and that approximation differs between Flutter 3.35 + // (this package's floor) and current stable by up to 0.12px. Two tenths + // covers that variation; the rrect family stays pinned to a hundredth. + const superellipseSlack = 0.2; + + void expectBounds(Path path, Rect rect, {double tolerance = 0.01}) { + final bounds = path.getBounds(); + expect(bounds.left, closeTo(rect.left, tolerance)); + expect(bounds.top, closeTo(rect.top, tolerance)); + expect(bounds.right, closeTo(rect.right, tolerance)); + expect(bounds.bottom, closeTo(rect.bottom, tolerance)); + } + + group('per-corner radii', () { + const rect = Rect.fromLTWH(0, 0, 200, 100); + const radius = BorderRadius.only( + topLeft: Radius.circular(40), + topRight: Radius.zero, + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(40), + ); + + for (final superellipse in [false, true]) { + final family = superellipse ? 'superellipse' : 'circular'; + + test('$family: the outer contour still spans the whole rect', () { + expectBounds( + geometry(rect, radius, superellipse: superellipse).outer, + rect, + tolerance: superellipse ? superellipseSlack : 0.01, + ); + }); + + test('$family: a rounded corner drops a point a square corner keeps', () { + final outer = geometry(rect, radius, superellipse: superellipse).outer; + // The same offset from each corner: cut away where the radius is + // large, kept where the corner is square. + expect( + outer.contains(const Offset(3, 3)), + isFalse, + reason: 'inside the 40px topLeft corner', + ); + expect( + outer.contains(const Offset(197, 3)), + isTrue, + reason: 'the topRight corner is square', + ); + expect( + outer.contains(const Offset(197, 97)), + isFalse, + reason: 'inside the 40px bottomRight corner', + ); + expect( + outer.contains(const Offset(100, 50)), + isTrue, + reason: 'the middle is always inside', + ); + }); + } + + test('circular: every corner measures the radius it was given', () { + final outer = geometry(rect, radius).outer; + final corners = cornersOf(rect); + double entryAt(String corner) { + final (point, inward) = corners[corner]!; + return diagonalEntry(outer, point, inward, 50); + } + + expect(entryAt('topLeft'), closeTo(circularEntry(40), 0.05)); + expect(entryAt('topRight'), lessThan(0.05), reason: 'a square corner'); + expect(entryAt('bottomLeft'), closeTo(circularEntry(10), 0.05)); + expect(entryAt('bottomRight'), closeTo(circularEntry(40), 0.05)); + }); + + test('superellipse: corner size still follows the per-corner radii', () { + final outer = geometry(rect, radius, superellipse: true).outer; + final corners = cornersOf(rect); + double entryAt(String corner) { + final (point, inward) = corners[corner]!; + return diagonalEntry(outer, point, inward, 50); + } + + expect(entryAt('topRight'), lessThan(0.05), reason: 'a square corner'); + expect(entryAt('topLeft'), closeTo(entryAt('bottomRight'), 0.05)); + expect( + entryAt('topLeft'), + greaterThan(entryAt('bottomLeft')), + reason: '40px cuts deeper than 10px', + ); + }); + }); + + group('scaleRadii clamping', () { + // 80 + 40 on a 100px top edge: both scale by 100/120, keeping their 2:1 + // proportion instead of one being clipped. + const rect = Rect.fromLTWH(0, 0, 100, 100); + const radius = BorderRadius.only( + topLeft: Radius.circular(80), + topRight: Radius.circular(40), + ); + const scale = 100 / 120; + + test('matches what RRect.scaleRadii would produce', () { + final reference = RRect.fromRectAndCorners( + rect, + topLeft: const Radius.circular(80), + topRight: const Radius.circular(40), + ).scaleRadii(); + expect(reference.tlRadiusX, closeTo(80 * scale, 1e-9)); + expect(reference.trRadiusX, closeTo(40 * scale, 1e-9)); + + final outer = geometry(rect, radius).outer; + final corners = cornersOf(rect); + final (tlPoint, tlInward) = corners['topLeft']!; + final (trPoint, trInward) = corners['topRight']!; + expect( + diagonalEntry(outer, tlPoint, tlInward, 50), + closeTo(circularEntry(80 * scale), 0.05), + ); + expect( + diagonalEntry(outer, trPoint, trInward, 50), + closeTo(circularEntry(40 * scale), 0.05), + ); + }); + + test('both radii shrink by the same ratio', () { + final outer = geometry(rect, radius).outer; + final corners = cornersOf(rect); + final (tlPoint, tlInward) = corners['topLeft']!; + final (trPoint, trInward) = corners['topRight']!; + final tl = diagonalEntry(outer, tlPoint, tlInward, 50); + final tr = diagonalEntry(outer, trPoint, trInward, 50); + expect(tl / tr, closeTo(2, 0.02), reason: 'the 2:1 proportion survives'); + }); + + test('superellipse clamps too and stays inside the rect', () { + final outer = geometry(rect, radius, superellipse: true).outer; + expectBounds(outer, rect, tolerance: superellipseSlack); + final corners = cornersOf(rect); + final (tlPoint, tlInward) = corners['topLeft']!; + final (trPoint, trInward) = corners['topRight']!; + final tl = diagonalEntry(outer, tlPoint, tlInward, 50); + final tr = diagonalEntry(outer, trPoint, trInward, 50); + expect(tl, greaterThan(tr)); + expect( + tl, + lessThan(circularEntry(80) * 1.6), + reason: 'an unclamped 80px corner would cut deeper', + ); + }); + }); + + group('stadium (infinite radius)', () { + const infinite = BorderRadius.all(Radius.circular(double.infinity)); + + test('a 200x40 rect resolves to a 20px radius on every corner', () { + const rect = Rect.fromLTWH(0, 0, 200, 40); + final outer = geometry(rect, infinite).outer; + expectBounds(outer, rect); + for (final MapEntry(key: name, value: (point, inward)) in cornersOf( + rect, + ).entries) { + expect( + diagonalEntry(outer, point, inward, 20), + closeTo(circularEntry(20), 0.05), + reason: name, + ); + } + expect(outer.contains(const Offset(1, 1)), isFalse, reason: 'pill end'); + expect( + outer.contains(const Offset(100, 1)), + isTrue, + reason: 'the straight top edge', + ); + }); + + test('the radius tracks the box as it resizes', () { + const tall = Rect.fromLTWH(0, 0, 200, 100); + final corners = cornersOf(tall); + final (point, inward) = corners['topLeft']!; + expect( + diagonalEntry(geometry(tall, infinite).outer, point, inward, 60), + closeTo(circularEntry(50), 0.05), + reason: 'half the 100px short side', + ); + }); + + test('a square box comes out a circle', () { + const square = Rect.fromLTWH(0, 0, 80, 80); + final outer = geometry(square, infinite).outer; + // Every point at radius 40 from the centre is on the boundary: just + // inside is contained, just outside is not. + for (final angle in [0.0, 0.7, 1.9, 3.4, 5.2]) { + final unit = Offset(math.cos(angle), math.sin(angle)); + expect(outer.contains(const Offset(40, 40) + unit * 39), isTrue); + expect(outer.contains(const Offset(40, 40) + unit * 41), isFalse); + } + }); + + test('superellipse stadium is symmetric across all four corners', () { + const rect = Rect.fromLTWH(0, 0, 200, 40); + final outer = geometry(rect, infinite, superellipse: true).outer; + expectBounds(outer, rect, tolerance: superellipseSlack); + final entries = [ + for (final (point, inward) in cornersOf(rect).values) + diagonalEntry(outer, point, inward, 20), + ]; + for (final entry in entries) { + expect(entry, closeTo(entries.first, 0.05)); + } + expect(entries.first, greaterThan(0)); + }); + }); + + group('inner contour', () { + const rect = Rect.fromLTWH(0, 0, 200, 100); + + test('every corner shrinks by the border width', () { + const radius = BorderRadius.only( + topLeft: Radius.circular(40), + bottomRight: Radius.circular(12), + ); + final ring = geometry(rect, radius, borderWidth: 5); + final inner = rect.deflate(5); + expectBounds(ring.inner, inner); + final corners = cornersOf(inner); + final (tlPoint, tlInward) = corners['topLeft']!; + final (brPoint, brInward) = corners['bottomRight']!; + expect( + diagonalEntry(ring.inner, tlPoint, tlInward, 50), + closeTo(circularEntry(35), 0.05), + ); + expect( + diagonalEntry(ring.inner, brPoint, brInward, 50), + closeTo(circularEntry(7), 0.05), + ); + }); + + test('a corner smaller than the border width floors at square', () { + const radius = BorderRadius.all(Radius.circular(3)); + final ring = geometry(rect, radius, borderWidth: 5); + final corners = cornersOf(rect.deflate(5)); + final (point, inward) = corners['topLeft']!; + expect(diagonalEntry(ring.inner, point, inward, 20), lessThan(0.05)); + }); + + test('a box thinner than twice the border has no inner contour', () { + const sliver = Rect.fromLTWH(0, 0, 200, 8); + final ring = geometry(sliver, BorderRadius.circular(4), borderWidth: 5); + expect(ring.inner.getBounds().isEmpty, isTrue); + expect( + ring.ring.contains(const Offset(100, 4)), + isTrue, + reason: 'the ring is then the whole shape', + ); + }); + + test('the ring is the band between the two contours', () { + final ring = geometry(rect, BorderRadius.circular(16), borderWidth: 4); + expect(ring.ring.contains(const Offset(100, 2)), isTrue); + expect(ring.ring.contains(const Offset(100, 50)), isFalse); + }); + + test('an empty rect has no ring at all', () { + final ring = geometry(Rect.zero, BorderRadius.circular(8)); + expect(ring.ring.getBounds().isEmpty, isTrue); + expect(ring.outer.getBounds().isEmpty, isTrue); + }); + }); + + group('contour', () { + test('builds an arbitrary rect in the same shape family', () { + const rect = Rect.fromLTWH(0, 0, 200, 100); + final ring = geometry(rect, BorderRadius.circular(16)); + const other = Rect.fromLTWH(10, 10, 100, 60); + final path = ring.shapeContour(other, BorderRadius.circular(20)); + expectBounds(path, other); + final corners = cornersOf(other); + final (point, inward) = corners['topLeft']!; + expect( + diagonalEntry(path, point, inward, 30), + closeTo(circularEntry(20), 0.05), + ); + }); + }); + + group('segment integration', () { + const rect = Rect.fromLTWH(0, 0, 200, 100); + + for (final superellipse in [false, true]) { + test('${superellipse ? 'superellipse' : 'rrect'} band and weight', () { + final geometry = BeamRingGeometry( + rect: rect, + radius: BorderRadius.circular(20), + borderWidth: 2, + useSuperellipse: superellipse, + segment: BeamSegment.bottomHalf, + ); + expect(geometry.segmentRange?.from, closeTo(0.25, 0.02)); + expect(geometry.segmentRange?.to, closeTo(0.75, 0.02)); + expect( + geometry.segmentBand(inward: 3, outward: 3).getBounds().top, + greaterThan(35), + ); + expect(geometry.segmentWeightAt(0.5), 1); + expect(geometry.segmentWeightAt(0), 0); + }); + } + + test('no segment leaves band and weight neutral', () { + final geometry = BeamRingGeometry( + rect: rect, + radius: BorderRadius.circular(20), + borderWidth: 2, + useSuperellipse: false, + ); + expect(geometry.segmentRange, isNull); + expect(geometry.segmentBand(inward: 2, outward: 2).getBounds(), rect); + expect(geometry.segmentWeightAt(0.9), 1); + }); + }); + + group('beamGeometry memo', () { + BeamConfig config({double radius = 12}) => BeamConfig.resolve( + variant: BeamVariant.rotate, + palette: BeamColors.colorful.resolve(), + brightness: Brightness.dark, + shape: BeamShape.circular(radius, segment: BeamSegment.bottomHalf), + ); + + test('equal rect and config return the identical geometry', () { + const rect = Rect.fromLTWH(0, 0, 180, 80); + final first = beamGeometry(rect, config()); + final second = beamGeometry(rect, config()); + expect(identical(first, second), isTrue); + }); + + test('a changed rect or config returns new geometry', () { + const rect = Rect.fromLTWH(0, 0, 180, 80); + final baseConfig = config(); + final first = beamGeometry(rect, baseConfig); + expect( + identical( + first, + beamGeometry(const Rect.fromLTWH(0, 0, 181, 80), baseConfig), + ), + isFalse, + ); + expect(identical(first, beamGeometry(rect, config(radius: 13))), isFalse); + }); + }); +} diff --git a/test/painting/save_layer_budget_test.dart b/test/painting/save_layer_budget_test.dart new file mode 100644 index 0000000..b7472dc --- /dev/null +++ b/test/painting/save_layer_budget_test.dart @@ -0,0 +1,369 @@ +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/painting.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/models/beam_colors.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/models/beam_options.dart'; +import 'package:flutter_border_beam/src/models/beam_segment.dart'; +import 'package:flutter_border_beam/src/models/beam_shape.dart'; +import 'package:flutter_border_beam/src/models/beam_style.dart'; +import 'package:flutter_border_beam/src/models/beam_timing.dart'; +import 'package:flutter_border_beam/src/models/beam_variant.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Enforces CLAUDE.md hard rule 2: the per-frame `saveLayer` budget. +/// +/// Every `saveLayer` is an offscreen render target, so the count is the +/// single cheapest proxy for the cost of a beam frame. The budget below is +/// the measured count for a full frame (`paintBehind` + `paintAbove`) of each +/// variant; it must only ever go down. +void main() { + // Measured maxima over every brightness × palette × time sample. Each entry + // is one layer composite: the layers a variant stacks over the child, plus + // the mask sub-layers that intersect two masks, plus (pulse-outside) the + // two behind-child glow layers. + // + // rotate 4 — inner + inner mask + stroke + blurred bloom + // small 3 — inner + stroke + blurred bloom (single-mask inner) + // line 4 — inner + inner mask + stroke + blurred bloom + // pulseInside 4 — inner + inner mask + stroke + blurred bloom + // pulseOutside 3 — behind: core glow + bloom halo; above: stroke + const budget = { + BeamVariant.rotate: 4, + BeamVariant.small: 3, + BeamVariant.line: 4, + BeamVariant.pulseInside: 4, + BeamVariant.pulseOutside: 3, + }; + + const size = ui.Size(350, 140); + final palettes = { + 'colorful': BeamColors.colorful, + 'mono': BeamColors.mono, + 'custom': const BeamColors.custom([ui.Color(0xFFFF0080)]), + }; + // Post fade-in samples spread over the cycle: mid-travel, the golden + // freeze, and a late frame several cycles in. + const samples = [0.5, 1.3, 7.9]; + + for (final variant in BeamVariant.values) { + test('$variant stays within its saveLayer budget', () { + var worst = 0; + var worstCase = ''; + for (final brightness in ui.Brightness.values) { + for (final MapEntry(key: name, value: colors) in palettes.entries) { + final config = BeamConfig.resolve( + variant: variant, + palette: colors.resolve(), + brightness: brightness, + ); + final resolver = BeamPhaseResolver(config); + final strategy = strategyFor(variant); + for (final t in samples) { + final recorder = ui.PictureRecorder(); + final canvas = _CountingCanvas(ui.Canvas(recorder)); + final phases = resolver.sample(t, 1); + strategy.paintBehind(canvas, size, config, phases); + strategy.paintAbove(canvas, size, config, phases); + recorder.endRecording().dispose(); + + expect( + canvas.unforwarded, + isEmpty, + reason: + 'the counting canvas does not forward ' + '${canvas.unforwarded.join(', ')} — add explicit ' + 'delegating overrides so painting is still exercised', + ); + // Every layer and every clip scope must be closed, or the + // painter would leak state into whatever draws next. + expect( + canvas.saves + canvas.saveLayers, + canvas.restores, + reason: + 'unbalanced save/restore for $variant/$brightness/$name ' + 'at t=$t: ${canvas.saves} save + ${canvas.saveLayers} ' + 'saveLayer vs ${canvas.restores} restore', + ); + if (canvas.saveLayers > worst) { + worst = canvas.saveLayers; + worstCase = '$brightness/$name at t=${t}s'; + } + } + } + } + // ignore: avoid_print + print('$variant: max $worst saveLayer/frame ($worstCase)'); + expect( + worst, + budget[variant], + reason: + 'the $variant frame now issues $worst saveLayer calls ' + '(worst case $worstCase). Fewer is a win — update the budget ' + 'table. More is a regression: fold the new work into an ' + 'existing layer.', + ); + }); + } + + // Every Phase 3 surface option, measured against the SAME table. None of + // them may buy its effect with a layer: the comet re-clips the bloom layer + // it already had, the sparkles and the dash mask ride inside layers that + // exist anyway, a wider glow only changes a blur sigma, extra beams and a + // turned edge change geometry, and a contour changes which path the ring + // is cut from. + final scenarios = + { + 'comet': ( + style: const BeamStyle(comet: true), + shape: const BeamShape(), + timing: const BeamTiming(), + ), + 'sparkle: 1': ( + style: const BeamStyle(sparkle: 1), + shape: const BeamShape(), + timing: const BeamTiming(), + ), + 'segments: 8': ( + style: const BeamStyle(segments: 8), + shape: const BeamShape(), + timing: const BeamTiming(), + ), + 'glowSpread: 2': ( + style: const BeamStyle(glowSpread: 2), + shape: const BeamShape(), + timing: const BeamTiming(), + ), + 'tailLength: 2': ( + style: const BeamStyle(tailLength: 2), + shape: const BeamShape(), + timing: const BeamTiming(), + ), + 'beamCount: 3': ( + style: const BeamStyle(), + shape: const BeamShape(), + timing: const BeamTiming(beamCount: 3), + ), + 'direction: reverse': ( + style: const BeamStyle(), + shape: const BeamShape(), + timing: const BeamTiming(direction: BeamDirection.reverse), + ), + 'edge: left': ( + style: const BeamStyle(), + shape: const BeamShape(edge: BeamEdge.left), + timing: const BeamTiming(), + ), + 'ringOffset: 8': ( + style: const BeamStyle(), + shape: const BeamShape(ringOffset: 8), + timing: const BeamTiming(), + ), + 'contour': ( + style: const BeamStyle(), + shape: BeamShape(contour: _blobContour), + timing: const BeamTiming(), + ), + 'segment: bottomHalf': ( + style: const BeamStyle(), + shape: const BeamShape(segment: BeamSegment.bottomHalf), + timing: const BeamTiming(), + ), + 'segment: bottomEdge': ( + style: const BeamStyle(), + shape: const BeamShape(segment: BeamSegment.bottomEdge), + timing: const BeamTiming(), + ), + 'segment feather: 0': ( + style: const BeamStyle(), + shape: const BeamShape( + segment: BeamSegment( + start: BeamAnchor.rightCenter, + end: BeamAnchor.leftCenter, + feather: 0, + ), + ), + timing: const BeamTiming(), + ), + 'line wrapCorners': ( + style: const BeamStyle(), + shape: const BeamShape(wrapCorners: true), + timing: const BeamTiming(), + ), + // renderScale is one canvas transform inside BeamPainter, so a + // strategy frame is the same frame; innerSizeScale only resizes + // blobs a layer already holds; and the stock pulse-outside table + // only moves insets and blur sigmas. + 'renderScale: 0.5': ( + style: const BeamStyle(renderScale: 0.5), + shape: const BeamShape(), + timing: const BeamTiming(), + ), + 'innerSizeScale: 0.6': ( + style: const BeamStyle(innerSizeScale: 0.6), + shape: const BeamShape(), + timing: const BeamTiming(), + ), + 'pulseOutsideStock': ( + style: BeamStyle.pulseOutsideStock, + shape: const BeamShape(), + timing: const BeamTiming(), + ), + }; + + for (final MapEntry(key: label, value: scenario) in scenarios.entries) { + for (final variant in BeamVariant.values) { + test('$variant with $label stays within its saveLayer budget', () { + var worst = 0; + var worstCase = ''; + for (final brightness in ui.Brightness.values) { + final config = BeamConfig.resolve( + variant: variant, + palette: BeamColors.colorful.resolve(), + brightness: brightness, + style: scenario.style, + shape: scenario.shape, + timing: scenario.timing, + ); + final resolver = BeamPhaseResolver(config); + final strategy = strategyFor(variant); + for (final t in samples) { + final recorder = ui.PictureRecorder(); + final canvas = _CountingCanvas(ui.Canvas(recorder)); + final phases = resolver.sample(t, 1); + strategy.paintBehind(canvas, size, config, phases); + strategy.paintAbove(canvas, size, config, phases); + recorder.endRecording().dispose(); + + expect(canvas.unforwarded, isEmpty); + expect( + canvas.saves + canvas.saveLayers, + canvas.restores, + reason: + 'unbalanced save/restore for $variant/$label/$brightness ' + 'at t=$t', + ); + if (canvas.saveLayers > worst) { + worst = canvas.saveLayers; + worstCase = '$brightness at t=${t}s'; + } + } + } + expect( + worst, + budget[variant], + reason: + '$label changed the $variant frame to $worst saveLayer calls ' + '(worst case $worstCase). A surface option must use exactly ' + 'the layers the variant already composites.', + ); + }); + } + } +} + +/// A lobed contour: enough of a departure from a rounded rect to exercise the +/// offset path, and value-equal so the config caches it. +final _blobContour = BeamPathContour((rect) { + final path = Path(); + final centre = rect.center; + for (var i = 0; i <= 48; i++) { + final a = i / 48 * 2 * math.pi; + final r = rect.shortestSide / 2 * (0.75 + 0.25 * (i % 8 < 4 ? 1 : 0)); + final p = Offset( + centre.dx + r * 1.6 * math.cos(a), + centre.dy + r * math.sin(a), + ); + if (i == 0) { + path.moveTo(p.dx, p.dy); + } else { + path.lineTo(p.dx, p.dy); + } + } + return path..close(); +}, key: 'budget-blob'); + +/// A [ui.Canvas] that counts layer and clip scopes while forwarding the draw +/// calls to a real canvas, so the painters run exactly as they do on screen. +/// +/// Everything the strategies call is delegated explicitly; anything else +/// lands in [unforwarded] via `noSuchMethod` so a new draw call fails the +/// test loudly instead of silently going unpainted. +class _CountingCanvas implements ui.Canvas { + _CountingCanvas(this._inner); + + final ui.Canvas _inner; + + int saves = 0; + int saveLayers = 0; + int restores = 0; + final Set unforwarded = {}; + + @override + void save() { + saves++; + _inner.save(); + } + + @override + void saveLayer(ui.Rect? bounds, ui.Paint paint) { + saveLayers++; + _inner.saveLayer(bounds, paint); + } + + @override + void restore() { + restores++; + _inner.restore(); + } + + @override + void clipPath(ui.Path path, {bool doAntiAlias = true}) => + _inner.clipPath(path, doAntiAlias: doAntiAlias); + + @override + void clipRect( + ui.Rect rect, { + ui.ClipOp clipOp = ui.ClipOp.intersect, + bool doAntiAlias = true, + }) => _inner.clipRect(rect, clipOp: clipOp, doAntiAlias: doAntiAlias); + + @override + void drawRect(ui.Rect rect, ui.Paint paint) => _inner.drawRect(rect, paint); + + @override + void drawPath(ui.Path path, ui.Paint paint) => _inner.drawPath(path, paint); + + @override + void drawCircle(ui.Offset c, double radius, ui.Paint paint) => + _inner.drawCircle(c, radius, paint); + + @override + void translate(double dx, double dy) => _inner.translate(dx, dy); + + @override + void scale(double sx, [double? sy]) => _inner.scale(sx, sy); + + @override + void rotate(double radians) => _inner.rotate(radians); + + @override + void transform(Float64List matrix4) => _inner.transform(matrix4); + + @override + dynamic noSuchMethod(Invocation invocation) { + unforwarded.add(_symbolName(invocation.memberName)); + return null; + } +} + +String _symbolName(Symbol symbol) { + final text = symbol.toString(); + final start = text.indexOf('"'); + final end = text.lastIndexOf('"'); + return start >= 0 && end > start ? text.substring(start + 1, end) : text; +} diff --git a/test/painting/segment_paint_test.dart b/test/painting/segment_paint_test.dart new file mode 100644 index 0000000..a26eff4 --- /dev/null +++ b/test/painting/segment_paint_test.dart @@ -0,0 +1,411 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/painting.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/constants/line_keyframes.dart'; +import 'package:flutter_border_beam/src/models/beam_blob.dart'; +import 'package:flutter_border_beam/src/models/beam_colors.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/models/beam_options.dart'; +import 'package:flutter_border_beam/src/models/beam_segment.dart'; +import 'package:flutter_border_beam/src/models/beam_shape.dart'; +import 'package:flutter_border_beam/src/models/beam_style.dart'; +import 'package:flutter_border_beam/src/models/beam_variant.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_border_beam/src/painting/ring_geometry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _size = ui.Size.square(300); + +void main() { + group('rotate segment mask', () { + test('bottomHalf paints only the lower half over a full cycle', () async { + final frames = await _cycleFrames( + _config(BeamVariant.rotate, segment: BeamSegment.bottomHalf), + ); + expect( + frames.totalAlpha(const ui.Rect.fromLTRB(0, 180, 300, 300)), + greaterThan(10), + ); + expect(frames.totalAlpha(const ui.Rect.fromLTRB(0, 0, 300, 120)), 0); + }); + + test('topHalf paints only the upper half over a full cycle', () async { + final frames = await _cycleFrames( + _config(BeamVariant.rotate, segment: BeamSegment.topHalf), + ); + expect( + frames.totalAlpha(const ui.Rect.fromLTRB(0, 0, 300, 120)), + greaterThan(10), + ); + expect(frames.totalAlpha(const ui.Rect.fromLTRB(0, 180, 300, 300)), 0); + }); + + test('feather zero cuts hard while feather 40 ramps', () async { + const hard = BeamSegment( + start: BeamAnchor.rightCenter, + end: BeamAnchor.leftCenter, + feather: 0, + ); + const soft = BeamSegment( + start: BeamAnchor.rightCenter, + end: BeamAnchor.leftCenter, + feather: 40, + ); + final hardPixels = await _cycleFrames( + _config(BeamVariant.rotate, segment: hard), + ); + final softPixels = await _cycleFrames( + _config(BeamVariant.rotate, segment: soft), + ); + final geometry = BeamRingGeometry( + rect: ui.Offset.zero & _size, + radius: BorderRadius.circular(16), + borderWidth: 1, + useSuperellipse: false, + segment: soft, + ); + final start = geometry.segmentRange!.from; + double sample(_Pixels pixels, double distance) { + final point = geometry.perimeter.offsetPointAt( + start + distance / geometry.perimeter.length, + 1, + ); + return pixels.alphaNear(point); + } + + // The bloom is clipped inside its blurred saveLayer, so it legitimately + // bleeds across even a hard segment edge. The unblurred layers still + // make the zero-feather transition much steeper than a feathered one. + // alphaNear reads a 5x5 neighborhood, so the two sides of the step are + // sampled 8px apart to keep their windows disjoint. + final hardStep = sample(hardPixels, 8) - sample(hardPixels, -8); + final softStep = sample(softPixels, 8) - sample(softPixels, -8); + expect(sample(hardPixels, 2), greaterThan(0.05)); + expect(hardStep, greaterThan(softStep + 0.02)); + final ramp = [ + for (final d in [2.0, 10.0, 20.0, 30.0, 42.0]) sample(softPixels, d), + ]; + for (var i = 1; i < ramp.length; i++) { + expect(ramp[i], greaterThanOrEqualTo(ramp[i - 1] - 0.02)); + } + expect(ramp.last, greaterThan(ramp.first)); + }); + }); + + group('line path travel', () { + test('traveller follows the bottom half perimeter', () async { + Future<_Pixels> at(double progress) => _paint( + _config(BeamVariant.line, segment: BeamSegment.bottomHalf), + phases: BeamFramePhases( + fadeOpacity: 1, + hueDegrees: 0, + travelProgress: progress, + travellers: [progress], + ), + ); + + final middle = await at(0.5); + // The stock edge-fade is fully transparent at 0.1/0.9, so probe just + // inside those ramps while the anchors are still on the side runs. + final early = await at(0.15); + final late = await at(0.85); + expect( + middle.totalAlpha(const ui.Rect.fromLTRB(110, 250, 190, 300)), + greaterThan(2), + ); + expect( + early.totalAlpha(const ui.Rect.fromLTRB(250, 150, 300, 250)), + greaterThan(1), + ); + expect( + late.totalAlpha(const ui.Rect.fromLTRB(0, 150, 50, 250)), + greaterThan(1), + ); + }); + + test('wrapCorners bends into the bottom-right corner arc', () async { + // A 60px radius on the 300px box gives 94px corner arcs — long enough + // for a bent traveller to sit well inside one while the temporal edge + // fade still carries it. Both runs travel left to right, so 0.82 puts + // the planar anchor 9px short of the bottom-right arc, where its run + // ends, and the wrapped anchor 28px into that same arc. + const progress = 0.82; + final planar = await _paint( + _config(BeamVariant.line, radius: 60), + phases: _linePhasesAt(progress), + ); + final wrapped = await _paint( + _config(BeamVariant.line, wrapCorners: true, radius: 60), + phases: _linePhasesAt(progress), + ); + // A 24px box straddling the bottom-right arc's 45° point — (282, 282), + // on the arc about (240, 240). + const arc = ui.Rect.fromLTRB(270, 270, 294, 294); + final bent = wrapped.totalAlpha(arc); + // The wrapped traveller rides the arc; the planar one never leaves the + // straight run, so the arc catches only what its glow spills into it. + expect(bent, greaterThan(1)); + expect(bent, greaterThan(planar.totalAlpha(arc) + 6)); + expect(bent, greaterThan(planar.totalAlpha(arc) * 3)); + }); + + // wrapCorners is a modifier on the ordinary line variant, so turning it on + // must never reverse the animation — only lengthen the run around the two + // corners. Planar travel is authored bottom-edge left to right and turned + // rigidly for the other edges, which leaves it counter-clockwise on all + // four. [forward] is travel toward growing x (horizontal) or y (vertical). + for (final (edge, axis, forward) in [ + (BeamEdge.bottom, Axis.horizontal, true), + (BeamEdge.top, Axis.horizontal, false), + (BeamEdge.left, Axis.vertical, true), + (BeamEdge.right, Axis.vertical, false), + ]) { + test('wrapCorners keeps the planar travel direction on $edge', () async { + Future centre(bool wrap, double progress) async { + final pixels = await _paint( + _config(BeamVariant.line, edge: edge, wrapCorners: wrap), + phases: _linePhasesAt(progress), + ); + final centroid = pixels.centroid(); + return axis == Axis.horizontal ? centroid.dx : centroid.dy; + } + + final planarEarly = await centre(false, 0.25); + final planarLate = await centre(false, 0.75); + final wrappedEarly = await centre(true, 0.25); + final wrappedLate = await centre(true, 0.75); + + // The planar run's direction, and the wrapped run agreeing with it + // rather than starting from the far end. + expect(planarLate > planarEarly, forward); + expect(wrappedLate > wrappedEarly, forward); + expect(wrappedEarly < 150, forward); + expect(wrappedLate > 150, forward); + // The two anchors coincide at mid-run and stay close at the quarter + // points: equal progress lands a little further along the wrapped run, + // which is 327px of perimeter against the planar run's 300px of edge. + expect(await centre(true, 0.5), closeTo(await centre(false, 0.5), 5)); + expect(wrappedEarly, closeTo(planarEarly, 25)); + expect(wrappedLate, closeTo(planarLate, 25)); + }); + } + }); + + for (final variant in [BeamVariant.pulseInside, BeamVariant.pulseOutside]) { + test('$variant paints no pixels above center with bottomHalf', () async { + final pixels = await _paint( + _config(variant, segment: BeamSegment.bottomHalf), + behindAndAbove: true, + ); + expect(pixels.totalAlpha(const ui.Rect.fromLTRB(0, 0, 300, 140)), 0); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 160, 300, 300)), + greaterThan(1), + ); + }); + } + + test('segmented stroke bands retain visible ring pixels', () async { + for (final variant in BeamVariant.values) { + final pixels = await _cycleFrames( + _config(variant, segment: BeamSegment.bottomHalf), + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 250, 300, 300)), + greaterThan(1), + reason: '$variant', + ); + } + }); + + test('rotate sparkles cannot spill beyond a segment band', () async { + final pixels = await _cycleFrames( + _config( + BeamVariant.rotate, + segment: BeamSegment.bottomHalf, + style: const BeamStyle(sparkle: 1), + ), + ); + expect(pixels.totalAlpha(const ui.Rect.fromLTRB(0, 0, 300, 120)), 0); + }); + + test('pulse variants cycle a short spec palette without throwing', () async { + const short = BeamColors.spec( + border: [ + BeamBlob( + color: ui.Color(0xFFFF0080), + position: ui.Offset(0.5, 0.5), + size: ui.Size(40, 24), + ), + ], + ); + for (final variant in [BeamVariant.pulseInside, BeamVariant.pulseOutside]) { + final pixels = await _paint( + _config(variant, colors: short), + behindAndAbove: true, + ); + expect( + pixels.totalAlpha(ui.Offset.zero & _size), + greaterThan(1), + reason: '$variant', + ); + } + }); + + test('an omitted segment and an explicit null paint identically', () async { + final omitted = BeamConfig.resolve( + variant: BeamVariant.rotate, + palette: BeamColors.colorful.resolve(), + brightness: ui.Brightness.dark, + ); + final explicit = BeamConfig.resolve( + variant: BeamVariant.rotate, + palette: BeamColors.colorful.resolve(), + brightness: ui.Brightness.dark, + shape: const BeamShape(segment: null, wrapCorners: false), + ); + final a = await _paint(omitted, t: 1.3, behindAndAbove: true); + final b = await _paint(explicit, t: 1.3, behindAndAbove: true); + expect(a.bytes, b.bytes); + }); +} + +BeamConfig _config( + BeamVariant variant, { + BeamSegment? segment, + bool wrapCorners = false, + double radius = 16, + BeamEdge edge = BeamEdge.bottom, + BeamColors colors = BeamColors.colorful, + BeamStyle style = const BeamStyle(), +}) => BeamConfig.resolve( + variant: variant, + palette: colors.resolve(), + brightness: ui.Brightness.dark, + style: style, + shape: BeamShape( + radius: BorderRadius.all(Radius.circular(radius)), + edge: edge, + segment: segment, + wrapCorners: wrapCorners, + ), +); + +/// A frame of the line variant's own tracks at [progress]. +/// +/// Only the planar branch reads `lineX`/`lineW`/`edge` off the phases; path +/// mode re-samples the same tracks per traveller. Sampling them here is what +/// puts a planar and a path-mode run at the same point of the same cycle — +/// the hand-built defaults would otherwise freeze the planar traveller. +BeamFramePhases _linePhasesAt(double progress) => BeamFramePhases( + fadeOpacity: 1, + hueDegrees: 0, + travelProgress: progress, + travellers: [progress], + lineX: sampleKeyframes(lineTravelX, progress), + lineW: sampleKeyframes(lineTravelW, progress), + edge: sampleKeyframes(lineEdgeFade, progress), +); + +Future<_Pixels> _cycleFrames(BeamConfig config) async { + final frames = <_Pixels>[]; + for (final progress in [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875]) { + frames.add( + await _paint( + config, + phases: BeamFramePhases( + fadeOpacity: 1, + hueDegrees: 0, + angleRadians: progress * 2 * math.pi, + travelProgress: progress, + travellers: [progress], + ), + ), + ); + } + return _Pixels.max(frames); +} + +Future<_Pixels> _paint( + BeamConfig config, { + double t = 1.3, + BeamFramePhases? phases, + bool behindAndAbove = false, +}) async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + final frame = phases ?? BeamPhaseResolver(config).sample(t, 1); + final strategy = strategyFor(config.variant); + if (behindAndAbove) strategy.paintBehind(canvas, _size, config, frame); + strategy.paintAbove(canvas, _size, config, frame); + final image = await recorder.endRecording().toImage(300, 300); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + return _Pixels(data!.buffer.asUint8List(), image.width, image.height); +} + +class _Pixels { + _Pixels(this.bytes, this.width, this.height); + + factory _Pixels.max(List<_Pixels> frames) { + final bytes = List.filled(frames.first.bytes.length, 0); + for (final frame in frames) { + for (var i = 0; i < bytes.length; i++) { + bytes[i] = math.max(bytes[i], frame.bytes[i]); + } + } + return _Pixels(bytes, frames.first.width, frames.first.height); + } + + final List bytes; + final int width; + final int height; + + double alphaAt(int x, int y) { + if (x < 0 || y < 0 || x >= width || y >= height) return 0; + return bytes[(y * width + x) * 4 + 3] / 255; + } + + double alphaNear(ui.Offset point) { + var best = 0.0; + for (var dy = -2; dy <= 2; dy++) { + for (var dx = -2; dx <= 2; dx++) { + best = math.max( + best, + alphaAt(point.dx.round() + dx, point.dy.round() + dy), + ); + } + } + return best; + } + + double totalAlpha(ui.Rect rect) { + var sum = 0.0; + for (var y = rect.top.round(); y < rect.bottom.round(); y++) { + for (var x = rect.left.round(); x < rect.right.round(); x++) { + sum += alphaAt(x, y); + } + } + return sum; + } + + /// The alpha-weighted centre of everything painted — where the traveller + /// sits, for a frame holding one beam. + ui.Offset centroid() { + var sumX = 0.0; + var sumY = 0.0; + var weight = 0.0; + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + final alpha = alphaAt(x, y); + sumX += x * alpha; + sumY += y * alpha; + weight += alpha; + } + } + return weight == 0 + ? ui.Offset.zero + : ui.Offset(sumX / weight, sumY / weight); + } +} diff --git a/test/painting/surface_features_test.dart b/test/painting/surface_features_test.dart new file mode 100644 index 0000000..9129d14 --- /dev/null +++ b/test/painting/surface_features_test.dart @@ -0,0 +1,669 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/painting.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/models/beam_colors.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/models/beam_options.dart'; +import 'package:flutter_border_beam/src/models/beam_shape.dart'; +import 'package:flutter_border_beam/src/models/beam_style.dart'; +import 'package:flutter_border_beam/src/models/beam_timing.dart'; +import 'package:flutter_border_beam/src/models/beam_variant.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_border_beam/src/painting/ring_geometry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Per-feature painting tests for the Phase 3 surface options: each one +/// asserts pixels appear where the feature promises them and stay away from +/// where it does not. +/// +/// The assertions are deliberately coarse — presence, absence, and ordering +/// of totals — so they pin behaviour without pinning the look, which the +/// goldens own. +void main() { + const beamSize = ui.Size(350, 140); + + group('direction', () { + test('reverse mirrors the rotate window about the beam angle', () async { + final config = _config(BeamVariant.rotate); + final forward = await _paint( + config, + phases: const BeamFramePhases(fadeOpacity: 1, hueDegrees: 0), + ); + final reverse = await _paint( + config, + phases: const BeamFramePhases( + fadeOpacity: 1, + hueDegrees: 0, + reversedNow: true, + ), + ); + final rect = ui.Offset.zero & beamSize; + final a = forward.ringProfile(rect); + final b = reverse.ringProfile(rect); + // Sample i sits at angle i·2π/n; its mirror about the beam angle (0) is + // sample n − i. + var mirrored = 0.0; + var same = 0.0; + for (var i = 1; i < a.length; i++) { + mirrored += (a[i] - b[a.length - i]).abs(); + same += (a[i] - b[i]).abs(); + } + expect( + mirrored, + lessThan(same * 0.5), + reason: 'the reversed window should be the forward one mirrored', + ); + }); + + test('reverse mirrors the line beam head across the edge', () async { + // Two thirds through the cycle, where the head is well off centre and + // a mirror is not the same picture. + const t = 2.0; + final forward = await _paint(_config(BeamVariant.line), t: t); + final reverse = await _paint( + _config( + BeamVariant.line, + timing: const BeamTiming(direction: BeamDirection.reverse), + ), + t: t, + ); + const band = ui.Rect.fromLTRB(0, 126, 350, 140); + final xf = forward.brightestColumn(band); + final xr = reverse.brightestColumn(band); + expect( + (xf - 175).abs(), + greaterThan(25), + reason: 'a head at mid-edge would mirror onto itself', + ); + expect(xr, closeTo(350 - xf, 12)); + }); + }); + + group('beamCount', () { + test('rotate paints two heads half a turn apart', () async { + final pixels = await _paint( + _config(BeamVariant.rotate, timing: const BeamTiming(beamCount: 2)), + phases: const BeamFramePhases(fadeOpacity: 1, hueDegrees: 0), + ); + final profile = pixels.ringProfile(ui.Offset.zero & beamSize); + final arcs = _litArcs(profile); + expect(arcs.length, 2, reason: 'two beams, two lit arcs'); + final gap = (arcs[1].centre - arcs[0].centre).abs() / profile.length; + expect(gap, closeTo(0.5, 0.08), reason: 'half a turn apart'); + }); + + test('one beam paints a single arc', () async { + final pixels = await _paint( + _config(BeamVariant.rotate), + phases: const BeamFramePhases(fadeOpacity: 1, hueDegrees: 0), + ); + expect(_litArcs(pixels.ringProfile(ui.Offset.zero & beamSize)).length, 1); + }); + + test('line paints a head per traveller', () async { + final pixels = await _paint( + _config(BeamVariant.line, timing: const BeamTiming(beamCount: 3)), + t: 1.3, + ); + final band = ui.Rect.fromLTRB(0, 120, 350, 140); + final columns = pixels.columnProfile(band); + expect( + _litArcs(columns, threshold: 0.35).length, + greaterThanOrEqualTo(2), + reason: 'three travellers, at least two inside the edge fade', + ); + }); + }); + + group('tailLength', () { + test('scales the lit arc about the head', () async { + Future litSamples(double tail) async { + final pixels = await _paint( + _config(BeamVariant.rotate, style: BeamStyle(tailLength: tail)), + phases: const BeamFramePhases(fadeOpacity: 1, hueDegrees: 0), + ); + final profile = pixels.ringProfile(ui.Offset.zero & beamSize); + final peak = profile.reduce(math.max); + return profile.where((v) => v > peak * 0.2).length; + } + + final short = await litSamples(0.5); + final normal = await litSamples(1); + final long = await litSamples(2); + expect(short, lessThan(normal)); + expect(long, greaterThan(normal)); + }); + }); + + group('glowSpread', () { + test('rotate reaches further in with a wider bloom', () async { + // The other two layers are switched off: the bloom is the only one + // glowSpread touches, and it is far the dimmest of the three. + Future bloomReach(double spread) async { + final pixels = await _paint( + _config( + BeamVariant.rotate, + style: BeamStyle( + glowSpread: spread, + innerOpacityFactor: 0, + strokeOpacityFactor: 0, + ), + ), + phases: const BeamFramePhases(fadeOpacity: 1, hueDegrees: 0), + ); + return pixels.inwardReach(); + } + + final normal = await bloomReach(1); + expect(normal, greaterThan(0)); + expect(await bloomReach(2), greaterThan(normal)); + }); + + test('pulse-outside pushes its halo further out', () async { + Future reach(double spread) async { + final pixels = await _paint( + _config( + BeamVariant.pulseOutside, + style: BeamStyle(glowSpread: spread), + ), + behind: true, + canvas: const ui.Size(550, 340), + origin: const ui.Offset(100, 100), + ); + return pixels.farthestLit( + const ui.Rect.fromLTWH(100, 100, 350, 140), + axis: Axis.vertical, + ); + } + + expect(await reach(2), greaterThan(await reach(1) + 2)); + }); + }); + + group('comet', () { + test('paints a halo outside the shape, unlike the plain bloom', () async { + Future outside(bool comet) async { + final pixels = await _paint( + _config(BeamVariant.rotate, style: BeamStyle(comet: comet)), + phases: const BeamFramePhases(fadeOpacity: 1, hueDegrees: 0), + canvas: const ui.Size(390, 180), + origin: const ui.Offset(20, 20), + ); + // A strip just outside the top edge, where the beam's head sits. + return pixels.totalAlpha(const ui.Rect.fromLTRB(20, 10, 390, 19)); + } + + expect(await outside(false), lessThan(0.5)); + expect(await outside(true), greaterThan(2)); + }); + }); + + group('sparkle', () { + test('adds pixels around the head and nowhere else', () async { + const phases = BeamFramePhases(fadeOpacity: 1, hueDegrees: 0); + final plain = await _paint(_config(BeamVariant.rotate), phases: phases); + final sparkly = await _paint( + _config(BeamVariant.rotate, style: const BeamStyle(sparkle: 1)), + phases: phases, + ); + // The head of the default window sits at 0.8 of a turn from the beam + // angle — the upper left of the box at angle 0. + const nearHead = ui.Rect.fromLTRB(0, 0, 120, 60); + const farSide = ui.Rect.fromLTRB(200, 100, 350, 140); + expect( + sparkly.totalAlpha(nearHead), + greaterThan(plain.totalAlpha(nearHead)), + ); + expect( + sparkly.totalAlpha(farSide), + closeTo(plain.totalAlpha(farSide), 0.5), + ); + }); + + test('is deterministic for a given frame', () async { + const phases = BeamFramePhases(fadeOpacity: 1, hueDegrees: 0); + final config = _config( + BeamVariant.rotate, + style: const BeamStyle(sparkle: 1), + ); + final first = await _paint(config, phases: phases); + final second = await _paint(config, phases: phases); + expect(first.bytes, second.bytes); + }); + }); + + group('segments', () { + test('cuts the ring into gaps the solid ring does not have', () async { + const phases = BeamFramePhases(fadeOpacity: 1, hueDegrees: 0); + final solid = await _paint(_config(BeamVariant.rotate), phases: phases); + final dashed = await _paint( + _config(BeamVariant.rotate, style: const BeamStyle(segments: 8)), + phases: phases, + ); + final rect = ui.Offset.zero & beamSize; + // Sampled right on the ring, where the stroke the mask cuts lives. + final solidProfile = solid.ringProfile(rect, depth: 1); + final dashedProfile = dashed.ringProfile(rect, depth: 1); + final gaps = _runs([ + for (var i = 0; i < solidProfile.length; i++) + solidProfile[i] > 0.05 && dashedProfile[i] < solidProfile[i] * 0.7, + ]); + expect( + gaps, + greaterThanOrEqualTo(3), + reason: 'eight dashes leave several dark gaps across the lit arc', + ); + expect( + dashedProfile.reduce((a, b) => a + b), + lessThan(solidProfile.reduce((a, b) => a + b)), + ); + }); + + test('a solid ring is left alone', () async { + const phases = BeamFramePhases(fadeOpacity: 1, hueDegrees: 0); + final solid = await _paint(_config(BeamVariant.rotate), phases: phases); + final one = await _paint( + _config(BeamVariant.rotate, style: const BeamStyle(segments: 1)), + phases: phases, + ); + expect( + one.bytes, + solid.bytes, + reason: 'a single segment is no ring at all, so it is ignored', + ); + }); + }); + + group('edge', () { + test('top paints the top band and leaves the bottom empty', () async { + final pixels = await _paint( + _config(BeamVariant.line, shape: const BeamShape(edge: BeamEdge.top)), + t: 1.3, + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 0, 350, 20)), + greaterThan(5), + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 120, 350, 140)), + lessThan(0.5), + ); + }); + + test('left paints the left band and leaves the right empty', () async { + final pixels = await _paint( + _config(BeamVariant.line, shape: const BeamShape(edge: BeamEdge.left)), + t: 1.3, + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 0, 20, 140)), + greaterThan(5), + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(330, 0, 350, 140)), + lessThan(0.5), + ); + }); + + test('right paints the right band', () async { + final pixels = await _paint( + _config(BeamVariant.line, shape: const BeamShape(edge: BeamEdge.right)), + t: 1.3, + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(330, 0, 350, 140)), + greaterThan(5), + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 0, 20, 140)), + lessThan(0.5), + ); + }); + + test('bottom is unchanged by the transform', () async { + final pixels = await _paint( + _config( + BeamVariant.line, + shape: const BeamShape(edge: BeamEdge.bottom), + ), + t: 1.3, + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 120, 350, 140)), + greaterThan(5), + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 0, 350, 20)), + lessThan(0.5), + ); + }); + }); + + group('ringOffset', () { + test('a positive offset paints outside the child bounds', () async { + final pixels = await _paint( + _config(BeamVariant.rotate, shape: const BeamShape(ringOffset: 10)), + phases: const BeamFramePhases(fadeOpacity: 1, hueDegrees: 0), + canvas: const ui.Size(390, 180), + origin: const ui.Offset(20, 20), + ); + // The ring now sits 10px out; the band just outside the child's bounds + // carries it. + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(20, 12, 370, 19)), + greaterThan(2), + ); + }); + + test('a negative offset pulls the ring inside them', () async { + final pixels = await _paint( + _config(BeamVariant.rotate, shape: const BeamShape(ringOffset: -12)), + phases: const BeamFramePhases(fadeOpacity: 1, hueDegrees: 0), + ); + // Nothing is left in the outermost band the ring used to occupy. + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 0, 350, 3)), + lessThan(0.5), + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 12, 350, 20)), + greaterThan(1), + ); + }); + }); + + group('contour', () { + test('a star contour paints along the star, not the rect', () async { + final pixels = await _paint( + _config(BeamVariant.rotate, shape: BeamShape(contour: _star)), + phases: const BeamFramePhases(fadeOpacity: 1, hueDegrees: 0), + ); + // The beam rides the star's arms; the box's own corners and the gaps + // between the arms — where a rounded rect would have painted — stay + // empty. + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(124, 112, 148, 134)), + greaterThan(1), + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(0, 0, 24, 24)), + lessThan(0.05), + ); + expect( + pixels.totalAlpha(const ui.Rect.fromLTRB(40, 90, 80, 130)), + lessThan(0.05), + ); + }); + + test('the ring keeps an even width around a lobed contour', () { + final path = _star.build(const ui.Rect.fromLTWH(0, 0, 200, 200)); + final widths = _insetWidths(path, 6); + final even = widths.where((w) => (w - 6).abs() < 1.5).length; + expect( + even, + greaterThan(widths.length * 3 ~/ 4), + reason: + 'a true normal offset holds the width everywhere but the folded ' + 'concave vertices, unlike a scale about the centre', + ); + }); + }); +} + +// ─── Fixtures ───────────────────────────────────────────────────────────── + +BeamConfig _config( + BeamVariant variant, { + BeamStyle style = const BeamStyle(), + BeamShape shape = const BeamShape(), + BeamTiming timing = const BeamTiming(), + ui.Brightness brightness = ui.Brightness.dark, +}) => BeamConfig.resolve( + variant: variant, + palette: BeamColors.colorful.resolve(), + brightness: brightness, + style: style, + shape: shape, + timing: timing, +); + +final _star = BeamPathContour((rect) { + final path = Path(); + final centre = rect.center; + final outer = rect.shortestSide / 2; + final inner = outer * 0.5; + for (var i = 0; i < 10; i++) { + final r = i.isEven ? outer : inner; + final a = -math.pi / 2 + i * math.pi / 5; + final p = centre + ui.Offset(math.cos(a) * r, math.sin(a) * r); + if (i == 0) { + path.moveTo(p.dx, p.dy); + } else { + path.lineTo(p.dx, p.dy); + } + } + return path..close(); +}, key: 'test-star'); + +/// Renders one frame of [config] and returns its pixels. +/// +/// [canvas]/[origin] make room for anything painted outside the beam's own +/// bounds — a comet halo or a pushed-out ring — which the widget's +/// `CustomPaint` does not clip either. +Future<_Pixels> _paint( + BeamConfig config, { + double t = 0.9, + BeamFramePhases? phases, + ui.Size size = const ui.Size(350, 140), + ui.Size? canvas, + ui.Offset origin = ui.Offset.zero, + bool behind = false, +}) async { + final target = canvas ?? size; + final recorder = ui.PictureRecorder(); + final c = ui.Canvas(recorder); + c.translate(origin.dx, origin.dy); + final frame = phases ?? BeamPhaseResolver(config).sample(t, 1); + final strategy = strategyFor(config.variant); + if (behind) { + strategy.paintBehind(c, size, config, frame); + } else { + strategy.paintAbove(c, size, config, frame); + } + final image = await recorder.endRecording().toImage( + target.width.toInt(), + target.height.toInt(), + ); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + return _Pixels(bytes!.buffer.asUint8List(), image.width, image.height); +} + +/// The alpha channel of a rendered frame, with the sampling helpers the +/// feature assertions are phrased in. +class _Pixels { + _Pixels(this.bytes, this.width, this.height); + + final List bytes; + final int width; + final int height; + + double alphaAt(double x, double y) { + final px = x.round(); + final py = y.round(); + if (px < 0 || py < 0 || px >= width || py >= height) return 0; + return bytes[(py * width + px) * 4 + 3] / 255; + } + + /// Total alpha inside [rect], in whole-pixel units. + double totalAlpha(ui.Rect rect) { + var sum = 0.0; + for (var y = rect.top.round(); y < rect.bottom.round(); y++) { + for (var x = rect.left.round(); x < rect.right.round(); x++) { + sum += alphaAt(x.toDouble(), y.toDouble()); + } + } + return sum; + } + + /// Alpha sampled just inside the border of [rect], once per angle around + /// it — the ring as the rotating beam sees it. + /// + /// [depth] is how far in the sample looks: 1 reads the stroke alone, while + /// a deeper probe also reaches the rounded corners, where the contour sits + /// several px inside the box. + List ringProfile(ui.Rect rect, {int samples = 144, int depth = 10}) => + [ + for (var i = 0; i < samples; i++) + _ringSample(rect, i * 2 * math.pi / samples, depth), + ]; + + double _ringSample(ui.Rect rect, double angle, int depth) { + final dx = math.sin(angle); + final dy = -math.cos(angle); + final tx = dx.abs() < 1e-6 ? double.infinity : (rect.width / 2) / dx.abs(); + final ty = dy.abs() < 1e-6 ? double.infinity : (rect.height / 2) / dy.abs(); + final t = math.min(tx, ty); + if (!t.isFinite) return 0; + final edge = rect.center + ui.Offset(dx * t, dy * t); + var best = 0.0; + for (var inward = 1; inward <= depth; inward++) { + final p = edge - ui.Offset(dx, dy) * inward.toDouble(); + best = math.max(best, alphaAt(p.dx, p.dy)); + } + return best; + } + + /// Column-by-column alpha totals inside [rect], normalised to 0–1. + List columnProfile(ui.Rect rect) { + final columns = []; + for (var x = rect.left.round(); x < rect.right.round(); x++) { + var sum = 0.0; + for (var y = rect.top.round(); y < rect.bottom.round(); y++) { + sum += alphaAt(x.toDouble(), y.toDouble()); + } + columns.add(sum); + } + final peak = columns.fold(0.0, math.max); + return peak <= 0 ? columns : [for (final v in columns) v / peak]; + } + + /// The x of the brightest column inside [rect]. + double brightestColumn(ui.Rect rect) { + final columns = columnProfile(rect); + var best = 0; + for (var i = 1; i < columns.length; i++) { + if (columns[i] > columns[best]) best = i; + } + return rect.left + best; + } + + /// How far in from the nearest image edge the farthest lit pixel sits. + double inwardReach() { + var reach = 0.0; + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + if (alphaAt(x.toDouble(), y.toDouble()) <= 0) continue; + final d = math + .min(math.min(x, width - 1 - x), math.min(y, height - 1 - y)) + .toDouble(); + reach = math.max(reach, d); + } + } + return reach; + } + + /// How far past [rect] the farthest lit pixel sits on the given axis. + double farthestLit(ui.Rect rect, {required Axis axis}) { + var reach = 0.0; + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + if (alphaAt(x.toDouble(), y.toDouble()) <= 0.02) continue; + final d = axis == Axis.vertical + ? math.max(rect.top - y, y - rect.bottom) + : math.max(rect.left - x, x - rect.right); + reach = math.max(reach, d); + } + } + return reach; + } +} + +/// How many runs of `true` [flags] holds. +int _runs(List flags) { + var n = 0; + for (var i = 0; i < flags.length; i++) { + if (flags[i] && (i == 0 || !flags[i - 1])) n++; + } + return n; +} + +/// The runs of a profile that are lit above [threshold] of its peak, as +/// (centre index, length) pairs — the arcs a beam leaves on the ring. +List<({double centre, int length})> _litArcs( + List profile, { + double threshold = 0.25, +}) { + // Smoothed first: a sample sitting right on the threshold would otherwise + // split one arc into several, and an arc count is the whole assertion. + final smooth = [ + for (var i = 0; i < profile.length; i++) + [ + for (var k = -2; k <= 2; k++) + profile[(i + k + profile.length) % profile.length], + ].reduce((a, b) => a + b) / + 5, + ]; + final peak = smooth.fold(0.0, math.max); + if (peak <= 0) return const []; + final lit = [for (final v in smooth) v > peak * threshold]; + final arcs = <({double centre, int length})>[]; + var start = -1; + for (var i = 0; i < lit.length; i++) { + if (lit[i] && start < 0) start = i; + if (!lit[i] && start >= 0) { + arcs.add((centre: (start + i - 1) / 2, length: i - start)); + start = -1; + } + } + if (start >= 0) { + arcs.add(( + centre: (start + lit.length - 1) / 2, + length: lit.length - start, + )); + // A run that wraps the end joins the one at the start. + if (arcs.length > 1 && lit.first) { + final first = arcs.removeAt(0); + final last = arcs.removeLast(); + arcs.insert(0, (centre: last.centre, length: first.length + last.length)); + } + } + return arcs; +} + +/// Distances from a sample of points on [path] to the nearest point of the +/// path inset by [inset] — the realised border width around a contour. +List _insetWidths(Path path, double inset) { + final inner = BeamRingGeometry.insetPath(path, inset); + final innerPoints = []; + for (final metric in inner.computeMetrics()) { + for (var i = 0; i < 200; i++) { + final tangent = metric.getTangentForOffset(metric.length * i / 200); + if (tangent != null) innerPoints.add(tangent.position); + } + } + final widths = []; + for (final metric in path.computeMetrics()) { + for (var i = 0; i < 40; i++) { + final tangent = metric.getTangentForOffset(metric.length * i / 40); + if (tangent == null) continue; + var nearest = double.infinity; + for (final p in innerPoints) { + nearest = math.min(nearest, (p - tangent.position).distance); + } + widths.add(nearest); + } + } + return widths; +} diff --git a/test/widget/beam_motion_test.dart b/test/widget/beam_motion_test.dart new file mode 100644 index 0000000..d30ee27 --- /dev/null +++ b/test/widget/beam_motion_test.dart @@ -0,0 +1,456 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_clock.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child, {bool disableAnimations = false}) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + builder: (context, app) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: disableAnimations), + child: app!, + ), + home: Scaffold( + body: Center(child: SizedBox(width: 350, height: 140, child: child)), + ), +); + +Iterable _painters(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType(); + +BeamPainter _painter(WidgetTester tester) => _painters(tester).first; + +BeamClock _clock(WidgetTester tester) => _painter(tester).clock; + +/// Playback beyond plain on/off: what reduced motion does to the four +/// behaviors, when a repeat budget ends a run, and the one-shot brightness +/// envelopes. +void main() { + group('reducedMotion', () { + testWidgets('animate ignores the platform request', (tester) async { + await tester.pumpWidget( + _host( + disableAnimations: true, + const BorderBeam.rotate( + playback: BeamPlayback(reducedMotion: BeamReducedMotion.animate), + child: SizedBox.expand(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + final clock = _clock(tester); + expect(clock.isRunning, isTrue); + expect(clock.elapsedSeconds, closeTo(0.5, 1e-9)); + expect(_painter(tester).staticMode, isFalse); + expect(tester.binding.hasScheduledFrame, isTrue); + }); + + testWidgets('staticFrame freezes on one frame', (tester) async { + await tester.pumpWidget( + _host( + disableAnimations: true, + const BorderBeam.rotate( + playback: BeamPlayback( + reducedMotion: BeamReducedMotion.staticFrame, + ), + child: SizedBox.expand(), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + expect(tester.binding.hasScheduledFrame, isFalse); + final painter = _painter(tester); + expect(painter.staticMode, isTrue); + expect(painter.clock.isRunning, isFalse); + }); + + testWidgets('staticFrame is the default', (tester) async { + await tester.pumpWidget( + _host( + disableAnimations: true, + const BorderBeam.rotate(child: SizedBox.expand()), + ), + ); + await tester.pump(const Duration(seconds: 1)); + expect(_painter(tester).staticMode, isTrue); + }); + + testWidgets('hide paints nothing and never ticks', (tester) async { + await tester.pumpWidget( + _host( + disableAnimations: true, + const BorderBeam.rotate( + playback: BeamPlayback(reducedMotion: BeamReducedMotion.hide), + child: SizedBox.expand(), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + expect(_painters(tester), isEmpty, reason: 'no painter is mounted'); + expect(tester.binding.transientCallbackCount, 0); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('hide paints the beam again once the request lifts', ( + tester, + ) async { + Widget build(bool reduced) => _host( + disableAnimations: reduced, + const BorderBeam.rotate( + playback: BeamPlayback(reducedMotion: BeamReducedMotion.hide), + child: SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(true)); + await tester.pump(const Duration(seconds: 1)); + expect(_painters(tester), isEmpty); + + await tester.pumpWidget(build(false)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(_painters(tester), isNotEmpty); + expect(_clock(tester).isRunning, isTrue); + }); + + testWidgets('slow runs the clock at a quarter rate', (tester) async { + await tester.pumpWidget( + _host( + disableAnimations: true, + const BorderBeam.rotate( + playback: BeamPlayback(reducedMotion: BeamReducedMotion.slow), + child: SizedBox.expand(), + ), + ), + ); + final clock = _clock(tester); + expect(clock.speed, 0.25); + expect(_painter(tester).staticMode, isFalse); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(clock.elapsedSeconds, closeTo(0.25, 1e-9)); + }); + + testWidgets('slow scales the configured speed rather than replacing it', ( + tester, + ) async { + await tester.pumpWidget( + _host( + disableAnimations: true, + const BorderBeam.rotate( + timing: BeamTiming(speed: 2), + playback: BeamPlayback(reducedMotion: BeamReducedMotion.slow), + child: SizedBox.expand(), + ), + ), + ); + expect(_clock(tester).speed, 0.5); + }); + + testWidgets('slow restores the rate when the request lifts', ( + tester, + ) async { + Widget build(bool reduced) => _host( + disableAnimations: reduced, + const BorderBeam.rotate( + timing: BeamTiming(speed: 2), + playback: BeamPlayback(reducedMotion: BeamReducedMotion.slow), + child: SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(true)); + final clock = _clock(tester); + expect(clock.speed, 0.5); + + await tester.pumpWidget(build(false)); + expect(clock.speed, 2); + + await tester.pumpWidget(build(true)); + expect(clock.speed, 0.5); + }); + }); + + group('repeat', () { + testWidgets('once fades out after a single cycle', (tester) async { + var deactivated = 0; + await tester.pumpWidget( + _host( + BorderBeam.rotate( + playback: BeamPlayback(repeat: const BeamRepeat.once()), + onDeactivate: () => deactivated++, + child: const SizedBox.expand(), + ), + ), + ); + await tester.pump(); + // The rotate cycle is 1.96s; nothing has expired before it. + await tester.pump(const Duration(milliseconds: 1900)); + expect(_clock(tester).stage, isNot(BeamFadeStage.fadingOut)); + expect(deactivated, 0); + + await tester.pump(const Duration(milliseconds: 100)); + expect( + _clock(tester).stage, + BeamFadeStage.fadingOut, + reason: 'the last cycle ends on a fade, not a cut', + ); + expect(deactivated, 0, reason: 'the fade has not finished yet'); + + await tester.pump(const Duration(milliseconds: 600)); + expect(deactivated, 1); + expect(_clock(tester).isVisible, isFalse); + }); + + testWidgets('count(3) runs three cycles', (tester) async { + var deactivated = 0; + await tester.pumpWidget( + _host( + BorderBeam.rotate( + playback: BeamPlayback(repeat: const BeamRepeat.count(3)), + onDeactivate: () => deactivated++, + child: const SizedBox.expand(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 5800)); + expect(_clock(tester).stage, isNot(BeamFadeStage.fadingOut)); + + await tester.pump(const Duration(milliseconds: 200)); + expect(_clock(tester).stage, BeamFadeStage.fadingOut); + await tester.pump(const Duration(milliseconds: 600)); + expect(deactivated, 1); + }); + + testWidgets('the gap counts toward the budget', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + timing: BeamTiming(cycleGap: Duration(seconds: 1)), + playback: BeamPlayback(repeat: BeamRepeat.once()), + child: SizedBox.expand(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2500)); + expect( + _clock(tester).stage, + isNot(BeamFadeStage.fadingOut), + reason: 'the rest between sweeps is part of the cycle', + ); + await tester.pump(const Duration(milliseconds: 600)); + expect(_clock(tester).stage, BeamFadeStage.fadingOut); + }); + + testWidgets('forever never stops on its own', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + playback: BeamPlayback(repeat: BeamRepeat.forever()), + child: SizedBox.expand(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 12)); + expect(_clock(tester).isVisible, isTrue); + expect(_clock(tester).stage, isNot(BeamFadeStage.fadingOut)); + }); + + testWidgets('a restart runs the budget again', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _host( + BorderBeam.rotate( + controller: controller, + playback: const BeamPlayback(repeat: BeamRepeat.once()), + child: const SizedBox.expand(), + ), + ), + ); + controller.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2000)); + expect(_clock(tester).stage, BeamFadeStage.fadingOut); + await tester.pump(const Duration(milliseconds: 600)); + expect(controller.isActive, isFalse); + + controller.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(controller.isActive, isTrue); + expect(_clock(tester).stage, isNot(BeamFadeStage.fadingOut)); + }); + }); + + group('pulse and flash', () { + testWidgets('pulse rises and settles back to rest', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _host( + BorderBeam.rotate( + controller: controller, + child: const SizedBox.expand(), + ), + ), + ); + controller.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final clock = _clock(tester); + expect(clock.boost, 1); + + controller.pulse(); + expect(clock.isBoosting, isTrue); + await tester.pump(const Duration(milliseconds: 240)); + expect(clock.boost, closeTo(BeamClock.pulsePeak, 1e-6)); + + await tester.pump(const Duration(milliseconds: 180)); + expect(clock.boost, greaterThan(1)); + expect(clock.boost, lessThan(BeamClock.pulsePeak)); + + await tester.pump(const Duration(milliseconds: 200)); + expect(clock.boost, 1); + expect(clock.isBoosting, isFalse); + }); + + testWidgets('flash holds at the peak before decaying', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _host( + BorderBeam.rotate( + controller: controller, + child: const SizedBox.expand(), + ), + ), + ); + controller.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final clock = _clock(tester); + + controller.flash(); + expect(clock.boost, closeTo(BeamClock.flashPeak, 1e-9)); + await tester.pump(const Duration(milliseconds: 100)); + expect( + clock.boost, + closeTo(BeamClock.flashPeak, 1e-9), + reason: 'the hold keeps it at full for 120ms', + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(clock.boost, lessThan(BeamClock.flashPeak)); + await tester.pump(const Duration(milliseconds: 250)); + expect(clock.boost, 1); + }); + + testWidgets('cycle retiming preserves a pulse envelope in progress', ( + tester, + ) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + Widget build(Duration cycle) => _host( + BorderBeam.rotate( + controller: controller, + timing: BeamTiming(cycle: cycle), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(const Duration(seconds: 2))); + controller.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final clock = _clock(tester); + controller.pulse(); + await tester.pump(const Duration(milliseconds: 100)); + final before = clock.boost; + + await tester.pumpWidget(build(const Duration(seconds: 4))); + expect(clock.boost, closeTo(before, 1e-9)); + await tester.pump(const Duration(milliseconds: 140)); + expect(clock.boost, closeTo(BeamClock.pulsePeak, 1e-6)); + }); + + testWidgets('both are no-ops while the beam is hidden', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _host( + BorderBeam.rotate( + controller: controller, + child: const SizedBox.expand(), + ), + ), + ); + final clock = _clock(tester); + expect(clock.isVisible, isFalse); + + controller.pulse(); + expect(clock.isBoosting, isFalse); + expect(clock.boost, 1); + controller.flash(); + expect(clock.isBoosting, isFalse); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('a boost is dropped when the beam restarts', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _host( + BorderBeam.rotate( + controller: controller, + child: const SizedBox.expand(), + ), + ), + ); + controller.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final clock = _clock(tester); + controller.flash(); + expect(clock.isBoosting, isTrue); + + controller.stop(); + await tester.pump(const Duration(milliseconds: 600)); + controller.start(); + await tester.pump(); + expect(clock.isBoosting, isFalse); + expect(clock.boost, 1); + }); + + testWidgets('a paused beam takes no boost', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _host( + BorderBeam.rotate( + controller: controller, + child: const SizedBox.expand(), + ), + ), + ); + controller.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + controller.pause(); + await tester.pump(); + + controller.pulse(); + expect(_clock(tester).isBoosting, isFalse); + }); + }); +} diff --git a/test/widget/beam_progress_test.dart b/test/widget/beam_progress_test.dart new file mode 100644 index 0000000..946c7e6 --- /dev/null +++ b/test/widget/beam_progress_test.dart @@ -0,0 +1,443 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_clock.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Scaffold( + body: Center(child: SizedBox(width: 350, height: 140, child: child)), + ), +); + +BeamPainter _painter(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .first; + +BeamClock _clock(WidgetTester tester) => _painter(tester).clock; + +/// Whether the painter leaves a non-transparent pixel in a 350x140 box. +/// +/// Rasterizing is real async work, so it runs outside the fake clock. +Future _paintsPixels(WidgetTester tester, BeamPainter painter) async { + const size = ui.Size(350, 140); + final painted = await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + painter.paint(ui.Canvas(recorder), size); + final picture = recorder.endRecording(); + final image = await picture.toImage( + size.width.toInt(), + size.height.toInt(), + ); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + picture.dispose(); + image.dispose(); + for (var i = 3; i < bytes!.lengthInBytes; i += 4) { + if (bytes.getUint8(i) != 0) return true; + } + return false; + }); + return painted!; +} + +/// The three ways something other than the clock moves the beam — a driven +/// [BorderBeam.progress], a [BorderBeam.follow] pointer, and the live +/// strength/speed listenables — plus the childless overlay constructor. +void main() { + group('progress', () { + testWidgets('drives the sweep from the value', (tester) async { + await tester.pumpWidget( + _host(const BorderBeam.rotate(progress: 0.25, child: SizedBox())), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final painter = _painter(tester); + expect(painter.progress?.value, 0.25); + expect( + painter.resolver + .sample(painter.clock.elapsedSeconds, 1, progress: 0.25) + .angleRadians, + closeTo(math.pi / 2, 1e-9), + ); + }); + + testWidgets('changing it does not re-resolve the config', (tester) async { + Widget build(double progress) => + _host(BorderBeam.rotate(progress: progress, child: const SizedBox())); + + await tester.pumpWidget(build(0.1)); + final before = _painter(tester); + await tester.pumpWidget(build(0.9)); + final after = _painter(tester); + expect(identical(before.config, after.config), isTrue); + expect(identical(before.resolver, after.resolver), isTrue); + expect(after.progress?.value, 0.9); + }); + + testWidgets('clamps out-of-range values', (tester) async { + await tester.pumpWidget( + _host(const BorderBeam.rotate(progress: 1.7, child: SizedBox())), + ); + expect(_painter(tester).progress?.value, 1); + }); + + testWidgets('the clock still runs underneath', (tester) async { + await tester.pumpWidget( + _host(const BorderBeam.line(progress: 0.5, child: SizedBox())), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final painter = _painter(tester); + expect(painter.clock.isRunning, isTrue); + final early = painter.resolver.sample(0.7, 1, progress: 0.5); + final late = painter.resolver.sample(3.7, 1, progress: 0.5); + expect(early.lineX, closeTo(late.lineX, 1e-9)); + expect(early.hueDegrees, isNot(closeTo(late.hueDegrees, 1e-6))); + }); + + testWidgets('null hands the travel back to the clock', (tester) async { + Widget build(double? progress) => + _host(BorderBeam.rotate(progress: progress, child: const SizedBox())); + + await tester.pumpWidget(build(0.25)); + await tester.pumpWidget(build(null)); + expect(_painter(tester).progress?.value, isNull); + }); + }); + + group('follow', () { + testWidgets('eases the sweep toward the pointer', (tester) async { + Widget build(Offset? follow) => + _host(BorderBeam.rotate(follow: follow, child: const SizedBox())); + + await tester.pumpWidget(build(null)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + + // The right edge of the box: a quarter of the way round from 12 + // o'clock. + await tester.pumpWidget(build(const Offset(1, 0.5))); + final painter = _painter(tester); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + final part = painter.progress?.value; + expect(part, isNotNull); + expect( + part, + isNot(closeTo(0.25, 0.005)), + reason: 'the beam eases rather than snapping', + ); + + for (var i = 0; i < 30; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(painter.progress?.value, closeTo(0.25, 0.01)); + }); + + testWidgets('takes the short way round', (tester) async { + Widget build(Offset? follow) => + _host(BorderBeam.rotate(follow: follow, child: const SizedBox())); + + await tester.pumpWidget(build(null)); + await tester.pump(); + // 100ms into the 1.96s cycle: the beam is just past 12 o'clock. + await tester.pump(const Duration(milliseconds: 100)); + // The left edge is progress 0.75 — a short hop backwards through 0, + // not three quarters of a lap forwards. + await tester.pumpWidget(build(const Offset(0, 0.5))); + final painter = _painter(tester); + await tester.pump(const Duration(milliseconds: 16)); + var previous = painter.progress!.value!; + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 16)); + final now = painter.progress!.value!; + final step = ((now - previous) + 0.5) % 1.0 - 0.5; + expect( + step, + lessThanOrEqualTo(1e-9), + reason: 'every step runs backwards toward the target', + ); + previous = now; + } + expect(previous, closeTo(0.75, 0.01)); + }); + + testWidgets('releasing resumes from where the pointer left it', ( + tester, + ) async { + Widget build(Offset? follow) => + _host(BorderBeam.rotate(follow: follow, child: const SizedBox())); + + await tester.pumpWidget(build(null)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pumpWidget(build(const Offset(1, 0.5))); + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + final painter = _painter(tester); + final held = painter.progress!.value!; + + await tester.pumpWidget(build(null)); + expect(painter.progress?.value, isNull); + final resumed = painter.resolver + .sample(painter.clock.elapsedSeconds, 1) + .travelProgress; + expect( + resumed, + closeTo(held, 1e-6), + reason: 'the timed sweep picks up where the follow ended', + ); + expect(painter.resolver.travelTimeOffset, isNot(0)); + + // And it keeps travelling from there. + await tester.pump(const Duration(milliseconds: 100)); + final later = painter.resolver + .sample(painter.clock.elapsedSeconds, 1) + .travelProgress; + expect(later, greaterThan(resumed)); + }); + + testWidgets('the line variant follows the axis it travels', (tester) async { + Widget build(Offset? follow) => + _host(BorderBeam.line(follow: follow, child: const SizedBox())); + + await tester.pumpWidget(build(null)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pumpWidget(build(const Offset(0.8, 0.2))); + final painter = _painter(tester); + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(painter.progress?.value, closeTo(0.8, 0.01)); + }); + + testWidgets('the pulse variants have no travel to steer', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeam.pulseInside( + follow: Offset(1, 0.5), + child: SizedBox(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(_painter(tester).progress?.value, isNull); + }); + + testWidgets('an explicit progress wins over it', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + progress: 0.5, + follow: Offset(1, 0.5), + child: SizedBox(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(_painter(tester).progress?.value, 0.5); + }); + }); + + group('strengthListenable', () { + testWidgets('scales the painted layers without a rebuild', (tester) async { + final strength = ValueNotifier(1); + addTearDown(strength.dispose); + var builds = 0; + await tester.pumpWidget( + _host( + Builder( + builder: (context) { + builds++; + return BorderBeam.rotate( + strengthListenable: strength, + child: const SizedBox(), + ); + }, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final painter = _painter(tester); + expect(await _paintsPixels(tester, painter), isTrue); + expect(builds, 1); + + strength.value = 0; + await tester.pump(); + expect( + await _paintsPixels(tester, _painter(tester)), + isFalse, + reason: 'a strength of 0 suppresses every layer', + ); + expect(builds, 1, reason: 'the value never goes through a rebuild'); + expect(identical(_painter(tester).config, painter.config), isTrue); + }); + + testWidgets('reaches the painter as the listenable itself', (tester) async { + final strength = ValueNotifier(0.4); + addTearDown(strength.dispose); + await tester.pumpWidget( + _host( + BorderBeam.rotate( + strengthListenable: strength, + child: const SizedBox(), + ), + ), + ); + expect(identical(_painter(tester).strength, strength), isTrue); + }); + + testWidgets('still scales a debug-frozen frame', (tester) async { + final strength = ValueNotifier(0); + addTearDown(strength.dispose); + await tester.pumpWidget( + _host( + BorderBeam.rotate( + playback: const BeamPlayback( + debugFrozenAt: Duration(milliseconds: 1300), + ), + strengthListenable: strength, + child: const SizedBox(), + ), + ), + ); + await tester.pump(); + expect(await _paintsPixels(tester, _painter(tester)), isFalse); + + strength.value = 1; + await tester.pump(); + expect(await _paintsPixels(tester, _painter(tester)), isTrue); + }); + }); + + group('speedListenable', () { + testWidgets('drives the clock rate without a rebuild', (tester) async { + final speed = ValueNotifier(2); + addTearDown(speed.dispose); + var builds = 0; + await tester.pumpWidget( + _host( + Builder( + builder: (context) { + builds++; + return BorderBeam.rotate( + speedListenable: speed, + child: const SizedBox(), + ); + }, + ), + ), + ); + final clock = _clock(tester); + expect(clock.speed, 2); + + speed.value = 0.5; + await tester.pump(); + expect(clock.speed, 0.5); + expect(builds, 1); + }); + + testWidgets('wins over the timing speed', (tester) async { + final speed = ValueNotifier(3); + addTearDown(speed.dispose); + await tester.pumpWidget( + _host( + BorderBeam.rotate( + timing: const BeamTiming(speed: 0.5), + speedListenable: speed, + child: const SizedBox(), + ), + ), + ); + expect(_clock(tester).speed, 3); + }); + + testWidgets('a swap moves the listener across', (tester) async { + final first = ValueNotifier(2); + final second = ValueNotifier(4); + addTearDown(first.dispose); + addTearDown(second.dispose); + Widget build(ValueNotifier speed) => _host( + BorderBeam.rotate(speedListenable: speed, child: const SizedBox()), + ); + + await tester.pumpWidget(build(first)); + await tester.pumpWidget(build(second)); + expect(_clock(tester).speed, 4); + + first.value = 9; + await tester.pump(); + expect(_clock(tester).speed, 4, reason: 'the old listener is gone'); + second.value = 0.5; + await tester.pump(); + expect(_clock(tester).speed, 0.5); + }); + }); + + group('BorderBeam.overlay', () { + testWidgets('fills its parent in a Stack and paints', (tester) async { + await tester.pumpWidget( + _host( + const Stack( + children: [ + Center(child: Text('content')), + Positioned.fill(child: BorderBeam.overlay(borderRadius: 16)), + ], + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + expect(find.text('content'), findsOneWidget); + expect(tester.getSize(find.byType(BorderBeam)), const Size(350, 140)); + expect(await _paintsPixels(tester, _painter(tester)), isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('takes the same options as the generic constructor', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const Stack( + children: [ + Positioned.fill( + child: BorderBeam.overlay( + variant: BeamVariant.line, + colors: BeamColors.ocean, + progress: 0.3, + ), + ), + ], + ), + ), + ); + final painter = _painter(tester); + expect(painter.config.variant, BeamVariant.line); + expect(painter.progress?.value, 0.3); + }); + }); +} diff --git a/test/widget/beam_research_features_test.dart b/test/widget/beam_research_features_test.dart new file mode 100644 index 0000000..d03b5fa --- /dev/null +++ b/test/widget/beam_research_features_test.dart @@ -0,0 +1,665 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_clock.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The options ported from the reference implementations: the stock +/// pulse-outside look, the pulse-inside wash scale, the render-scale +/// magnification, and the three playback fields (offscreen pause, frozen +/// time, fade curve). +/// +/// The pixel assertions are deliberately coarse — presence, absence, and the +/// ordering of totals — so they pin behaviour without pinning the look, which +/// the goldens own. +void main() { + const beamSize = ui.Size(350, 140); + + // ─── BeamStyle.pulseOutsideStock ────────────────────────────────────────── + + group('BeamStyle.pulseOutsideStock', () { + test('rolls the demo tuning back through the hooks that carry it', () { + final config = _config( + BeamVariant.pulseOutside, + style: BeamStyle.pulseOutsideStock, + ); + // Stock tuning disables the demo-only behind-glow boost in the + // strategy, leaving the shared foreground/behind hook neutral. + expect(config.glowBoost, 1); + for (final factor in [ + config.strokeOpacityFactor, + config.innerOpacityFactor, + config.bloomOpacityFactor, + ]) { + expect(factor * 1.71, closeTo(1, 1e-12)); + } + expect(config.glowBrightness, 1.3, reason: 'the untuned preset'); + expect(config.glowSaturation, 1.2); + expect(config.pulseOutsideTuning, BeamPulseOutsideTuning.stock); + }); + + test('leaves the blur overrides free for the caller', () { + // The stock blurs differ by brightness (3/6 core, 22.5/15 bloom), which + // one const double cannot say — the tuning flag carries them, and the + // hooks stay null so a caller can still override either. + expect(BeamStyle.pulseOutsideStock.coreBlur, isNull); + expect(BeamStyle.pulseOutsideStock.bloomBlur, isNull); + }); + + for (final brightness in ui.Brightness.values) { + test('$brightness: paints a dimmer glow than the demo recipe', () async { + // The behind-child pass is the whole outward glow; give it room. + const canvas = ui.Size(750, 540); + const origin = ui.Offset(200, 200); + Future<_Pixels> render(BeamStyle style) => _paint( + _config( + BeamVariant.pulseOutside, + style: style, + brightness: brightness, + ), + size: beamSize, + canvas: canvas, + origin: origin, + behind: true, + ); + final demo = await render(const BeamStyle()); + final stock = await render(BeamStyle.pulseOutsideStock); + final all = ui.Offset.zero & canvas; + final stockAlpha = stock.totalAlpha(all); + expect(stockAlpha, greaterThan(0), reason: 'it still glows'); + expect( + stockAlpha, + lessThan(demo.totalAlpha(all)), + reason: 'the demo recipe multiplies every glow layer by 1.71', + ); + }); + + test('$brightness: the tuning flag alone moves the geometry', () async { + // Same opacities on both sides, so only the insets, the blurs, and + // the size unit can account for the difference. + Future<_Pixels> render(BeamPulseOutsideTuning tuning) => _paint( + _config( + BeamVariant.pulseOutside, + style: BeamStyle(pulseOutsideTuning: tuning), + brightness: brightness, + ), + size: beamSize, + canvas: const ui.Size(750, 540), + origin: const ui.Offset(200, 200), + behind: true, + ); + final demo = await render(BeamPulseOutsideTuning.demo); + final stock = await render(BeamPulseOutsideTuning.stock); + expect(stock.bytes, isNot(demo.bytes)); + }); + } + + test('is a value, and layering keeps the stock geometry', () { + expect(BeamStyle.pulseOutsideStock, BeamStyle.pulseOutsideStock); + final louder = BeamStyle.pulseOutsideStock.copyWith(glowBoost: 1.4); + expect(louder.glowBoost, 1.4); + expect(louder.pulseOutsideTuning, BeamPulseOutsideTuning.stock); + expect( + louder.strokeOpacityFactor, + BeamStyle.pulseOutsideStock.strokeOpacityFactor, + ); + }); + }); + + // ─── BeamStyle.innerSizeScale ───────────────────────────────────────────── + + group('innerSizeScale', () { + test('1 paints exactly what leaving it unset paints', () async { + final bare = await _paint(_config(BeamVariant.pulseInside)); + final one = await _paint( + _config( + BeamVariant.pulseInside, + style: const BeamStyle(innerSizeScale: 1), + ), + ); + expect(one.bytes, bare.bytes); + }); + + test('below 1 pulls the inner wash tighter to the border', () async { + final rect = ui.Offset.zero & beamSize; + // The inner wash is the only layer that moves, so read it alone. + Future innerAlpha(double scale) async { + final pixels = await _paint( + _config( + BeamVariant.pulseInside, + style: BeamStyle( + innerSizeScale: scale, + // Silence the ring and the bloom: their geometry is fixed. + strokeOpacityFactor: 0, + bloomOpacityFactor: 0, + ), + ), + ); + return pixels.totalAlpha(rect); + } + + final full = await innerAlpha(1); + final tight = await innerAlpha(0.6); + expect(tight, greaterThan(0), reason: 'the wash is still painted'); + expect(tight, lessThan(full * 0.9)); + }); + + test('only the pulse-inside variant reads it', () async { + for (final variant in BeamVariant.values) { + if (variant == BeamVariant.pulseInside) continue; + final bare = await _paint(_config(variant)); + final scaled = await _paint( + _config(variant, style: const BeamStyle(innerSizeScale: 0.4)), + ); + expect(scaled.bytes, bare.bytes, reason: '$variant'); + } + }); + }); + + // ─── BeamStyle.renderScale ──────────────────────────────────────────────── + + group('renderScale', () { + test('resolves clamped to 0.25-1', () { + double resolved(double value) => _config( + BeamVariant.rotate, + style: BeamStyle(renderScale: value), + ).renderScale; + expect(resolved(0.1), 0.25); + expect(resolved(4), 1); + expect(resolved(0.5), 0.5); + expect(_config(BeamVariant.rotate).renderScale, 1); + }); + + test('scaledBy moves the box-relative lengths and nothing else', () { + final base = _config( + BeamVariant.rotate, + shape: const BeamShape( + radius: BorderRadius.all(Radius.circular(24)), + borderWidth: 2, + ringOffset: 6, + ), + ); + final half = base.scaledBy(0.5); + expect(half.borderRadius, BorderRadius.circular(12)); + expect(half.borderWidth, 1); + expect(half.ringOffset, 3); + expect(half.renderScale, 1, reason: 'the copy has spent the scale'); + expect(half.palette, base.palette); + expect(half.cycleSeconds, base.cycleSeconds); + }); + + testWidgets('1 paints exactly what leaving it unset paints', ( + tester, + ) async { + final bare = await _paintThroughPainter( + tester, + _config(BeamVariant.rotate), + ); + final one = await _paintThroughPainter( + tester, + _config(BeamVariant.rotate, style: const BeamStyle(renderScale: 1)), + ); + expect(one.bytes, bare.bytes); + }); + + testWidgets('below 1 repaints the same box differently', (tester) async { + const large = ui.Size(700, 280); + final full = await _paintThroughPainter( + tester, + _config(BeamVariant.rotate), + size: large, + ); + final scaled = await _paintThroughPainter( + tester, + _config(BeamVariant.rotate, style: const BeamStyle(renderScale: 0.5)), + size: large, + ); + expect(scaled.bytes, isNot(full.bytes)); + expect(scaled.totalAlpha(ui.Offset.zero & large), greaterThan(0)); + }); + + testWidgets('the magnified beam still lands on the box bounds', ( + tester, + ) async { + // The transform is the part that can silently go wrong: scaling about + // anything but the origin slides the whole beam off its box. The + // pulse-inside wash hugs all four edges, so a shifted beam loses one. + const large = ui.Size(700, 280); + final pixels = await _paintThroughPainter( + tester, + _config( + BeamVariant.pulseInside, + style: const BeamStyle(renderScale: 0.5), + ), + size: large, + ); + const probe = 8.0; + final edges = { + 'top': ui.Rect.fromLTWH(0, 0, large.width, probe), + 'bottom': ui.Rect.fromLTWH(0, large.height - probe, large.width, probe), + 'left': ui.Rect.fromLTWH(0, 0, probe, large.height), + 'right': ui.Rect.fromLTWH(large.width - probe, 0, probe, large.height), + }; + for (final MapEntry(key: name, value: band) in edges.entries) { + expect(pixels.totalAlpha(band), greaterThan(0), reason: name); + } + }); + }); + + // ─── BeamPlayback.debugFrozenAt ─────────────────────────────────────────── + + group('debugFrozenAt', () { + testWidgets('never starts the clock', (tester) async { + await tester.pumpWidget( + _app( + const BorderBeam.rotate( + playback: BeamPlayback(debugFrozenAt: Duration(seconds: 2)), + child: SizedBox.expand(), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + final painter = _painterOf(tester); + expect(painter.frozenAt, const Duration(seconds: 2)); + expect(painter.clock.isRunning, isFalse); + expect(painter.clock.elapsedSeconds, 0); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('two frames a wall-clock apart paint the same pixels', ( + tester, + ) async { + await tester.pumpWidget( + _app( + const BorderBeam.rotate( + playback: BeamPlayback(debugFrozenAt: Duration(milliseconds: 1300)), + child: SizedBox.expand(), + ), + ), + ); + final first = await _recordIn(tester, _painterOf(tester), beamSize); + await tester.pump(const Duration(seconds: 4)); + await tester.pump(const Duration(seconds: 7)); + final later = await _recordIn(tester, _painterOf(tester), beamSize); + expect(later.bytes, first.bytes); + expect(first.totalAlpha(ui.Offset.zero & beamSize), greaterThan(0)); + }); + + testWidgets('paints the frame the timeline holds at that instant', ( + tester, + ) async { + const at = Duration(milliseconds: 1300); + final config = _config(BeamVariant.rotate); + late _Pixels expected; + late _Pixels actual; + await tester.runAsync(() async { + expected = await _paint(config, t: 1.3); + final clock = BeamClock(createTicker: Ticker.new); + final painter = BeamPainter( + clock: clock, + config: config, + resolver: BeamPhaseResolver(config), + strategy: strategyFor(config.variant), + behind: false, + staticMode: false, + frozenAt: at, + ); + actual = await _record(painter, beamSize); + clock.dispose(); + }); + expect(actual.bytes, expected.bytes); + }); + }); + + // ─── BeamPlayback.fadeCurve ─────────────────────────────────────────────── + + group('fadeCurve', () { + test('cssEase is the web ease', () { + expect(BeamPlayback.cssEase, const Cubic(0.25, 0.1, 0.25, 1)); + }); + + testWidgets('a curve replaces the spring on the fade-in', (tester) async { + final clock = BeamClock( + createTicker: tester.createTicker, + fadeCurve: BeamPlayback.cssEase, + )..activate(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect( + clock.fadeOpacity, + closeTo(BeamPlayback.cssEase.transform(0.5), 1e-9), + ); + clock.dispose(); + }); + + testWidgets('and on the fade-out', (tester) async { + final clock = BeamClock( + createTicker: tester.createTicker, + fadeCurve: BeamPlayback.cssEase, + )..activate(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + expect(clock.fadeOpacity, 1); + clock.deactivate(); + await tester.pump(const Duration(milliseconds: 250)); + expect( + clock.fadeOpacity, + closeTo(1 - BeamPlayback.cssEase.transform(0.5), 1e-9), + ); + clock.dispose(); + }); + + testWidgets('the default fade is the spring, not the ease', (tester) async { + final clock = BeamClock(createTicker: tester.createTicker)..activate(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + final spring = clock.fadeOpacity; + clock.dispose(); + expect(spring, isNot(closeTo(BeamPlayback.cssEase.transform(0.5), 1e-3))); + expect(spring, greaterThan(0)); + expect(spring, lessThanOrEqualTo(1)); + }); + + testWidgets('the widget hands its curve to the clock', (tester) async { + await tester.pumpWidget( + _app( + const BorderBeam.rotate( + playback: BeamPlayback(fadeCurve: BeamPlayback.cssEase), + child: SizedBox.expand(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect( + _painterOf(tester).clock.fadeOpacity, + closeTo(BeamPlayback.cssEase.transform(0.5), 1e-9), + ); + }); + }); + + // ─── BeamPlayback.pauseWhenOffscreen ────────────────────────────────────── + + group('pauseWhenOffscreen', () { + // cacheExtent keeps rows built well past the 256px margin, so there is + // something offscreen left to pause. The double form is the one every + // supported Flutter accepts (ScrollCacheExtent arrives after 3.35). + Widget list({bool? pause}) => _app( + ListView.builder( + // ignore: deprecated_member_use + cacheExtent: 4000, + itemCount: 12, + itemBuilder: (context, index) => SizedBox( + height: 200, + child: BorderBeam.rotate( + key: ValueKey(index), + playback: BeamPlayback(pauseWhenOffscreen: pause), + child: const SizedBox.expand(), + ), + ), + ), + size: const Size(400, 600), + ); + + testWidgets('offscreen rows stop ticking, and start again on return', ( + tester, + ) async { + await tester.pumpWidget(list()); + await tester.pump(); + expect(_clockAt(tester, 0).isRunning, isTrue, reason: 'in view'); + expect(_clockAt(tester, 1).isRunning, isTrue, reason: 'in view'); + expect( + _clockAt(tester, 2).isRunning, + isTrue, + reason: 'inside the 256px margin', + ); + expect(_clockAt(tester, 6).isRunning, isFalse, reason: 'far below'); + expect(_clockAt(tester, 11).isRunning, isFalse); + + // A paused beam keeps its play state and its timeline. + final resting = _clockAt(tester, 6); + expect(resting.isVisible, isTrue); + final held = resting.elapsedSeconds; + + // Not pumpAndSettle: a running beam schedules frames forever, so the + // tree never settles. Two frames are enough — one to land the scroll, + // one to run the post-frame visibility check. + await tester.drag(find.byType(ListView), const Offset(0, -1200)); + await tester.pump(); + await tester.pump(); + + expect(_clockAt(tester, 6).isRunning, isTrue, reason: 'scrolled in'); + expect( + _clockAt(tester, 6).elapsedSeconds, + greaterThanOrEqualTo(held), + reason: 'it resumes rather than restarting', + ); + expect(_clockAt(tester, 0).isRunning, isFalse, reason: 'now far above'); + }); + + testWidgets('false keeps every row running', (tester) async { + await tester.pumpWidget(list(pause: false)); + await tester.pump(); + for (final index in [0, 6, 11]) { + expect(_clockAt(tester, index).isRunning, isTrue, reason: '$index'); + } + }); + + testWidgets('offscreen time does not consume a duration budget', ( + tester, + ) async { + await tester.pumpWidget( + _app( + ListView.builder( + // ignore: deprecated_member_use + cacheExtent: 4000, + itemCount: 12, + itemBuilder: (context, index) => index == 6 + ? SizedBox( + key: const ValueKey(6), + height: 200, + child: const BorderBeam.rotate( + playback: BeamPlayback(duration: Duration(seconds: 1)), + child: SizedBox.expand(), + ), + ) + : const SizedBox(height: 200), + ), + size: const Size(400, 600), + ), + ); + await tester.pump(); + final clock = _clockAt(tester, 6); + expect(clock.isRunning, isFalse, reason: 'the beam starts offscreen'); + + await tester.pump(const Duration(seconds: 2)); + expect(clock.isVisible, isTrue); + expect(clock.stage, isNot(BeamFadeStage.fadingOut)); + + await tester.drag(find.byType(ListView), const Offset(0, -1200)); + await tester.pump(); + await tester.pump(); + expect(clock.isRunning, isTrue); + await tester.pump(const Duration(milliseconds: 1100)); + expect(clock.stage, BeamFadeStage.fadingOut); + }); + + testWidgets('a beam outside any scrollable is never paused', ( + tester, + ) async { + await tester.pumpWidget( + _app(const BorderBeam.rotate(child: SizedBox.expand())), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(_painterOf(tester).clock.isRunning, isTrue); + }); + + testWidgets('an offscreen beam disposes without leaking its ticker', ( + tester, + ) async { + await tester.pumpWidget(list()); + await tester.pump(); + expect(_clockAt(tester, 11).isRunning, isFalse); + await tester.pumpWidget(_app(const SizedBox.expand())); + await tester.pump(); + expect(tester.binding.transientCallbackCount, 0); + }); + }); +} + +// ─── Harness ──────────────────────────────────────────────────────────────── + +Widget _app(Widget child, {Size size = const Size(350, 140)}) => MaterialApp( + debugShowCheckedModeBanner: false, + home: Center( + child: SizedBox(width: size.width, height: size.height, child: child), + ), +); + +BeamPainter _painterOf(WidgetTester tester) => tester + .widgetList(find.byType(CustomPaint)) + .map((paint) => paint.foregroundPainter) + .whereType() + .first; + +/// The clock of the beam keyed [index]. +/// +/// `skipOffstage: false`: a row parked in the scrollable's cache extent is +/// laid out but never painted, and it is exactly the row this feature is +/// about — the default finders would skip it. +BeamClock _clockAt(WidgetTester tester, int index) => tester + .widgetList( + find.descendant( + of: find.byKey(ValueKey(index), skipOffstage: false), + matching: find.byType(CustomPaint, skipOffstage: false), + ), + ) + .map((paint) => paint.foregroundPainter) + .whereType() + .first + .clock; + +BeamConfig _config( + BeamVariant variant, { + BeamStyle style = const BeamStyle(), + BeamShape shape = const BeamShape(), + ui.Brightness brightness = ui.Brightness.dark, +}) => BeamConfig.resolve( + variant: variant, + palette: (style.colors ?? BeamColors.colorful).resolve(), + brightness: brightness, + style: style, + shape: shape, +); + +/// Renders one strategy frame of [config] and returns its pixels. +Future<_Pixels> _paint( + BeamConfig config, { + double t = 0.9, + ui.Size size = const ui.Size(350, 140), + ui.Size? canvas, + ui.Offset origin = ui.Offset.zero, + bool behind = false, +}) async { + final target = canvas ?? size; + final recorder = ui.PictureRecorder(); + final c = ui.Canvas(recorder); + c.translate(origin.dx, origin.dy); + final frame = BeamPhaseResolver(config).sample(t, 1); + final strategy = strategyFor(config.variant); + if (behind) { + strategy.paintBehind(c, size, config, frame); + } else { + strategy.paintAbove(c, size, config, frame); + } + return _rasterize(recorder, target); +} + +/// Renders one frame through a [BeamPainter], which is where `renderScale` +/// lives — a strategy never sees it. +Future<_Pixels> _paintThroughPainter( + WidgetTester tester, + BeamConfig config, { + ui.Size size = const ui.Size(350, 140), + Duration at = const Duration(milliseconds: 900), +}) async { + late _Pixels pixels; + await tester.runAsync(() async { + final clock = BeamClock(createTicker: Ticker.new); + final painter = BeamPainter( + clock: clock, + config: config, + resolver: BeamPhaseResolver(config), + strategy: strategyFor(config.variant), + behind: false, + staticMode: false, + frozenAt: at, + ); + pixels = await _record(painter, size); + clock.dispose(); + }); + return pixels; +} + +/// [_record], run on the real event loop: `toImage` never completes under a +/// widget test's fake async. +Future<_Pixels> _recordIn( + WidgetTester tester, + BeamPainter painter, + ui.Size size, +) async { + late _Pixels pixels; + await tester.runAsync(() async { + pixels = await _record(painter, size); + }); + return pixels; +} + +Future<_Pixels> _record(BeamPainter painter, ui.Size size) async { + final recorder = ui.PictureRecorder(); + painter.paint(ui.Canvas(recorder), size); + return _rasterize(recorder, size); +} + +Future<_Pixels> _rasterize(ui.PictureRecorder recorder, ui.Size size) async { + final image = await recorder.endRecording().toImage( + size.width.toInt(), + size.height.toInt(), + ); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final width = image.width; + final height = image.height; + image.dispose(); + return _Pixels(bytes!.buffer.asUint8List(), width, height); +} + +/// The alpha channel of a rendered frame. +class _Pixels { + _Pixels(this.bytes, this.width, this.height); + + final List bytes; + final int width; + final int height; + + double _alphaAt(int x, int y) { + if (x < 0 || y < 0 || x >= width || y >= height) return 0; + return bytes[(y * width + x) * 4 + 3] / 255; + } + + /// Total alpha inside [rect], in whole-pixel units. + double totalAlpha(ui.Rect rect) { + var sum = 0.0; + for (var y = rect.top.round(); y < rect.bottom.round(); y++) { + for (var x = rect.left.round(); x < rect.right.round(); x++) { + sum += _alphaAt(x, y); + } + } + return sum; + } +} diff --git a/test/widget/beam_shape_test.dart b/test/widget/beam_shape_test.dart new file mode 100644 index 0000000..3498f25 --- /dev/null +++ b/test/widget/beam_shape_test.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child, {TextDirection? textDirection}) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Scaffold( + body: Center( + child: SizedBox( + width: 350, + height: 140, + child: textDirection == null + ? child + : Directionality(textDirection: textDirection, child: child), + ), + ), + ), +); + +BeamConfig _config(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .first + .config; + +/// How `BeamShape` reaches `BeamConfig`: inherited from a `BorderBeamTheme`, +/// overridden field by field on the widget, short-circuited by the +/// `borderRadius:` shorthand, and — for a directional radius — resolved +/// against the ambient `Directionality`. +void main() { + group('inheritance', () { + testWidgets('a theme borderWidth and superellipse reach the config', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData( + shape: BeamShape(borderWidth: 4, superellipse: true), + ), + child: BorderBeam.rotate(child: SizedBox.expand()), + ), + ), + ); + final config = _config(tester); + expect(config.borderWidth, 4); + expect(config.useSuperellipse, isTrue); + expect( + config.borderRadius, + BorderRadius.circular(16), + reason: 'the radius still falls through to the rotate preset', + ); + }); + + testWidgets('the widget overrides an inherited superellipse with false', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData( + shape: BeamShape(superellipse: true, borderWidth: 4), + ), + child: BorderBeam.rotate( + shape: BeamShape(superellipse: false), + child: SizedBox.expand(), + ), + ), + ), + ); + final config = _config(tester); + expect(config.useSuperellipse, isFalse, reason: 'false is a value'); + expect(config.borderWidth, 4, reason: 'the field it did not set'); + }); + + testWidgets('each variant keeps its own default radius and width', ( + tester, + ) async { + for (final variant in BeamVariant.values) { + await tester.pumpWidget( + _host(BorderBeam(variant: variant, child: const SizedBox.expand())), + ); + final config = _config(tester); + expect( + config.borderRadius, + BorderRadius.circular(variant == BeamVariant.small ? 32 : 16), + reason: '$variant', + ); + expect(config.borderWidth, 1, reason: '$variant'); + expect(config.useSuperellipse, isFalse, reason: '$variant'); + } + }); + }); + + group('borderRadius shorthand', () { + testWidgets('beats shape.radius on the same widget', (tester) async { + await tester.pumpWidget( + _host( + BorderBeam.rotate( + borderRadius: 8, + shape: BeamShape(radius: BorderRadius.circular(40)), + child: const SizedBox.expand(), + ), + ), + ); + expect(_config(tester).borderRadius, BorderRadius.circular(8)); + }); + + testWidgets('beats a theme radius', (tester) async { + await tester.pumpWidget( + _host( + BorderBeamTheme( + data: BorderBeamThemeData( + shape: BeamShape(radius: BorderRadius.circular(40)), + ), + child: const BorderBeam.rotate( + borderRadius: 8, + child: SizedBox.expand(), + ), + ), + ), + ); + expect(_config(tester).borderRadius, BorderRadius.circular(8)); + }); + + testWidgets('leaves the other shape fields alone', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + borderRadius: 8, + shape: BeamShape(borderWidth: 6, superellipse: true), + child: SizedBox.expand(), + ), + ), + ); + final config = _config(tester); + expect(config.borderRadius, BorderRadius.circular(8)); + expect(config.borderWidth, 6); + expect(config.useSuperellipse, isTrue); + }); + + testWidgets('a stadium shape survives to the config as an infinite ' + 'radius', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + shape: BeamShape.stadium(), + child: SizedBox.expand(), + ), + ), + ); + expect( + _config(tester).borderRadius, + const BorderRadius.all(Radius.circular(double.infinity)), + reason: 'the ring geometry, not the config, clamps it to the box', + ); + }); + }); + + group('directional radius', () { + const directional = BorderRadiusDirectional.only( + topStart: Radius.circular(20), + ); + + Widget beam(TextDirection direction) => _host( + textDirection: direction, + const BorderBeam.rotate( + shape: BeamShape(radius: directional), + child: SizedBox.expand(), + ), + ); + + testWidgets('topStart resolves to the top-left corner in LTR', ( + tester, + ) async { + await tester.pumpWidget(beam(TextDirection.ltr)); + final radius = _config(tester).borderRadius; + expect(radius.topLeft, const Radius.circular(20)); + expect(radius.topRight, Radius.zero); + }); + + testWidgets('topStart resolves to the top-right corner in RTL', ( + tester, + ) async { + await tester.pumpWidget(beam(TextDirection.rtl)); + final radius = _config(tester).borderRadius; + expect(radius.topRight, const Radius.circular(20)); + expect(radius.topLeft, Radius.zero); + expect(radius.bottomLeft, Radius.zero); + expect(radius.bottomRight, Radius.zero); + }); + + testWidgets('flipping the direction re-resolves the config', ( + tester, + ) async { + await tester.pumpWidget(beam(TextDirection.ltr)); + final ltr = _config(tester); + + await tester.pumpWidget(beam(TextDirection.rtl)); + final rtl = _config(tester); + expect(identical(rtl, ltr), isFalse, reason: 'a fresh config object'); + expect(rtl.borderRadius.topRight, const Radius.circular(20)); + + await tester.pumpWidget(beam(TextDirection.ltr)); + final back = _config(tester); + expect(identical(back, rtl), isFalse); + expect(back.borderRadius, ltr.borderRadius); + }); + + testWidgets('a non-directional radius is unaffected by RTL', ( + tester, + ) async { + await tester.pumpWidget( + _host( + textDirection: TextDirection.rtl, + BorderBeam.rotate( + shape: BeamShape( + radius: const BorderRadius.only(topLeft: Radius.circular(20)), + ), + child: const SizedBox.expand(), + ), + ), + ); + final radius = _config(tester).borderRadius; + expect(radius.topLeft, const Radius.circular(20)); + expect(radius.topRight, Radius.zero); + }); + }); +} diff --git a/test/widget/beam_shorthand_test.dart b/test/widget/beam_shorthand_test.dart new file mode 100644 index 0000000..0e2fb5a --- /dev/null +++ b/test/widget/beam_shorthand_test.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Scaffold( + body: Center(child: SizedBox(width: 350, height: 140, child: child)), + ), +); + +BeamPainter _painter(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .first; + +/// The three shorthands (`colors`, `active`, `borderRadius`) are the top of +/// the resolution order: they beat the value object they abbreviate on the +/// same widget, and everything a `BorderBeamTheme` supplies. The generic +/// `BorderBeam(variant:)` constructor must build exactly what the matching +/// named constructor does. +/// +/// The `borderRadius` shorthand is covered in `beam_shape_test.dart`, next +/// to the rest of the shape resolution. +void main() { + group('colors shorthand', () { + testWidgets('beats style.colors on the same widget', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + colors: BeamColors.sunset, + style: BeamStyle(colors: BeamColors.ocean, strength: 0.3), + child: SizedBox.expand(), + ), + ), + ); + final config = _painter(tester).config; + expect(identical(config.palette, BeamColors.sunset.resolve()), isTrue); + expect(config.strength, 0.3, reason: 'the rest of the style survives'); + }); + + testWidgets('beats a theme colors and the widget style together', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData( + style: BeamStyle(colors: BeamColors.ocean), + ), + child: BorderBeam.rotate( + colors: BeamColors.sunset, + style: BeamStyle(colors: BeamColors.mono), + child: SizedBox.expand(), + ), + ), + ), + ); + final config = _painter(tester).config; + expect(identical(config.palette, BeamColors.sunset.resolve()), isTrue); + expect( + config.staticColors, + isFalse, + reason: 'the mono palette never took effect', + ); + }); + + testWidgets('a null shorthand leaves style.colors in charge', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + style: BeamStyle(colors: BeamColors.ocean), + child: SizedBox.expand(), + ), + ), + ); + expect( + identical(_painter(tester).config.palette, BeamColors.ocean.resolve()), + isTrue, + ); + }); + }); + + group('active shorthand', () { + testWidgets('beats playback.active on the same widget', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + active: true, + playback: BeamPlayback(active: false), + child: SizedBox.expand(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(_painter(tester).clock.isVisible, isTrue); + expect(tester.binding.hasScheduledFrame, isTrue); + }); + + testWidgets('active: false wins over playback.active: true', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + active: false, + playback: BeamPlayback(active: true), + child: SizedBox.expand(), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + expect(_painter(tester).clock.isVisible, isFalse); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('beats a theme playback', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData(playback: BeamPlayback(active: false)), + child: BorderBeam.rotate(active: true, child: SizedBox.expand()), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(_painter(tester).clock.isVisible, isTrue); + }); + + testWidgets('a theme active: false keeps a plain beam idle', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData(playback: BeamPlayback(active: false)), + child: BorderBeam.rotate(child: SizedBox.expand()), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + expect(_painter(tester).clock.isVisible, isFalse); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('the shorthand keeps the rest of the playback', (tester) async { + var deactivated = 0; + await tester.pumpWidget( + _host( + BorderBeam.rotate( + active: true, + playback: const BeamPlayback( + active: false, + duration: Duration(seconds: 1), + ), + onDeactivate: () => deactivated++, + child: const SizedBox.expand(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1100)); + await tester.pump(const Duration(milliseconds: 600)); + expect(deactivated, 1, reason: 'playback.duration still applies'); + }); + }); + + group('the generic constructor', () { + Widget named(BeamVariant variant) => switch (variant) { + BeamVariant.rotate => const BorderBeam.rotate( + colors: BeamColors.ocean, + child: SizedBox.expand(), + ), + BeamVariant.small => const BorderBeam.small( + colors: BeamColors.ocean, + child: SizedBox.expand(), + ), + BeamVariant.line => const BorderBeam.line( + colors: BeamColors.ocean, + child: SizedBox.expand(), + ), + BeamVariant.pulseInside => const BorderBeam.pulseInside( + colors: BeamColors.ocean, + child: SizedBox.expand(), + ), + BeamVariant.pulseOutside => const BorderBeam.pulseOutside( + colors: BeamColors.ocean, + child: SizedBox.expand(), + ), + }; + + for (final variant in BeamVariant.values) { + testWidgets('$variant matches its named constructor', (tester) async { + await tester.pumpWidget( + _host( + BorderBeam( + variant: variant, + colors: BeamColors.ocean, + child: const SizedBox.expand(), + ), + ), + ); + final generic = _painter(tester); + expect(generic.config.variant, variant); + expect( + identical(generic.strategy, strategyFor(variant)), + isTrue, + reason: 'the strategy for $variant', + ); + + await tester.pumpWidget(_host(named(variant))); + final byName = _painter(tester); + expect(byName.config, generic.config); + expect(identical(byName.strategy, generic.strategy), isTrue); + expect(byName.behind, generic.behind); + }); + } + + testWidgets('pulseOutside is the only variant that paints behind', ( + tester, + ) async { + for (final variant in BeamVariant.values) { + await tester.pumpWidget( + _host(BorderBeam(variant: variant, child: const SizedBox.expand())), + ); + final custom = tester.widget( + find + .descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ) + .first, + ); + expect( + custom.painter, + variant == BeamVariant.pulseOutside ? isA() : isNull, + reason: '$variant', + ); + expect( + custom.foregroundPainter, + isA(), + reason: '$variant', + ); + } + }); + }); +} diff --git a/test/widget/beam_speed_test.dart b/test/widget/beam_speed_test.dart new file mode 100644 index 0000000..7ee5e12 --- /dev/null +++ b/test/widget/beam_speed_test.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_clock.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Scaffold( + body: Center(child: SizedBox(width: 350, height: 140, child: child)), + ), +); + +BeamPainter _painter(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .first; + +BeamClock _clock(WidgetTester tester) => _painter(tester).clock; + +/// `BeamTiming.speed` is the declarative playback rate: the beam's timeline +/// advances by `speed` seconds of animation per second of wall time. A +/// `BorderBeamController` owns the rate instead whenever one is attached. +void main() { + testWidgets('speed 2 advances the timeline at twice wall time', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + timing: BeamTiming(speed: 2), + child: SizedBox.expand(), + ), + ), + ); + final clock = _clock(tester); + expect(clock.speed, 2); + // The first tick after Ticker.start reports elapsed 0. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(clock.elapsedSeconds, closeTo(1, 1e-9)); + await tester.pump(const Duration(milliseconds: 500)); + expect(clock.elapsedSeconds, closeTo(2, 1e-9)); + }); + + testWidgets('the default rate is real time', (tester) async { + await tester.pumpWidget( + _host(const BorderBeam.rotate(child: SizedBox.expand())), + ); + final clock = _clock(tester); + expect(clock.speed, 1); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(clock.elapsedSeconds, closeTo(0.5, 1e-9)); + }); + + testWidgets('changing speed on rebuild takes effect from the next frame', ( + tester, + ) async { + Widget build(double speed) => _host( + BorderBeam.rotate( + timing: BeamTiming(speed: speed), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(2)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + final clock = _clock(tester); + expect(clock.elapsedSeconds, closeTo(1, 1e-9)); + + await tester.pumpWidget(build(0.5)); + expect(clock.speed, 0.5); + expect( + clock.elapsedSeconds, + closeTo(1, 1e-9), + reason: 'a rate change does not move the timeline', + ); + await tester.pump(const Duration(seconds: 1)); + expect(clock.elapsedSeconds, closeTo(1.5, 1e-9)); + }); + + testWidgets('changing only the speed keeps the resolved config', ( + tester, + ) async { + Widget build(double speed) => _host( + BorderBeam.rotate( + timing: BeamTiming(speed: speed), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(2)); + final before = _painter(tester); + await tester.pumpWidget(build(0.5)); + final after = _painter(tester); + expect( + identical(before.config, after.config), + isTrue, + reason: 'the rate is applied to the clock, not painted', + ); + expect(identical(before.resolver, after.resolver), isTrue); + }); + + testWidgets('changing the cycle does re-resolve the config', (tester) async { + Widget build(Duration cycle) => _host( + BorderBeam.rotate( + timing: BeamTiming(cycle: cycle), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(const Duration(seconds: 2))); + final before = _painter(tester); + await tester.pumpWidget(build(const Duration(seconds: 3))); + expect(identical(before.config, _painter(tester).config), isFalse); + }); + + testWidgets('a BorderBeamTheme speed is inherited', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData(timing: BeamTiming(speed: 3)), + child: BorderBeam.rotate(child: SizedBox.expand()), + ), + ), + ); + final clock = _clock(tester); + expect(clock.speed, 3); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(clock.elapsedSeconds, closeTo(0.6, 1e-9)); + }); + + testWidgets('a widget speed beats an inherited one', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData(timing: BeamTiming(speed: 3)), + child: BorderBeam.rotate( + timing: BeamTiming(speed: 0.25), + child: SizedBox.expand(), + ), + ), + ), + ); + expect(_clock(tester).speed, 0.25); + }); + + test('a non-positive speed asserts at the value-object boundary', () { + final zero = double.parse('0'); + expect( + () => BeamTiming(speed: zero), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('speed must be finite and positive'), + ), + ), + ); + }); + + group('with a controller attached', () { + testWidgets('the widget speed is ignored', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _host( + BorderBeam.rotate( + controller: controller, + timing: const BeamTiming(speed: 5), + child: const SizedBox.expand(), + ), + ), + ); + final clock = _clock(tester); + expect(clock.speed, 1, reason: "the controller's own rate wins"); + + controller.speed = 4; + expect(clock.speed, 4); + controller.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(clock.elapsedSeconds, closeTo(1, 1e-9)); + }); + + testWidgets('a rebuild does not restore the widget speed', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + Widget build(BeamColors colors) => _host( + BorderBeam.rotate( + controller: controller, + colors: colors, + timing: const BeamTiming(speed: 5), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(BeamColors.ocean)); + controller.speed = 2; + await tester.pumpWidget(build(BeamColors.sunset)); + expect(_clock(tester).speed, 2); + }); + + testWidgets('detaching hands the rate back to the widget', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + Widget build(BorderBeamController? c) => _host( + BorderBeam.rotate( + controller: c, + timing: const BeamTiming(speed: 5), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(controller)); + controller.speed = 2; + expect(_clock(tester).speed, 2); + + await tester.pumpWidget(build(null)); + expect(_clock(tester).speed, 5); + }); + }); +} diff --git a/test/widget/beam_sync_test.dart b/test/widget/beam_sync_test.dart new file mode 100644 index 0000000..3ebb6e0 --- /dev/null +++ b/test/widget/beam_sync_test.dart @@ -0,0 +1,395 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_clock.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_utils.dart'; + +Widget _host(Widget child, {bool disableAnimations = false}) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + builder: (context, app) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: disableAnimations), + child: app!, + ), + home: Scaffold(body: Center(child: child)), +); + +List _painters(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .toList(); + +Widget _beam({double? phaseOffset, Key? key}) => SizedBox( + width: 120, + height: 60, + child: BorderBeam.rotate( + key: key, + timing: BeamTiming(phaseOffset: phaseOffset), + child: const SizedBox.expand(), + ), +); + +/// A group of beams on one clock: one ticker, one timeline, and playback +/// owned by the scope rather than by each member. +void main() { + testWidgets('three beams share a single ticker', (tester) async { + await tester.pumpWidget( + _host( + BeamSync( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [_beam(), _beam(), _beam()], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(BorderBeam), findsNWidgets(3)); + expect(tester.binding.transientCallbackCount, 1); + }); + + testWidgets('the same three beams unsynced run three tickers', ( + tester, + ) async { + await tester.pumpWidget( + _host( + Row( + mainAxisSize: MainAxisSize.min, + children: [_beam(), _beam(), _beam()], + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.binding.transientCallbackCount, 3); + }); + + testWidgets('every beam paints from the one clock', (tester) async { + await tester.pumpWidget( + _host( + BeamSync( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [_beam(), _beam(), _beam()], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + final clocks = _painters(tester).map((p) => p.clock).toSet(); + expect(clocks, hasLength(1)); + expect(clocks.single.elapsedSeconds, closeTo(0.3, 1e-9)); + }); + + testWidgets('lockstep beams sit at the same sweep position', (tester) async { + await tester.pumpWidget( + _host( + BeamSync( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [_beam(), _beam()], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final painters = _painters(tester); + final progress = painters + .map((p) => p.resolver.sample(p.clock.elapsedSeconds, 1).travelProgress) + .toSet(); + expect(progress, hasLength(1)); + }); + + testWidgets('each beam still applies its own phase offset', (tester) async { + await tester.pumpWidget( + _host( + BeamSync( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _beam(), + _beam(phaseOffset: 0.25), + _beam(phaseOffset: 0.5), + ], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final painters = _painters(tester); + final progress = painters + .map((p) => p.resolver.sample(p.clock.elapsedSeconds, 1).travelProgress) + .toList(); + expect(progress, hasLength(3)); + expect(progress[1] - progress[0], closeTo(0.25, 1e-9)); + expect(progress[2] - progress[0], closeTo(0.5, 1e-9)); + }); + + testWidgets('the group speed drives the shared clock', (tester) async { + await tester.pumpWidget(_host(BeamSync(speed: 3, child: _beam()))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(_painters(tester).first.clock.elapsedSeconds, closeTo(0.6, 1e-9)); + }); + + testWidgets('the group stops and starts every beam together', (tester) async { + Widget build({required bool active}) => _host( + BeamSync( + active: active, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [_beam(), _beam()], + ), + ), + ); + + await tester.pumpWidget(build(active: true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final clock = _painters(tester).first.clock; + expect(clock.isVisible, isTrue); + + await tester.pumpWidget(build(active: false)); + expect(clock.stage, BeamFadeStage.fadingOut); + await tester.pump(const Duration(milliseconds: 600)); + expect(clock.isVisible, isFalse); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(build(active: true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(clock.isVisible, isTrue); + expect(tester.binding.transientCallbackCount, 1); + }); + + testWidgets('the group reverses a fade-out that has not finished', ( + tester, + ) async { + Widget build({required bool active}) => + _host(BeamSync(active: active, child: _beam())); + + await tester.pumpWidget(build(active: true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + final clock = _painters(tester).first.clock; + + await tester.pumpWidget(build(active: false)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(clock.stage, BeamFadeStage.fadingOut); + expect(clock.fadeOpacity, lessThan(1)); + final elapsed = clock.elapsedSeconds; + + // Back on mid-fade: the fade must turn around from where it is instead of + // running on to invisible. + await tester.pumpWidget(build(active: true)); + expect(clock.stage, BeamFadeStage.fadingIn); + expect( + clock.elapsedSeconds, + closeTo(elapsed, 1e-9), + reason: 'a reversed fade keeps the timeline it was on', + ); + await tester.pump(const Duration(milliseconds: 700)); + expect(clock.isVisible, isTrue); + expect(clock.fadeOpacity, closeTo(1, 1e-6)); + }); + + testWidgets('a group that starts stopped never runs a ticker', ( + tester, + ) async { + await tester.pumpWidget(_host(BeamSync(active: false, child: _beam()))); + await tester.pump(const Duration(seconds: 1)); + expect(tester.binding.transientCallbackCount, 0); + expect(_painters(tester).first.clock.isVisible, isFalse); + }); + + testWidgets('reduced motion freezes the group on a static frame', ( + tester, + ) async { + await tester.pumpWidget( + _host( + disableAnimations: true, + BeamSync( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [_beam(), _beam()], + ), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + expect(tester.binding.transientCallbackCount, 0); + final painters = _painters(tester); + expect(painters, hasLength(2)); + for (final painter in painters) { + expect(painter.staticMode, isTrue); + } + }); + + testWidgets('the group resumes when the request lifts', (tester) async { + Widget build({required bool reduced}) => + _host(disableAnimations: reduced, BeamSync(child: _beam())); + + await tester.pumpWidget(build(reduced: true)); + await tester.pump(const Duration(seconds: 1)); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(build(reduced: false)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.binding.transientCallbackCount, 1); + expect(_painters(tester).first.staticMode, isFalse); + }); + + testWidgets('returning to active under reduced motion stops a fade ticker', ( + tester, + ) async { + Widget build(bool active) => _host( + disableAnimations: true, + BeamSync(active: active, child: _beam()), + ); + + await tester.pumpWidget(build(true)); + expect(tester.binding.transientCallbackCount, 0); + await tester.pumpWidget(build(false)); + expect(tester.binding.transientCallbackCount, 1); + await tester.pumpWidget(build(true)); + expect(tester.binding.transientCallbackCount, 0); + expect(_painters(tester).first.clock.isRunning, isFalse); + }); + + testWidgets('the group can opt into full motion under reduced motion', ( + tester, + ) async { + await tester.pumpWidget( + _host( + disableAnimations: true, + BeamSync(reducedMotion: BeamReducedMotion.animate, child: _beam()), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + final painter = _painters(tester).first; + expect(painter.staticMode, isFalse); + expect(painter.clock.isRunning, isTrue); + expect(painter.clock.elapsedSeconds, closeTo(0.2, 1e-9)); + }); + + testWidgets('the group slow policy scales its shared clock', (tester) async { + await tester.pumpWidget( + _host( + disableAnimations: true, + BeamSync(reducedMotion: BeamReducedMotion.slow, child: _beam()), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + final painter = _painters(tester).first; + expect(painter.staticMode, isFalse); + expect(painter.clock.speed, 0.25); + expect(painter.clock.elapsedSeconds, closeTo(0.25, 1e-9)); + }); + + testWidgets('disposing the group leaves no ticker behind', (tester) async { + await tester.pumpWidget( + _host( + BeamSync( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [_beam(), _beam(), _beam()], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.binding.transientCallbackCount, 1); + + await tester.pumpWidget(_host(const SizedBox())); + expect(tester.binding.transientCallbackCount, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('removing one beam leaves the rest running', (tester) async { + Widget build(int count) => _host( + BeamSync( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [for (var i = 0; i < count; i++) _beam()], + ), + ), + ); + + await tester.pumpWidget(build(3)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpWidget(build(1)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.binding.transientCallbackCount, 1); + expect(_painters(tester), hasLength(1)); + expect(tester.takeException(), isNull); + }); + + testWidgets('a controller inside a group asserts', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + // The group is stopped so it never starts a ticker: a build that throws + // leaves a tree Flutter 3.35 cannot unmount (`InheritedElement.unmount` + // asserts on its dependents), so a ticker started here would outlive the + // test and count against the next one. + await pumpExpectingAssertion( + tester, + _host( + BeamSync( + active: false, + child: SizedBox( + width: 120, + height: 60, + child: BorderBeam.rotate( + controller: controller, + child: const SizedBox.expand(), + ), + ), + ), + ), + message: 'A BorderBeam under a BeamSync runs on the group clock', + ); + }); + + testWidgets('leaving the group hands the beam its own clock', (tester) async { + final key = GlobalKey(); + Widget build({required bool synced}) { + final beam = _beam(key: key); + return _host(synced ? BeamSync(child: beam) : beam); + } + + await tester.pumpWidget(build(synced: true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + final shared = _painters(tester).first.clock; + expect(tester.binding.transientCallbackCount, 1); + + await tester.pumpWidget(build(synced: false)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + final own = _painters(tester).first.clock; + expect(identical(own, shared), isFalse); + expect(own.isVisible, isTrue); + expect(tester.binding.transientCallbackCount, 1); + + await tester.pumpWidget(_host(const SizedBox())); + expect(tester.binding.transientCallbackCount, 0); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widget/beam_theme_config_test.dart b/test/widget/beam_theme_config_test.dart new file mode 100644 index 0000000..ef5e378 --- /dev/null +++ b/test/widget/beam_theme_config_test.dart @@ -0,0 +1,256 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/constants/theme_presets.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + home: Scaffold( + body: Center( + child: SizedBox( + width: 200, + height: 100, + child: RepaintBoundary(child: child), + ), + ), + ), +); + +BeamConfig _config(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .first + .config; + +/// Rasterizes the beam's repaint boundary as raw RGBA. +Future _pixels(WidgetTester tester) async { + final boundary = tester.renderObject( + find.byType(BorderBeam), + ); + final bytes = await tester.runAsync(() async { + final image = await boundary.toImage(); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + image.dispose(); + return data!.buffer.asUint8List(); + }); + return bytes!; +} + +/// `BeamStyle.themeConfig` replaces the whole variant×brightness preset, so a +/// layer opacity set to zero must reach the painter's config *and* the +/// rasterized frame. +void main() { + group('themeConfig through a mounted beam', () { + Widget beam(BeamThemeConfig? themeConfig) => _host( + BorderBeam.rotate( + style: BeamStyle(themeConfig: themeConfig), + // A frozen palette and no hue animation keeps two renders of the + // same config byte-identical. + colors: BeamColors.ocean, + child: const SizedBox.expand(), + ), + ); + + testWidgets('replaces the preset the variant would have used', ( + tester, + ) async { + final preset = BeamThemeConfig.presetFor( + BeamVariant.rotate, + Brightness.dark, + ); + await tester.pumpWidget(beam(preset.copyWith(strokeOpacity: 0))); + final config = _config(tester); + expect(config.theme.strokeOpacity, 0); + expect( + config.theme.innerOpacity, + preset.innerOpacity, + reason: 'the untouched fields still come from the preset', + ); + expect(config.theme.bloomOpacity, preset.bloomOpacity); + expect(config.theme.innerShadow, preset.innerShadow); + }); + + testWidgets('a zero stroke opacity changes the rendered frame', ( + tester, + ) async { + final preset = BeamThemeConfig.presetFor( + BeamVariant.rotate, + Brightness.dark, + ); + + await tester.pumpWidget(beam(null)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + final withStroke = await _pixels(tester); + + // The same tree again: the rasterization itself is deterministic, so a + // difference below can only come from the config. + await tester.pumpWidget(beam(null)); + await tester.pump(); + expect( + await _pixels(tester), + withStroke, + reason: 'an unchanged config renders the identical frame', + ); + + await tester.pumpWidget(beam(preset.copyWith(strokeOpacity: 0))); + await tester.pump(); + final withoutStroke = await _pixels(tester); + expect( + withoutStroke, + isNot(withStroke), + reason: 'the stroke layer is gone', + ); + expect(withoutStroke.length, withStroke.length); + }); + + testWidgets('a fully transparent preset paints nothing', (tester) async { + const blank = BeamThemeConfig( + strokeOpacity: 0, + innerOpacity: 0, + bloomOpacity: 0, + innerShadow: Color(0x00000000), + saturation: 1, + ); + await tester.pumpWidget(beam(blank)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + final pixels = await _pixels(tester); + var opaque = 0; + for (var i = 3; i < pixels.length; i += 4) { + if (pixels[i] != 0) opaque++; + } + expect(opaque, 0, reason: 'every beam layer opacity is zero'); + }); + + testWidgets('style brightness and saturation still override it', ( + tester, + ) async { + await tester.pumpWidget( + _host( + BorderBeam.rotate( + style: BeamStyle( + themeConfig: BeamThemeConfig.presetFor( + BeamVariant.rotate, + Brightness.dark, + ).copyWith(brightness: 2, saturation: 3), + brightness: 0.5, + saturation: 0.25, + ), + child: const SizedBox.expand(), + ), + ), + ); + final config = _config(tester); + expect(config.brightnessFactor, 0.5); + expect(config.saturation, 0.25); + }); + + testWidgets('a themeConfig from a BorderBeamTheme is inherited', ( + tester, + ) async { + final preset = BeamThemeConfig.presetFor( + BeamVariant.line, + Brightness.dark, + ).copyWith(bloomOpacity: 0.05); + await tester.pumpWidget( + _host( + BorderBeamTheme( + data: BorderBeamThemeData(style: BeamStyle(themeConfig: preset)), + child: const BorderBeam.line(child: SizedBox.expand()), + ), + ), + ); + expect(_config(tester).theme.bloomOpacity, 0.05); + }); + }); + + group('BeamThemeConfig', () { + test('presetFor matches themePresetFor for every variant × brightness', () { + for (final variant in BeamVariant.values) { + for (final brightness in Brightness.values) { + final preset = BeamThemeConfig.presetFor(variant, brightness); + final expected = themePresetFor(variant, brightness); + expect(preset, expected, reason: '$variant $brightness'); + expect(preset.strokeOpacity, expected.strokeOpacity); + expect(preset.innerOpacity, expected.innerOpacity); + expect(preset.bloomOpacity, expected.bloomOpacity); + expect(preset.innerShadow, expected.innerShadow); + expect(preset.saturation, expected.saturation); + expect(preset.brightness, expected.brightness); + expect(preset.hairlineOpacity, expected.hairlineOpacity); + } + } + }); + + test('dark and light presets differ for every variant', () { + for (final variant in BeamVariant.values) { + expect( + BeamThemeConfig.presetFor(variant, Brightness.dark), + isNot(BeamThemeConfig.presetFor(variant, Brightness.light)), + reason: '$variant', + ); + } + }); + + test('copyWith keeps every field it is not given', () { + const base = BeamThemeConfig( + strokeOpacity: 0.26, + innerOpacity: 0.42, + bloomOpacity: 0.24, + innerShadow: Color(0x44FFFFFF), + saturation: 1.2, + brightness: 1.1, + hairlineOpacity: 0.3, + ); + final copy = base.copyWith(bloomOpacity: 0.9); + expect(copy.bloomOpacity, 0.9); + expect(copy.strokeOpacity, base.strokeOpacity); + expect(copy.innerOpacity, base.innerOpacity); + expect(copy.innerShadow, base.innerShadow); + expect(copy.saturation, base.saturation); + expect(copy.brightness, base.brightness); + expect(copy.hairlineOpacity, base.hairlineOpacity); + expect(base.copyWith(), base, reason: 'an empty copy is the same value'); + }); + + test('equality and hashCode are by value', () { + const a = BeamThemeConfig( + strokeOpacity: 0.5, + innerOpacity: 0.4, + bloomOpacity: 0.3, + innerShadow: Color(0x22000000), + saturation: 1.5, + ); + const b = BeamThemeConfig( + strokeOpacity: 0.5, + innerOpacity: 0.4, + bloomOpacity: 0.3, + innerShadow: Color(0x22000000), + saturation: 1.5, + ); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(b.copyWith(strokeOpacity: 0.51))); + expect(a, isNot(b.copyWith(innerOpacity: 0.41))); + expect(a, isNot(b.copyWith(bloomOpacity: 0.31))); + expect(a, isNot(b.copyWith(innerShadow: const Color(0x22000001)))); + expect(a, isNot(b.copyWith(saturation: 1.51))); + expect(a, isNot(b.copyWith(brightness: 1))); + expect(a, isNot(b.copyWith(hairlineOpacity: 0))); + expect(a.toString(), contains('strokeOpacity: 0.5')); + }); + }); +} diff --git a/test/widget/border_beam_cycle_gap_test.dart b/test/widget/border_beam_cycle_gap_test.dart new file mode 100644 index 0000000..4f9509f --- /dev/null +++ b/test/widget/border_beam_cycle_gap_test.dart @@ -0,0 +1,685 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child, {bool disableAnimations = false}) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + builder: (context, app) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: disableAnimations), + child: app!, + ), + home: Scaffold( + body: Center(child: SizedBox(width: 350, height: 140, child: child)), + ), +); + +BeamPainter _painter(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .first; + +BeamPhaseResolver _resolver(WidgetTester tester) => _painter(tester).resolver; + +// A resolver built straight from a config, for the instants the envelope is +// specified at — no clock, no widget. +BeamPhaseResolver _direct({ + required Duration cycle, + required Duration gap, + BeamVariant variant = BeamVariant.rotate, +}) => BeamPhaseResolver( + BeamConfig.resolve( + variant: variant, + palette: BeamColors.colorful.resolve(), + brightness: Brightness.dark, + timing: BeamTiming(cycle: cycle, cycleGap: gap), + ), +); + +/// `BeamTiming.cycleGap` rests the traveling beam between sweeps: the sweep +/// still takes `cycle`, then the beam parks at the end of its travel and its +/// fade envelope eases to zero for the rest of the gap. Phases are sampled +/// directly off the mounted beam's resolver so the instants are exact. +void main() { + const timing = BeamTiming( + cycle: Duration(seconds: 1), + cycleGap: Duration(seconds: 1), + ); + + testWidgets('rotate fades out through the gap and back in on the next ' + 'sweep', (tester) async { + await tester.pumpWidget( + _host(const BorderBeam.rotate(timing: timing, child: SizedBox.expand())), + ); + final resolver = _resolver(tester); + expect(resolver.config.gapSeconds, closeTo(1, 1e-9)); + + expect(resolver.sample(0.5, 1).fadeOpacity, 1, reason: 'mid sweep'); + expect(resolver.sample(1.5, 1).fadeOpacity, 0, reason: 'mid rest'); + expect(resolver.sample(2.5, 1).fadeOpacity, 1, reason: 'next sweep'); + }); + + testWidgets('rotate parks its angle at the end of the sweep', (tester) async { + await tester.pumpWidget( + _host(const BorderBeam.rotate(timing: timing, child: SizedBox.expand())), + ); + final resolver = _resolver(tester); + // Progress is pinned to 1.0 for the whole rest, so the beam holds where + // its sweep ended rather than drifting on. + expect(resolver.sample(1.2, 1).angleRadians, closeTo(2 * 3.14159265, 1e-5)); + expect(resolver.sample(1.9, 1).angleRadians, closeTo(2 * 3.14159265, 1e-5)); + }); + + testWidgets('the gap eases in and out rather than cutting', (tester) async { + await tester.pumpWidget( + _host(const BorderBeam.rotate(timing: timing, child: SizedBox.expand())), + ); + final resolver = _resolver(tester); + // gapFade = min(0.25, gap / 2) = 0.25s at each end of the rest. + final entering = resolver.sample(1.125, 1).fadeOpacity; + final leaving = resolver.sample(1.875, 1).fadeOpacity; + expect(entering, greaterThan(0)); + expect(entering, lessThan(1)); + expect(leaving, closeTo(entering, 1e-9)); + expect(resolver.sample(1.0, 1).fadeOpacity, closeTo(1, 1e-9)); + expect(resolver.sample(2.0, 1).fadeOpacity, closeTo(1, 1e-9)); + }); + + testWidgets('line parks invisible at the end of its travel', (tester) async { + await tester.pumpWidget( + _host(const BorderBeam.line(timing: timing, child: SizedBox.expand())), + ); + final resolver = _resolver(tester); + for (final t in [1.2, 1.5, 1.9]) { + final phases = resolver.sample(t, 1); + expect(phases.lineX, closeTo(0.94, 1e-9), reason: 'parked at t=$t'); + expect(phases.edge, 0, reason: 'edge fade is already 0 at t=$t'); + } + }); + + testWidgets('a zero gap leaves the sweep untouched', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeam.rotate( + timing: BeamTiming(cycle: Duration(seconds: 1)), + child: SizedBox.expand(), + ), + ), + ); + final resolver = _resolver(tester); + expect(resolver.config.gapSeconds, 0); + for (final t in [0.0, 0.25, 0.5, 0.99, 1.5, 7.3]) { + expect(resolver.sample(t, 1).fadeOpacity, 1, reason: 't=$t'); + expect( + resolver.sample(t, 1).angleRadians, + closeTo((t % 1.0) * 2 * 3.14159265358979, 1e-9), + reason: 't=$t', + ); + } + }); + + group('retiming across a gap', () { + const twoPlusOne = BeamTiming( + cycle: Duration(seconds: 2), + cycleGap: Duration(seconds: 1), + ); + + testWidgets('a cycle change mid-sweep keeps travel progress continuous', ( + tester, + ) async { + Widget build(BeamTiming timing) => _host( + BorderBeam.rotate(timing: timing, child: const SizedBox.expand()), + ); + + await tester.pumpWidget(build(twoPlusOne)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + final before = _painter(tester); + expect(before.clock.elapsedSeconds, closeTo(0.9, 1e-9)); + final angle = before.resolver.sample(0.9, 1).angleRadians; + + await tester.pumpWidget( + build( + const BeamTiming( + cycle: Duration(seconds: 4), + cycleGap: Duration(seconds: 1), + ), + ), + ); + final after = _painter(tester); + expect(after.config.cycleSeconds, closeTo(4, 1e-9)); + expect(after.config.gapSeconds, closeTo(1, 1e-9)); + expect( + after.clock.elapsedSeconds, + closeTo(1.8, 1e-9), + reason: 'elapsed is rescaled by the cycle ratio, gap and all', + ); + expect( + after.resolver.sample(after.clock.elapsedSeconds, 1).angleRadians, + closeTo(angle, 1e-6), + reason: 'the sweep is at the same fraction of its travel', + ); + }); + + testWidgets('changing only the gap needs no retime', (tester) async { + Widget build(Duration gap) => _host( + BorderBeam.rotate( + timing: BeamTiming(cycle: const Duration(seconds: 2), cycleGap: gap), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(const Duration(seconds: 1))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + final before = _painter(tester); + final angle = before.resolver.sample(0.9, 1).angleRadians; + + await tester.pumpWidget(build(const Duration(seconds: 2))); + final after = _painter(tester); + expect(after.config.gapSeconds, closeTo(2, 1e-9)); + expect( + after.clock.elapsedSeconds, + closeTo(0.9, 1e-9), + reason: 'the timeline is untouched', + ); + expect( + after.resolver.sample(0.9, 1).angleRadians, + closeTo(angle, 1e-9), + reason: 'the sweep keeps its position; the rest appears later', + ); + }); + + testWidgets('a phase-offset beam resting in the gap stays in the gap', ( + tester, + ) async { + Widget build(Duration cycle) => _host( + BorderBeam.rotate( + timing: BeamTiming( + cycle: cycle, + cycleGap: const Duration(seconds: 1), + phaseOffset: 0.5, + ), + child: const SizedBox.expand(), + ), + ); + + // cycle 2 + gap 1 with a half-cycle offset: at 1.5s elapsed the beam is + // 2.5s into its own 3s period — parked half a second into the rest. + await tester.pumpWidget(build(const Duration(seconds: 2))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1500)); + final before = _painter(tester); + final resting = before.resolver.sample(1.5, 1); + expect(resting.fadeOpacity, closeTo(0, 1e-9)); + expect(resting.travelProgress, closeTo(1, 1e-9)); + + await tester.pumpWidget(build(const Duration(seconds: 4))); + final after = _painter(tester); + final phases = after.resolver.sample(after.clock.elapsedSeconds, 1); + expect( + phases.fadeOpacity, + closeTo(0, 1e-9), + reason: 'the raw timeline says mid-sweep; the shifted one says gap', + ); + expect(phases.travelProgress, closeTo(1, 1e-9)); + }); + + testWidgets('a follow hand-back survives a cycle change', (tester) async { + Widget build({required Duration cycle, Offset? follow}) => _host( + BorderBeam.rotate( + timing: BeamTiming(cycle: cycle), + follow: follow, + child: const SizedBox.expand(), + ), + ); + + const short = Duration(seconds: 4); + await tester.pumpWidget(build(cycle: short)); + await tester.pump(); + + // Hand the sweep to a pointer parked most of a cycle behind the clock, + // let it settle, then let go: the beam carries a large negative travel + // offset from here on, which puts the shifted timeline behind zero. + await tester.pumpWidget( + build(cycle: short, follow: const Offset(0, 0.5)), + ); + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await tester.pumpWidget(build(cycle: short)); + final before = _painter(tester); + final offset = before.resolver.travelTimeOffset; + expect( + offset, + lessThan(-before.clock.elapsedSeconds), + reason: + 'the hand-back must outweigh elapsed for the shift to go ' + 'negative', + ); + final progress = before.resolver + .sample(before.clock.elapsedSeconds, 1) + .travelProgress; + + await tester.pumpWidget(build(cycle: const Duration(seconds: 16))); + final after = _painter(tester); + expect( + after.resolver.travelTimeOffset, + closeTo(offset, 1e-9), + reason: 'a rebuilt resolver keeps the hand-back', + ); + expect( + after.resolver.sample(after.clock.elapsedSeconds, 1).travelProgress, + closeTo(progress, 1e-6), + reason: 'the retime target is lifted by whole periods, not refused', + ); + }); + + testWidgets('the line breathe and spike tracks survive a retime', ( + tester, + ) async { + Widget build(Duration cycle) => _host( + BorderBeam.line( + timing: BeamTiming( + cycle: cycle, + cycleGap: const Duration(seconds: 1), + ), + child: const SizedBox.expand(), + ), + ); + + // A gap bends the retime target away from a pure ratio rescale, and + // these three tracks scale with the cycle, so only a rescaled timeline + // holds their phase. + await tester.pumpWidget(build(const Duration(seconds: 2))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2500)); + final before = _painter(tester); + final was = before.resolver.sample(before.clock.elapsedSeconds, 1); + + await tester.pumpWidget(build(const Duration(seconds: 4))); + final after = _painter(tester); + final now = after.resolver.sample(after.clock.elapsedSeconds, 1); + + expect(now.lineH, closeTo(was.lineH, 1e-9)); + expect(now.spike, closeTo(was.spike, 1e-9)); + expect(now.spike2, closeTo(was.spike2, 1e-9)); + }); + + testWidgets('a restart drops the timeline corrections a retime left', ( + tester, + ) async { + Widget build({required Duration cycle, required bool active}) => _host( + BorderBeam.line( + active: active, + timing: BeamTiming( + cycle: cycle, + cycleGap: const Duration(seconds: 1), + ), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget( + build(cycle: const Duration(seconds: 2), active: true), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2500)); + await tester.pumpWidget( + build(cycle: const Duration(seconds: 4), active: true), + ); + final retimed = _painter(tester); + expect(retimed.resolver.hueTimeOffset, isNot(0)); + expect(retimed.resolver.breatheTimeOffset, isNot(0)); + + // Off, let the fade finish, then on: activate() puts elapsed back to + // zero, so every correction measured against the old timeline goes. + await tester.pumpWidget( + build(cycle: const Duration(seconds: 4), active: false), + ); + await tester.pump(const Duration(milliseconds: 600)); + await tester.pumpWidget( + build(cycle: const Duration(seconds: 4), active: true), + ); + final restarted = _painter(tester); + expect(restarted.clock.elapsedSeconds, closeTo(0, 1e-9)); + expect(restarted.resolver.hueTimeOffset, 0); + expect(restarted.resolver.breatheTimeOffset, 0); + expect(restarted.resolver.travelTimeOffset, 0); + }); + + testWidgets('a synced group restart drops its members corrections', ( + tester, + ) async { + Widget build({required Duration cycle, required bool active}) => _host( + BeamSync( + active: active, + child: BorderBeam.line( + timing: BeamTiming(cycle: cycle), + child: const SizedBox.expand(), + ), + ), + ); + + await tester.pumpWidget( + build(cycle: const Duration(seconds: 2), active: true), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1500)); + await tester.pumpWidget( + build(cycle: const Duration(seconds: 4), active: true), + ); + expect(_painter(tester).resolver.hueTimeOffset, isNot(0)); + + // The group owns the clock, so the member is never told to restart — + // it has to hear it from the clock itself. + await tester.pumpWidget( + build(cycle: const Duration(seconds: 4), active: false), + ); + await tester.pump(const Duration(milliseconds: 600)); + await tester.pumpWidget( + build(cycle: const Duration(seconds: 4), active: true), + ); + final restarted = _painter(tester); + expect(restarted.clock.elapsedSeconds, closeTo(0, 1e-9)); + expect(restarted.resolver.hueTimeOffset, 0); + expect(restarted.resolver.breatheTimeOffset, 0); + }); + + testWidgets('a variant change rebuilds the clock and drops corrections', ( + tester, + ) async { + Widget build({ + required BeamVariant variant, + required Duration cycle, + bool active = true, + }) => _host( + BorderBeam( + variant: variant, + active: active, + timing: BeamTiming( + cycle: cycle, + cycleGap: const Duration(seconds: 1), + ), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget( + build(variant: BeamVariant.line, cycle: const Duration(seconds: 2)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2500)); + await tester.pumpWidget( + build(variant: BeamVariant.line, cycle: const Duration(seconds: 4)), + ); + final retimed = _painter(tester); + expect(retimed.resolver.hueTimeOffset, isNot(0)); + expect(retimed.resolver.breatheTimeOffset, isNot(0)); + + // The fps cap is variant-bound, so this swaps the clock out from under + // the beam — a new timeline at zero, and no reset the old clock could + // have announced. + await tester.pumpWidget( + build(variant: BeamVariant.rotate, cycle: const Duration(seconds: 4)), + ); + final swapped = _painter(tester); + expect(swapped.clock, isNot(same(retimed.clock))); + expect(swapped.clock.elapsedSeconds, closeTo(0, 1e-9)); + expect(swapped.resolver.hueTimeOffset, 0); + expect(swapped.resolver.breatheTimeOffset, 0); + expect(swapped.resolver.travelTimeOffset, 0); + + // The replacement clock is bound too, so a restart on *it* clears the + // corrections it accumulated after the swap. Retime again to put some + // there — the offsets are 0 right now, so a restart would prove + // nothing without this. From inside the gap, so the target is not a + // pure rescale and the breathe correction is non-zero as well. + await tester.pump(const Duration(milliseconds: 4500)); + await tester.pumpWidget( + build(variant: BeamVariant.rotate, cycle: const Duration(seconds: 8)), + ); + final again = _painter(tester); + expect(again.clock, same(swapped.clock)); + expect(again.resolver.hueTimeOffset, isNot(0)); + expect(again.resolver.breatheTimeOffset, isNot(0)); + + await tester.pumpWidget( + build( + variant: BeamVariant.rotate, + cycle: const Duration(seconds: 8), + active: false, + ), + ); + await tester.pump(const Duration(milliseconds: 600)); + expect(again.clock.isVisible, isFalse); + expect(again.resolver.hueTimeOffset, 0); + expect(again.resolver.breatheTimeOffset, 0); + }); + + testWidgets('a reduced-motion variant swap clears them without a restart', ( + tester, + ) async { + Widget build({ + required BeamVariant variant, + required Duration cycle, + bool reduced = false, + }) => _host( + disableAnimations: reduced, + BorderBeam( + variant: variant, + timing: BeamTiming( + cycle: cycle, + cycleGap: const Duration(seconds: 1), + ), + child: const SizedBox.expand(), + ), + ); + + // Accumulate the corrections while the beam is genuinely running: a + // beam that starts under reduced motion never activates its clock, so + // it never retimes either. + await tester.pumpWidget( + build(variant: BeamVariant.line, cycle: const Duration(seconds: 2)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2500)); + await tester.pumpWidget( + build(variant: BeamVariant.line, cycle: const Duration(seconds: 4)), + ); + expect(_painter(tester).resolver.breatheTimeOffset, isNot(0)); + + // Now freeze it. The clock stays visible and the corrections stay + // valid for the clock they describe. + await tester.pumpWidget( + build( + variant: BeamVariant.line, + cycle: const Duration(seconds: 4), + reduced: true, + ), + ); + expect(_painter(tester).staticMode, isTrue); + + // The static frame samples the resolver, so a stale correction shows. + // Nothing restarts the replacement clock here — reduced motion keeps + // _start() out — so the clear has to happen when the clock is built. + await tester.pumpWidget( + build( + variant: BeamVariant.rotate, + cycle: const Duration(seconds: 4), + reduced: true, + ), + ); + final swapped = _painter(tester); + expect(swapped.staticMode, isTrue); + expect(swapped.resolver.breatheTimeOffset, 0); + expect(swapped.resolver.travelTimeOffset, 0); + expect(swapped.resolver.hueTimeOffset, 0); + }); + + testWidgets('changing a track period re-phases only that track', ( + tester, + ) async { + Widget build(double breatheFactor) => _host( + BorderBeam.line( + timing: BeamTiming( + cycle: const Duration(seconds: 2), + breatheFactor: breatheFactor, + ), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(1.3)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + final before = _painter(tester); + final was = before.resolver.sample(before.clock.elapsedSeconds, 1); + + // Documented contract: a period change re-phases its own track and + // leaves the timeline — and therefore every other track — alone. + await tester.pumpWidget(build(2.6)); + final after = _painter(tester); + expect(after.clock.elapsedSeconds, closeTo(0.9, 1e-9)); + final now = after.resolver.sample(after.clock.elapsedSeconds, 1); + expect(now.travelProgress, closeTo(was.travelProgress, 1e-9)); + expect(now.spike, closeTo(was.spike, 1e-9)); + expect(now.spike2, closeTo(was.spike2, 1e-9)); + expect(now.lineH, isNot(closeTo(was.lineH, 1e-6))); + }); + + testWidgets('adding a gap to a running beam needs no retime either', ( + tester, + ) async { + Widget build(Duration gap) => _host( + BorderBeam.rotate( + timing: BeamTiming(cycle: const Duration(seconds: 2), cycleGap: gap), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(Duration.zero)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + await tester.pumpWidget(build(const Duration(seconds: 1))); + expect(_painter(tester).clock.elapsedSeconds, closeTo(0.9, 1e-9)); + }); + }); + + group('the gap envelope', () { + // cycle 1s + gap 1s: the fade at each end is min(0.25, gap / 2) = 0.25s, + // and it is a smoothstep, so a quarter into the fade is 0.15625 and half + // way is exactly 0.5. + final resolver = _direct( + cycle: const Duration(seconds: 1), + gap: const Duration(seconds: 1), + ); + + test('it holds at 1 until the sweep ends', () { + expect(resolver.sample(0.999, 1).fadeOpacity, 1); + expect(resolver.sample(1, 1).fadeOpacity, closeTo(1, 1e-9)); + }); + + test('it eases 1 to 0 over the first quarter second of the rest', () { + expect(resolver.sample(1.0625, 1).fadeOpacity, closeTo(0.84375, 1e-9)); + expect(resolver.sample(1.125, 1).fadeOpacity, closeTo(0.5, 1e-9)); + expect(resolver.sample(1.1875, 1).fadeOpacity, closeTo(0.15625, 1e-9)); + expect(resolver.sample(1.25, 1).fadeOpacity, closeTo(0, 1e-9)); + }); + + test('it stays at 0 through the middle of the rest', () { + for (final t in [1.25, 1.4, 1.5, 1.6, 1.75]) { + expect(resolver.sample(t, 1).fadeOpacity, 0, reason: 't=$t'); + } + }); + + test('it eases 0 back to 1 over the last quarter second', () { + expect(resolver.sample(1.8125, 1).fadeOpacity, closeTo(0.15625, 1e-9)); + expect(resolver.sample(1.875, 1).fadeOpacity, closeTo(0.5, 1e-9)); + expect(resolver.sample(1.9375, 1).fadeOpacity, closeTo(0.84375, 1e-9)); + expect(resolver.sample(2, 1).fadeOpacity, closeTo(1, 1e-9)); + }); + + test('a 100ms gap fades for 50ms at each side', () { + final short = _direct( + cycle: const Duration(seconds: 1), + gap: const Duration(milliseconds: 100), + ); + expect(short.sample(1, 1).fadeOpacity, closeTo(1, 1e-9)); + expect(short.sample(1.025, 1).fadeOpacity, closeTo(0.5, 1e-9)); + expect( + short.sample(1.05, 1).fadeOpacity, + closeTo(0, 1e-9), + reason: 'the two fades meet in the middle, with no flat rest', + ); + expect(short.sample(1.075, 1).fadeOpacity, closeTo(0.5, 1e-9)); + expect(short.sample(1.1, 1).fadeOpacity, closeTo(1, 1e-9)); + }); + + test('it multiplies the beam own fade rather than replacing it', () { + expect(resolver.sample(1.125, 0.5).fadeOpacity, closeTo(0.25, 1e-9)); + expect(resolver.sample(0.5, 0.5).fadeOpacity, closeTo(0.5, 1e-9)); + }); + + test('the envelope repeats every cycle + gap', () { + for (final t in [0.3, 1.1, 1.5, 1.9]) { + expect( + resolver.sample(t + 2, 1).fadeOpacity, + closeTo(resolver.sample(t, 1).fadeOpacity, 1e-9), + reason: 't=$t', + ); + } + }); + }); + + group('the pulse variants ignore the gap', () { + for (final variant in [BeamVariant.pulseInside, BeamVariant.pulseOutside]) { + test('$variant breathes straight through it', () { + final gapped = _direct( + cycle: const Duration(seconds: 2), + gap: const Duration(seconds: 1), + variant: variant, + ); + final plain = _direct( + cycle: const Duration(seconds: 2), + gap: Duration.zero, + variant: variant, + ); + expect(gapped.config.gapSeconds, closeTo(1, 1e-9)); + + for (final t in [0.5, 2.0, 2.5, 3.0, 5.7]) { + final a = gapped.sample(t, 1); + final b = plain.sample(t, 1); + expect(a.fadeOpacity, 1, reason: 'no gap envelope at t=$t'); + expect(a.hueDegrees, closeTo(b.hueDegrees, 1e-9), reason: 't=$t'); + for (var i = 0; i < 3; i++) { + expect(a.pulse.bw[i], closeTo(b.pulse.bw[i], 1e-9), reason: 't=$t'); + expect(a.pulse.bh[i], closeTo(b.pulse.bh[i], 1e-9), reason: 't=$t'); + expect(a.pulse.bx[i], closeTo(b.pulse.bx[i], 1e-9), reason: 't=$t'); + expect(a.pulse.by[i], closeTo(b.pulse.by[i], 1e-9), reason: 't=$t'); + } + expect(a.pulse.bgh, closeTo(b.pulse.bgh, 1e-9), reason: 't=$t'); + expect(a.pulse.bopTl, closeTo(b.pulse.bopTl, 1e-9), reason: 't=$t'); + expect(a.pulse.bopBr, closeTo(b.pulse.bopBr, 1e-9), reason: 't=$t'); + } + }); + + test('$variant passes the beam fade straight through', () { + final gapped = _direct( + cycle: const Duration(seconds: 2), + gap: const Duration(seconds: 1), + variant: variant, + ); + expect(gapped.sample(2.5, 0.4).fadeOpacity, 0.4); + }); + } + }); +} diff --git a/test/widget/border_beam_lifecycle_test.dart b/test/widget/border_beam_lifecycle_test.dart new file mode 100644 index 0000000..e4fb865 --- /dev/null +++ b/test/widget/border_beam_lifecycle_test.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Lifecycle coverage for the widget's one-ticker-per-beam contract: every +/// path that ends a beam — disposal mid-fade, a variant swap that rebuilds +/// the clock, `TickerMode`, controller detach — must leave no scheduled +/// frame callback behind. +void main() { + Widget host(Widget child, {bool disableAnimations = false}) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + builder: (context, app) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: app!, + ), + home: Scaffold( + body: Center(child: SizedBox(width: 350, height: 140, child: child)), + ), + ); + + Widget beamFor( + BeamVariant variant, { + bool active = true, + Widget child = const SizedBox.expand(), + VoidCallback? onActivate, + BorderBeamController? controller, + }) => switch (variant) { + BeamVariant.rotate => BorderBeam.rotate( + active: active, + onActivate: onActivate, + controller: controller, + child: child, + ), + BeamVariant.small => BorderBeam.small( + active: active, + onActivate: onActivate, + controller: controller, + child: child, + ), + BeamVariant.line => BorderBeam.line( + active: active, + onActivate: onActivate, + controller: controller, + child: child, + ), + BeamVariant.pulseInside => BorderBeam.pulseInside( + active: active, + onActivate: onActivate, + controller: controller, + child: child, + ), + BeamVariant.pulseOutside => BorderBeam.pulseOutside( + active: active, + onActivate: onActivate, + controller: controller, + child: child, + ), + }; + + group('disposal', () { + // Pulse variants build their clock with an fps cap, so the tick path + // differs per family — every variant is checked. + for (final variant in BeamVariant.values) { + testWidgets('$variant disposed mid fade-in leaves no ticker', ( + tester, + ) async { + await tester.pumpWidget(host(beamFor(variant))); + await tester.pump(); + // 200ms into the 600ms fade-in. + await tester.pump(const Duration(milliseconds: 200)); + expect( + tester.binding.transientCallbackCount, + greaterThan(0), + reason: 'the beam should be ticking here', + ); + + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('$variant disposed mid fade-out leaves no ticker', ( + tester, + ) async { + await tester.pumpWidget(host(beamFor(variant))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + + await tester.pumpWidget(host(beamFor(variant, active: false))); + // 200ms into the 500ms fade-out. + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + expect(tester.binding.transientCallbackCount, 0); + }); + } + }); + + testWidgets('a variant swap rebuilds the clock without leaking the old one', ( + tester, + ) async { + // rotate runs uncapped, pulseInside caps at the source's ~30fps driver: + // the swap must replace the clock, not keep two of them running. + await tester.pumpWidget(host(beamFor(BeamVariant.rotate))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + await tester.pumpWidget(host(beamFor(BeamVariant.pulseInside))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.takeException(), isNull); + expect( + tester.binding.transientCallbackCount, + 1, + reason: 'exactly one clock ticks after the swap', + ); + + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('TickerMode pauses frames and resumes them', (tester) async { + var activated = 0; + Widget build(bool enabled) => host( + TickerMode( + enabled: enabled, + child: beamFor(BeamVariant.rotate, onActivate: () => activated++), + ), + ); + + await tester.pumpWidget(build(false)); + await tester.pump(const Duration(milliseconds: 800)); + expect(tester.binding.hasScheduledFrame, isFalse); + expect(activated, 0, reason: 'a muted clock never completes its fade-in'); + + await tester.pumpWidget(build(true)); + await tester.pump(); + expect(tester.binding.hasScheduledFrame, isTrue); + await tester.pump(const Duration(milliseconds: 700)); + expect(activated, 1); + // Still animating after the fade finished. + expect(tester.binding.hasScheduledFrame, isTrue); + }); + + testWidgets('reduced-motion static frames dispose without a ticker', ( + tester, + ) async { + await tester.pumpWidget( + host(beamFor(BeamVariant.rotate), disableAnimations: true), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + expect(tester.takeException(), isNull); + expect(tester.binding.transientCallbackCount, 0); + + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('a controller outliving its beam is an inert no-op', ( + tester, + ) async { + final controller = BorderBeamController(); + await tester.pumpWidget( + host(beamFor(BeamVariant.rotate, controller: controller)), + ); + controller.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.isAttached, isTrue); + expect(controller.isRunning, isTrue); + + // Disposed mid fade-in, with the controller still referenced. + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + expect(controller.isAttached, isFalse); + expect(controller.isActive, isFalse); + expect(controller.isRunning, isFalse); + + controller.start(); + controller.resume(); + controller.seek(const Duration(seconds: 1)); + controller.speed = 2; + controller.stop(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('replacing the child keeps the beam painting', (tester) async { + Widget build(String label) => + host(beamFor(BeamVariant.rotate, child: Text(label))); + + await tester.pumpWidget(build('before')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + + await tester.pumpWidget(build('after')); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.text('before'), findsNothing); + expect(find.text('after'), findsOneWidget); + expect(tester.takeException(), isNull); + // The clock survives a child swap: no restart, no stall. + expect(tester.binding.hasScheduledFrame, isTrue); + + // The child keeps its own repaint boundary, so beam frames never + // re-rasterize it: one boundary around the CustomPaint, one around the + // child itself. + expect( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(RepaintBoundary), + ), + findsNWidgets(2), + ); + expect( + find.ancestor( + of: find.text('after'), + matching: find.byType(RepaintBoundary), + ), + findsAtLeastNWidgets(2), + ); + }); +} diff --git a/test/widget/border_beam_test.dart b/test/widget/border_beam_test.dart index 5601187..e8b5012 100644 --- a/test/widget/border_beam_test.dart +++ b/test/widget/border_beam_test.dart @@ -1,7 +1,9 @@ -import 'package:border_beam/border_beam.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'test_utils.dart'; + Widget _host(Widget child, {bool disableAnimations = false}) => MaterialApp( theme: ThemeData(brightness: Brightness.dark), builder: (context, app) => MediaQuery( @@ -74,7 +76,7 @@ void main() { await tester.pumpWidget( _host( BorderBeam.rotate( - startAfter: const Duration(seconds: 2), + playback: const BeamPlayback(startAfter: Duration(seconds: 2)), onActivate: () => activated++, child: const SizedBox.expand(), ), @@ -94,7 +96,7 @@ void main() { await tester.pumpWidget( _host( BorderBeam.rotate( - duration: const Duration(seconds: 2), + playback: const BeamPlayback(duration: Duration(seconds: 2)), onDeactivate: () => deactivated++, child: const SizedBox.expand(), ), @@ -113,7 +115,10 @@ void main() { testWidgets('autoPlay: false never starts by itself', (tester) async { await tester.pumpWidget( _host( - const BorderBeam.pulseInside(autoPlay: false, child: SizedBox.expand()), + const BorderBeam.pulseInside( + playback: BeamPlayback(autoPlay: false), + child: SizedBox.expand(), + ), ), ); await tester.pump(const Duration(seconds: 1)); @@ -153,25 +158,39 @@ void main() { }); group('controller', () { - testWidgets('asserts when startAfter/duration are set with a controller', ( + testWidgets('asserts when startAfter is set with a controller', ( tester, ) async { final controller = BorderBeamController(); - expect( - () => BorderBeam.rotate( - controller: controller, - startAfter: const Duration(seconds: 1), - child: const SizedBox.expand(), + addTearDown(controller.dispose); + await pumpExpectingAssertion( + tester, + _host( + BorderBeam.rotate( + controller: controller, + playback: const BeamPlayback(startAfter: Duration(seconds: 1)), + child: const SizedBox.expand(), + ), ), - throwsAssertionError, + message: 'When a BorderBeamController is attached it owns playback', ); - expect( - () => BorderBeam.rotate( - controller: controller, - duration: const Duration(seconds: 1), - child: const SizedBox.expand(), + }); + + testWidgets('asserts when duration is set with a controller', ( + tester, + ) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + await pumpExpectingAssertion( + tester, + _host( + BorderBeam.rotate( + controller: controller, + playback: const BeamPlayback(duration: Duration(seconds: 1)), + child: const SizedBox.expand(), + ), ), - throwsAssertionError, + message: 'When a BorderBeamController is attached it owns playback', ); }); diff --git a/test/widget/border_beam_theme_test.dart b/test/widget/border_beam_theme_test.dart new file mode 100644 index 0000000..1262f70 --- /dev/null +++ b/test/widget/border_beam_theme_test.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_clock.dart'; +import 'package:flutter_border_beam/src/models/beam_config.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_utils.dart'; + +Widget _host(Widget child, {bool disableAnimations = false}) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + builder: (context, app) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: disableAnimations), + child: app!, + ), + home: Scaffold( + body: Center(child: SizedBox(width: 350, height: 140, child: child)), + ), +); + +BeamPainter _painter(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .first; + +BeamConfig _config(WidgetTester tester) => _painter(tester).config; + +BeamClock _clock(WidgetTester tester) => _painter(tester).clock; + +/// A field left null on a beam falls through to the nearest +/// [BorderBeamTheme], then to the variant preset; a value set on the beam +/// always wins. +void main() { + testWidgets('a theme supplies defaults the beam leaves null', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData( + style: BeamStyle(colors: BeamColors.ocean, strength: 0.4), + shape: BeamShape(borderWidth: 3), + timing: BeamTiming(cycle: Duration(seconds: 4)), + ), + child: BorderBeam.rotate(child: SizedBox.expand()), + ), + ), + ); + final config = _config(tester); + expect(identical(config.palette, BeamColors.ocean.resolve()), isTrue); + expect(config.strength, 0.4); + expect(config.borderWidth, 3); + expect(config.cycleSeconds, closeTo(4, 1e-9)); + }); + + testWidgets('a shorthand on the beam wins over the theme', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData(style: BeamStyle(colors: BeamColors.ocean)), + child: BorderBeam.rotate( + colors: BeamColors.sunset, + child: SizedBox.expand(), + ), + ), + ), + ); + expect( + identical(_config(tester).palette, BeamColors.sunset.resolve()), + isTrue, + ); + }); + + testWidgets('nested themes merge, inner over outer', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData( + style: BeamStyle(colors: BeamColors.ocean, strength: 0.5), + shape: BeamShape(borderWidth: 2), + ), + child: BorderBeamTheme( + data: BorderBeamThemeData(style: BeamStyle(strength: 0.9)), + child: BorderBeam.rotate(child: SizedBox.expand()), + ), + ), + ), + ); + final config = _config(tester); + expect( + identical(config.palette, BeamColors.ocean.resolve()), + isTrue, + reason: 'the outer theme still supplies colors', + ); + expect(config.strength, 0.9, reason: 'the inner theme wins'); + expect(config.borderWidth, 2, reason: 'slots the inner theme omits'); + }); + + testWidgets('changing an outer theme rebuilds beams under an inner one', ( + tester, + ) async { + Widget build(BeamColors colors) => _host( + BorderBeamTheme( + data: BorderBeamThemeData(style: BeamStyle(colors: colors)), + child: const BorderBeamTheme( + data: BorderBeamThemeData(style: BeamStyle(strength: 0.7)), + child: BorderBeam.rotate(child: SizedBox.expand()), + ), + ), + ); + + await tester.pumpWidget(build(BeamColors.ocean)); + expect( + identical(_config(tester).palette, BeamColors.ocean.resolve()), + isTrue, + ); + + await tester.pumpWidget(build(BeamColors.sunset)); + expect( + identical(_config(tester).palette, BeamColors.sunset.resolve()), + isTrue, + ); + }); + + testWidgets('a theme playback keeps a beam idle', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData(playback: BeamPlayback(autoPlay: false)), + child: BorderBeam.rotate(child: SizedBox.expand()), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + group('playback through a theme', () { + testWidgets('an active shorthand still respects a theme autoPlay: false', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData(playback: BeamPlayback(autoPlay: false)), + child: BorderBeam.rotate(active: true, child: SizedBox.expand()), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + expect( + _clock(tester).isVisible, + isFalse, + reason: 'active says what to play, autoPlay says whether to start it', + ); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('a beam autoPlay: true overrides a theme autoPlay: false', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData(playback: BeamPlayback(autoPlay: false)), + child: BorderBeam.rotate( + playback: BeamPlayback(autoPlay: true), + child: SizedBox.expand(), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(_clock(tester).isVisible, isTrue); + }); + + testWidgets('a theme startAfter delays the start', (tester) async { + var activated = 0; + await tester.pumpWidget( + _host( + BorderBeamTheme( + data: const BorderBeamThemeData( + playback: BeamPlayback(startAfter: Duration(seconds: 2)), + ), + child: BorderBeam.rotate( + onActivate: () => activated++, + child: const SizedBox.expand(), + ), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + expect(activated, 0); + expect(_clock(tester).isVisible, isFalse); + expect(tester.binding.hasScheduledFrame, isFalse); + + await tester.pump(const Duration(seconds: 1, milliseconds: 100)); + await tester.pump(); + expect(_clock(tester).isVisible, isTrue); + await tester.pump(const Duration(milliseconds: 700)); + expect(activated, 1); + }); + + testWidgets('a beam startAfter overrides a theme one', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData( + playback: BeamPlayback(startAfter: Duration(seconds: 5)), + ), + child: BorderBeam.rotate( + playback: BeamPlayback(startAfter: Duration(milliseconds: 100)), + child: SizedBox.expand(), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 150)); + await tester.pump(); + expect(_clock(tester).isVisible, isTrue); + }); + + testWidgets('a theme duration bounds the total play time', (tester) async { + var deactivated = 0; + await tester.pumpWidget( + _host( + BorderBeamTheme( + data: const BorderBeamThemeData( + playback: BeamPlayback(duration: Duration(seconds: 1)), + ), + child: BorderBeam.rotate( + onDeactivate: () => deactivated++, + child: const SizedBox.expand(), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1100)); + await tester.pump(const Duration(milliseconds: 600)); + expect(deactivated, 1); + }); + + testWidgets('a theme reducedMotion: animate keeps the beam moving', ( + tester, + ) async { + await tester.pumpWidget( + _host( + disableAnimations: true, + const BorderBeamTheme( + data: BorderBeamThemeData( + playback: BeamPlayback(reducedMotion: BeamReducedMotion.animate), + ), + child: BorderBeam.rotate(child: SizedBox.expand()), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(_clock(tester).isRunning, isTrue); + }); + + testWidgets('a controller with a theme startAfter asserts', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + await pumpExpectingAssertion( + tester, + _host( + BorderBeamTheme( + data: const BorderBeamThemeData( + playback: BeamPlayback(startAfter: Duration(seconds: 1)), + ), + child: BorderBeam.rotate( + controller: controller, + child: const SizedBox.expand(), + ), + ), + ), + message: 'When a BorderBeamController is attached it owns playback', + ); + }); + + testWidgets('a controller with a theme duration asserts', (tester) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + await pumpExpectingAssertion( + tester, + _host( + BorderBeamTheme( + data: const BorderBeamThemeData( + playback: BeamPlayback(duration: Duration(seconds: 1)), + ), + child: BorderBeam.rotate( + controller: controller, + child: const SizedBox.expand(), + ), + ), + ), + message: 'When a BorderBeamController is attached it owns playback', + ); + }); + + testWidgets('an outer theme startAfter reaches a beam under an inner ' + 'theme', (tester) async { + await tester.pumpWidget( + _host( + const BorderBeamTheme( + data: BorderBeamThemeData( + playback: BeamPlayback(startAfter: Duration(seconds: 2)), + ), + child: BorderBeamTheme( + data: BorderBeamThemeData(style: BeamStyle(strength: 0.5)), + child: BorderBeam.rotate(child: SizedBox.expand()), + ), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + expect(_clock(tester).isVisible, isFalse); + await tester.pump(const Duration(seconds: 1, milliseconds: 100)); + await tester.pump(); + expect(_clock(tester).isVisible, isTrue); + }); + }); +} diff --git a/test/widget/border_beam_update_test.dart b/test/widget/border_beam_update_test.dart new file mode 100644 index 0000000..fb96cc0 --- /dev/null +++ b/test/widget/border_beam_update_test.dart @@ -0,0 +1,561 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_border_beam/src/animation/beam_clock.dart'; +import 'package:flutter_border_beam/src/animation/beam_phases.dart'; +import 'package:flutter_border_beam/src/painting/beam_painter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child, {bool disableAnimations = false}) => MaterialApp( + theme: ThemeData(brightness: Brightness.dark), + builder: (context, app) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: disableAnimations), + child: app!, + ), + home: Scaffold( + body: Center(child: SizedBox(width: 350, height: 140, child: child)), + ), +); + +BeamPainter _beamPainter(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BorderBeam), + matching: find.byType(CustomPaint), + ), + ) + .expand((paint) => [paint.painter, paint.foregroundPainter]) + .whereType() + .first; + +// The frame the painter would draw right now. +({BeamPainter painter, BeamFramePhases phases}) _frame(WidgetTester tester) { + final painter = _beamPainter(tester); + return ( + painter: painter, + phases: painter.resolver.sample( + painter.clock.elapsedSeconds, + painter.clock.fadeOpacity, + ), + ); +} + +void main() { + group('cycleDuration change', () { + testWidgets('retimes the rotate beam without a phase jump', (tester) async { + Widget build(Duration cycle) => _host( + BorderBeam.rotate( + timing: BeamTiming(cycle: cycle), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(const Duration(seconds: 2))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + final before = _frame(tester); + expect(before.painter.clock.elapsedSeconds, closeTo(0.9, 1e-9)); + + await tester.pumpWidget(build(const Duration(seconds: 4))); + final after = _frame(tester); + + expect( + after.painter.config.cycleSeconds, + closeTo(4, 1e-9), + reason: 'the new cycle is in effect', + ); + expect( + after.painter.clock.elapsedSeconds, + closeTo(1.8, 1e-9), + reason: 'elapsed time is rescaled by the cycle ratio', + ); + expect( + after.phases.angleRadians, + closeTo(before.phases.angleRadians, 1e-6), + ); + expect(after.phases.hueDegrees, closeTo(before.phases.hueDegrees, 1e-6)); + }); + + testWidgets('retimes the line beam without a phase jump', (tester) async { + Widget build(Duration cycle) => _host( + BorderBeam.line( + timing: BeamTiming(cycle: cycle), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(const Duration(seconds: 3))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1400)); + final before = _frame(tester); + + await tester.pumpWidget(build(const Duration(milliseconds: 1500))); + final after = _frame(tester); + + expect(after.phases.lineX, closeTo(before.phases.lineX, 1e-6)); + expect(after.phases.lineW, closeTo(before.phases.lineW, 1e-6)); + expect(after.phases.lineH, closeTo(before.phases.lineH, 1e-6)); + expect(after.phases.spike, closeTo(before.phases.spike, 1e-6)); + expect(after.phases.edge, closeTo(before.phases.edge, 1e-6)); + expect(after.phases.hueDegrees, closeTo(before.phases.hueDegrees, 1e-6)); + expect( + after.phases.bloomHueDegrees, + closeTo(before.phases.bloomHueDegrees, 1e-6), + ); + }); + + testWidgets('keeps every pulse oscillator continuous', (tester) async { + Widget build(Duration cycle) => _host( + BorderBeam.pulseInside( + timing: BeamTiming(cycle: cycle), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(const Duration(milliseconds: 2300))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1700)); + final before = _frame(tester); + + await tester.pumpWidget(build(const Duration(seconds: 5))); + final after = _frame(tester); + + for (var i = 0; i < 3; i++) { + expect( + after.phases.pulse.bw[i], + closeTo(before.phases.pulse.bw[i], 1e-6), + ); + expect( + after.phases.pulse.bh[i], + closeTo(before.phases.pulse.bh[i], 1e-6), + ); + expect( + after.phases.pulse.bx[i], + closeTo(before.phases.pulse.bx[i], 1e-6), + ); + expect( + after.phases.pulse.by[i], + closeTo(before.phases.pulse.by[i], 1e-6), + ); + } + expect(after.phases.pulse.bgh, closeTo(before.phases.pulse.bgh, 1e-6)); + expect( + after.phases.pulse.bopTl, + closeTo(before.phases.pulse.bopTl, 1e-6), + ); + expect( + after.phases.pulse.bopBr, + closeTo(before.phases.pulse.bopBr, 1e-6), + ); + expect(after.phases.hueDegrees, closeTo(before.phases.hueDegrees, 1e-6)); + }); + + testWidgets('keeps the fade envelope continuous mid fade-in', ( + tester, + ) async { + var activated = 0; + Widget build(Duration cycle) => _host( + BorderBeam.rotate( + timing: BeamTiming(cycle: cycle), + onActivate: () => activated++, + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(const Duration(seconds: 2))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + final before = _beamPainter(tester).clock; + final opacity = before.fadeOpacity; + expect(opacity, greaterThan(0)); + expect(opacity, lessThan(1)); + expect(before.stage, BeamFadeStage.fadingIn); + + await tester.pumpWidget(build(const Duration(seconds: 6))); + final after = _beamPainter(tester).clock; + expect(after.fadeOpacity, closeTo(opacity, 1e-6)); + expect(after.stage, BeamFadeStage.fadingIn); + + // The remaining fade time is unchanged too: 0.45s left of the 0.6s + // fade-in, not 0.45s scaled by the cycle ratio. + await tester.pump(const Duration(milliseconds: 400)); + expect(activated, 0); + await tester.pump(const Duration(milliseconds: 100)); + expect(activated, 1); + expect(after.fadeOpacity, 1); + }); + + testWidgets('an unchanged cycle leaves the timeline alone', (tester) async { + Widget build(double strength) => _host( + BorderBeam.rotate( + style: BeamStyle(strength: strength), + timing: const BeamTiming(cycle: Duration(seconds: 2)), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(1)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + await tester.pumpWidget(build(0.5)); + expect(_beamPainter(tester).clock.elapsedSeconds, closeTo(0.9, 1e-9)); + }); + + testWidgets('retiming during a gap preserves the gap phase', ( + tester, + ) async { + Widget build(Duration cycle) => _host( + BorderBeam.rotate( + timing: BeamTiming( + cycle: cycle, + cycleGap: const Duration(seconds: 1), + ), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(const Duration(seconds: 2))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2500)); + final before = _frame(tester); + expect(before.phases.travelProgress, 1); + expect(before.phases.fadeOpacity, 0); + + await tester.pumpWidget(build(const Duration(seconds: 4))); + final after = _frame(tester); + expect(after.painter.clock.elapsedSeconds, closeTo(4.5, 1e-9)); + expect(after.phases.travelProgress, 1); + expect( + after.phases.fadeOpacity, + closeTo(before.phases.fadeOpacity, 1e-9), + ); + }); + }); + + group('reduced motion', () { + testWidgets('turning it off starts a beam that never got to start', ( + tester, + ) async { + Widget build(bool reduced) => _host( + disableAnimations: reduced, + const BorderBeam.rotate(child: SizedBox.expand()), + ); + + await tester.pumpWidget(build(true)); + await tester.pump(const Duration(milliseconds: 100)); + expect(_beamPainter(tester).clock.isVisible, isFalse); + expect(tester.binding.hasScheduledFrame, isFalse); + + await tester.pumpWidget(build(false)); + expect(_beamPainter(tester).clock.isVisible, isTrue); + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.binding.hasScheduledFrame, isTrue); + expect(_beamPainter(tester).clock.isRunning, isTrue); + }); + + testWidgets('turning it off respects a pending startAfter delay', ( + tester, + ) async { + Widget build(bool reduced) => _host( + disableAnimations: reduced, + const BorderBeam.rotate( + playback: BeamPlayback(startAfter: Duration(seconds: 2)), + child: SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(true)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpWidget(build(false)); + expect(_beamPainter(tester).clock.isVisible, isFalse); + await tester.pump(const Duration(seconds: 2)); + await tester.pump(); + expect(_beamPainter(tester).clock.isVisible, isTrue); + }); + + testWidgets('toggling it on pauses and off resumes a running beam', ( + tester, + ) async { + Widget build(bool reduced) => _host( + disableAnimations: reduced, + const BorderBeam.rotate(child: SizedBox.expand()), + ); + + await tester.pumpWidget(build(false)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 800)); + final clock = _beamPainter(tester).clock; + expect(clock.isRunning, isTrue); + final frozenAt = clock.elapsedSeconds; + + await tester.pumpWidget(build(true)); + expect(clock.isRunning, isFalse); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.binding.hasScheduledFrame, isFalse); + expect(clock.elapsedSeconds, closeTo(frozenAt, 1e-9)); + expect(clock.isVisible, isTrue, reason: 'the frame stays on screen'); + + await tester.pumpWidget(build(false)); + expect(clock.isRunning, isTrue); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + clock.elapsedSeconds, + greaterThan(frozenAt), + reason: 'the timeline continues where it was frozen', + ); + }); + + testWidgets('turning it off does not replay a beam that already ran', ( + tester, + ) async { + var deactivated = 0; + Widget build(bool reduced) => _host( + disableAnimations: reduced, + BorderBeam.rotate( + playback: const BeamPlayback(duration: Duration(seconds: 1)), + onDeactivate: () => deactivated++, + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(false)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1100)); + await tester.pump(const Duration(milliseconds: 600)); + expect(deactivated, 1); + expect(_beamPainter(tester).clock.isVisible, isFalse); + + await tester.pumpWidget(build(true)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpWidget(build(false)); + await tester.pump(const Duration(milliseconds: 50)); + expect( + _beamPainter(tester).clock.isVisible, + isFalse, + reason: 'its play time is spent', + ); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('turning it off does not resume a controller-paused beam', ( + tester, + ) async { + final controller = BorderBeamController(); + addTearDown(controller.dispose); + Widget build(bool reduced) => _host( + disableAnimations: reduced, + BorderBeam.rotate( + controller: controller, + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(false)); + controller.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + expect(controller.isRunning, isTrue); + controller.pause(); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.isRunning, isFalse); + + await tester.pumpWidget(build(true)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpWidget(build(false)); + await tester.pump(const Duration(milliseconds: 50)); + expect( + controller.isRunning, + isFalse, + reason: 'the controller owns the pause', + ); + expect(controller.isActive, isTrue); + }); + }); + + group('widget updates', () { + testWidgets('enabling autoPlay applies startAfter from the new schedule', ( + tester, + ) async { + Widget build(bool autoPlay) => _host( + BorderBeam.rotate( + playback: BeamPlayback( + autoPlay: autoPlay, + startAfter: const Duration(milliseconds: 500), + ), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(false)); + await tester.pump(const Duration(seconds: 1)); + expect(_beamPainter(tester).clock.isVisible, isFalse); + + await tester.pumpWidget(build(true)); + await tester.pump(const Duration(milliseconds: 400)); + expect(_beamPainter(tester).clock.isVisible, isFalse); + await tester.pump(const Duration(milliseconds: 150)); + expect(_beamPainter(tester).clock.isVisible, isTrue); + }); + + testWidgets('changing a pending startAfter reschedules the start', ( + tester, + ) async { + Widget build(Duration delay) => _host( + BorderBeam.rotate( + playback: BeamPlayback(startAfter: delay), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(const Duration(seconds: 2))); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpWidget(build(const Duration(milliseconds: 100))); + await tester.pump(const Duration(milliseconds: 150)); + expect(_beamPainter(tester).clock.isVisible, isTrue); + }); + + testWidgets('changing duration starts a fresh budget from the update', ( + tester, + ) async { + Widget build(Duration? duration) => _host( + BorderBeam.rotate( + playback: BeamPlayback(duration: duration), + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(null)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 800)); + final clock = _beamPainter(tester).clock; + + await tester.pumpWidget(build(const Duration(seconds: 1))); + await tester.pump(const Duration(milliseconds: 900)); + expect(clock.stage, isNot(BeamFadeStage.fadingOut)); + await tester.pump(const Duration(milliseconds: 150)); + expect(clock.stage, BeamFadeStage.fadingOut); + }); + + testWidgets('a variant swap keeps visibility and restarts the clock', ( + tester, + ) async { + Widget build(bool pulse) => _host( + pulse + ? const BorderBeam.pulseInside(child: SizedBox.expand()) + : const BorderBeam.rotate(child: SizedBox.expand()), + ); + + await tester.pumpWidget(build(false)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + final first = _beamPainter(tester).clock; + expect(first.elapsedSeconds, closeTo(0.9, 1e-9)); + + await tester.pumpWidget(build(true)); + final second = _beamPainter(tester).clock; + expect(identical(second, first), isFalse, reason: 'a fresh clock'); + expect(second.isVisible, isTrue); + expect(second.elapsedSeconds, 0); + await tester.pump(const Duration(milliseconds: 50)); + expect(second.isRunning, isTrue); + }); + + testWidgets('a controller swap detaches the old and attaches the new', ( + tester, + ) async { + final first = BorderBeamController(); + final second = BorderBeamController(); + addTearDown(first.dispose); + addTearDown(second.dispose); + Widget build(BorderBeamController? controller) => _host( + BorderBeam.rotate( + controller: controller, + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(first)); + expect(first.isAttached, isTrue); + + await tester.pumpWidget(build(second)); + expect(first.isAttached, isFalse); + expect(second.isAttached, isTrue); + + second.start(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + expect(second.isActive, isTrue); + expect(_beamPainter(tester).clock.isVisible, isTrue); + + await tester.pumpWidget(build(null)); + expect(second.isAttached, isFalse); + }); + + testWidgets('re-activating mid fade-out resumes from the current opacity', ( + tester, + ) async { + var activated = 0; + var deactivated = 0; + Widget build(bool active) => _host( + BorderBeam.rotate( + active: active, + onActivate: () => activated++, + onDeactivate: () => deactivated++, + child: const SizedBox.expand(), + ), + ); + + await tester.pumpWidget(build(true)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + final clock = _beamPainter(tester).clock; + expect(clock.stage, BeamFadeStage.fadingIn); + + await tester.pumpWidget(build(false)); + await tester.pump(const Duration(milliseconds: 80)); + expect(clock.stage, BeamFadeStage.fadingOut); + final mid = clock.fadeOpacity; + expect(mid, greaterThan(0)); + expect(mid, lessThan(1)); + final elapsed = clock.elapsedSeconds; + + await tester.pumpWidget(build(true)); + expect(clock.stage, BeamFadeStage.fadingIn); + expect( + clock.fadeOpacity, + closeTo(mid, 1e-9), + reason: 'no jump back to zero', + ); + expect( + clock.elapsedSeconds, + closeTo(elapsed, 1e-9), + reason: 'a mid-fade re-activation keeps the timeline', + ); + + await tester.pump(const Duration(milliseconds: 700)); + expect(activated, 1); + expect(deactivated, 0); + expect(clock.fadeOpacity, 1); + }); + + testWidgets('a colors change resolves a new config exactly once', ( + tester, + ) async { + Widget build(BeamColors colors) => _host( + BorderBeam.rotate(colors: colors, child: const SizedBox.expand()), + ); + + await tester.pumpWidget(build(BeamColors.ocean)); + final ocean = _beamPainter(tester).config; + + await tester.pumpWidget(build(BeamColors.sunset)); + final sunset = _beamPainter(tester).config; + expect(identical(sunset, ocean), isFalse); + expect(identical(sunset.palette, BeamColors.sunset.resolve()), isTrue); + + // Rebuilding with the same colors reuses the resolved config. + await tester.pumpWidget(build(BeamColors.sunset)); + expect(identical(_beamPainter(tester).config, sunset), isTrue); + }); + }); +} diff --git a/test/widget/test_utils.dart b/test/widget/test_utils.dart new file mode 100644 index 0000000..cc885a5 --- /dev/null +++ b/test/widget/test_utils.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Pumps [app] and expects the framework to report an [AssertionError] whose +/// message contains [message]. +/// +/// A widget-level `assert` is reported once per rebuild attempt, and how many +/// attempts a broken build gets varies by Flutter version — one on 3.44, three +/// on the 3.35 floor this package supports. `tester.takeException()` collapses +/// anything past the first into a "Multiple exceptions" [FlutterError], so it +/// cannot reach the assert itself. This installs its own [FlutterError.onError] +/// around the pump, collects every report, and matches the one carrying +/// [message]: the count of reports is the framework's business, the content is +/// what the test pins. +/// +/// The tree is torn down inside that window too, so the follow-on framework +/// errors an unmount of a half-built tree can raise are captured rather than +/// surfacing later as an unexpected exception. Flutter 3.35 cannot always +/// finish that unmount — `InheritedElement.unmount` asserts on its dependents — +/// so a widget that starts a ticker on mount can outlive the pump and count +/// against the next test in the file; pump such a widget in a state that starts +/// no ticker. +Future pumpExpectingAssertion( + WidgetTester tester, + Widget app, { + required String message, +}) async { + final reported = []; + final previousOnError = FlutterError.onError; + void capture(FlutterErrorDetails details) => reported.add(details.exception); + // Restored below so the rest of the test sees the default reporter again; + // the tear-down is the safety net for an exception thrown past the `finally`. + addTearDown(() => FlutterError.onError = previousOnError); + FlutterError.onError = capture; + try { + await tester.pumpWidget(app); + await tester.pumpWidget(const SizedBox.shrink()); + } finally { + FlutterError.onError = previousOnError; + } + + expect( + reported.whereType().map((error) => '${error.message}'), + contains(contains(message)), + reason: + 'no reported assertion carried the expected message; ' + 'reported: $reported', + ); +} diff --git a/test/widgets/beam_decoration_test.dart b/test/widgets/beam_decoration_test.dart new file mode 100644 index 0000000..f78d09b --- /dev/null +++ b/test/widgets/beam_decoration_test.dart @@ -0,0 +1,449 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// [BeamDecoration] runs the beam engine from a [BoxPainter] instead of a +/// widget: it owns a raw [Ticker], repaints its box through `onChanged`, and +/// must give that ticker back when the render object drops it. +void main() { + const boundaryKey = ValueKey('boundary'); + + Widget scene(Decoration decoration, {bool foreground = true}) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: Brightness.dark), + home: Center( + child: RepaintBoundary( + key: boundaryKey, + child: Container( + width: 350, + height: 140, + foregroundDecoration: foreground ? decoration : null, + decoration: foreground ? null : decoration, + ), + ), + ), + ); + + // Non-transparent pixels inside the boundary. Nothing else in the scene + // paints, so a count above zero is the beam and only the beam. + Future paintedPixels(WidgetTester tester) async { + final boundary = tester.renderObject( + find.byKey(boundaryKey), + ); + var painted = 0; + await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + image.dispose(); + for (var i = 3; i < bytes!.lengthInBytes; i += 4) { + if (bytes.getUint8(i) != 0) painted++; + } + }); + return painted; + } + + group('painting', () { + testWidgets('paints the beam through a DecoratedBox', (tester) async { + await tester.pumpWidget( + scene( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + borderRadius: 16, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + + expect(await paintedPixels(tester), greaterThan(0)); + }); + + testWidgets('paints nothing while inactive', (tester) async { + await tester.pumpWidget( + scene( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + active: false, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + + expect(await paintedPixels(tester), 0); + }); + + testWidgets('paints every variant, in the slot its docs name', ( + tester, + ) async { + for (final variant in BeamVariant.values) { + // pulse-outside blooms behind the child; every other variant paints + // over it. Both land in whichever slot the decoration is given. + final foreground = variant != BeamVariant.pulseOutside; + await tester.pumpWidget( + scene( + BeamDecoration(variant: variant, brightness: Brightness.dark), + foreground: foreground, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + + expect( + await paintedPixels(tester), + greaterThan(0), + reason: '$variant painted nothing', + ); + await tester.pumpWidget(const SizedBox()); + } + }); + + testWidgets('holds the beam back until startAfter elapses', (tester) async { + await tester.pumpWidget( + scene( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + playback: BeamPlayback(startAfter: Duration(seconds: 1)), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(await paintedPixels(tester), 0); + + await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(const Duration(milliseconds: 600)); + expect(await paintedPixels(tester), greaterThan(0)); + }); + }); + + group('ticking', () { + testWidgets('calls onChanged on every clock tick', (tester) async { + var changes = 0; + final painter = const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + ).createBoxPainter(() => changes++); + + // The first tick after Ticker.start() reports elapsed 0; pump once + // before pumping durations. + await tester.pump(); + expect(changes, greaterThan(0)); + + final afterFirst = changes; + await tester.pump(const Duration(milliseconds: 16)); + await tester.pump(const Duration(milliseconds: 16)); + expect(changes, greaterThan(afterFirst)); + painter.dispose(); + }); + + testWidgets('an inactive decoration never ticks', (tester) async { + var changes = 0; + final painter = const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + playback: BeamPlayback(autoPlay: false), + ).createBoxPainter(() => changes++); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(changes, 0); + expect(tester.binding.transientCallbackCount, 0); + painter.dispose(); + }); + + testWidgets('gives its ticker back when the render object goes', ( + tester, + ) async { + await tester.pumpWidget( + scene( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + ), + ), + ); + await tester.pump(); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpWidget(const SizedBox()); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('disposing the painter mid-fade leaves no ticker', ( + tester, + ) async { + final painter = const BeamDecoration( + variant: BeamVariant.pulseInside, + brightness: Brightness.light, + ).createBoxPainter(() {}); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + painter.dispose(); + expect(tester.binding.transientCallbackCount, 0); + }); + + testWidgets('swapping decorations keeps exactly one ticker', ( + tester, + ) async { + await tester.pumpWidget( + scene( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + ), + ), + ); + await tester.pump(); + final before = tester.binding.transientCallbackCount; + + await tester.pumpWidget( + scene( + const BeamDecoration( + variant: BeamVariant.line, + brightness: Brightness.dark, + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + + expect(tester.binding.transientCallbackCount, before); + expect(await paintedPixels(tester), greaterThan(0)); + }); + }); + + group('value semantics', () { + const base = BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + colors: BeamColors.ocean, + borderRadius: 16, + ); + + test('equal field-for-field', () { + const same = BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + colors: BeamColors.ocean, + borderRadius: 16, + ); + expect(base, equals(same)); + expect(base.hashCode, same.hashCode); + }); + + test('each field participates', () { + expect( + base, + isNot( + const BeamDecoration( + variant: BeamVariant.line, + brightness: Brightness.dark, + colors: BeamColors.ocean, + borderRadius: 16, + ), + ), + ); + expect( + base, + isNot( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.light, + colors: BeamColors.ocean, + borderRadius: 16, + ), + ), + ); + expect( + base, + isNot( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + colors: BeamColors.sunset, + borderRadius: 16, + ), + ), + ); + expect( + base, + isNot( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + colors: BeamColors.ocean, + borderRadius: 24, + ), + ), + ); + expect( + base, + isNot( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + colors: BeamColors.ocean, + borderRadius: 16, + timing: BeamTiming(speed: 2), + ), + ), + ); + expect( + base, + isNot( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + colors: BeamColors.ocean, + borderRadius: 16, + theme: BorderBeamThemeData(style: BeamStyle(strength: 0.5)), + ), + ), + ); + expect( + base, + isNot( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + colors: BeamColors.ocean, + borderRadius: 16, + active: false, + ), + ), + ); + expect( + base, + isNot( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + colors: BeamColors.ocean, + borderRadius: 16, + style: BeamStyle(sparkle: 0.5), + ), + ), + ); + expect( + base, + isNot( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + colors: BeamColors.ocean, + borderRadius: 16, + shape: BeamShape(borderWidth: 2), + ), + ), + ); + expect( + base, + isNot( + const BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + colors: BeamColors.ocean, + borderRadius: 16, + playback: BeamPlayback(autoPlay: false), + ), + ), + ); + }); + + test('does not interpolate between two beam decorations', () { + const other = BeamDecoration( + variant: BeamVariant.line, + brightness: Brightness.dark, + ); + // lerp falls back to the halfway snap Decoration.lerp does when + // neither side can interpolate. + expect(Decoration.lerp(base, other, 0.25), same(base)); + expect(Decoration.lerp(base, other, 0.75), same(other)); + }); + + testWidgets('never absorbs a pointer', (tester) async { + var taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 200, + height: 80, + child: Stack( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + ), + // Laid over the target. A Decoration whose hitTest returns + // true would swallow the tap here. + const Positioned.fill( + child: DecoratedBox( + decoration: BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + ), + ), + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.byType(Stack)); + expect(taps, 1); + }); + + test('shorthands fold into the value objects for diagnostics', () { + final description = base.toDiagnosticsNode().toStringDeep(); + expect(description, contains('variant: rotate')); + expect(description, contains('brightness: dark')); + expect(description, contains('BeamStyle(colors:')); + expect(description, contains('BorderRadius.circular(16.0)')); + }); + }); + + group('theme inheritance', () { + testWidgets('takes its defaults from the BorderBeamThemeData passed in', ( + tester, + ) async { + const themed = BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + theme: BorderBeamThemeData(playback: BeamPlayback(active: false)), + ); + await tester.pumpWidget(scene(themed)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + + expect(await paintedPixels(tester), 0); + }); + + testWidgets('a decoration value wins over the theme it was given', ( + tester, + ) async { + const themed = BeamDecoration( + variant: BeamVariant.rotate, + brightness: Brightness.dark, + active: true, + theme: BorderBeamThemeData(playback: BeamPlayback(active: false)), + ); + await tester.pumpWidget(scene(themed)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1300)); + + expect(await paintedPixels(tester), greaterThan(0)); + }); + }); +} diff --git a/test/widgets/beam_focus_ring_test.dart b/test/widgets/beam_focus_ring_test.dart new file mode 100644 index 0000000..a6d6581 --- /dev/null +++ b/test/widgets/beam_focus_ring_test.dart @@ -0,0 +1,310 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// [BeamFocusRing] is a thin composition: it only decides `active` on the +/// [BorderBeam] it wraps, from focus plus [FocusManager.highlightMode]. Every +/// assertion below reads that decision off the inner beam. +void main() { + // The test binding reports the Android platform, whose automatic highlight + // mode is `touch` — the mode under which the ring deliberately stays dark. + // Focus tracking is asserted under traditional highlighting; the + // highlight-mode group sets its own. + setUp(() { + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; + }); + + tearDown(() { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.automatic; + }); + + bool? ringActive(WidgetTester tester) => + tester.widget(find.byType(BorderBeam)).active; + + // A focus change lands over two frames: the manager applies it during the + // first, and the node's notification rebuilds the ring in the second. + Future settleFocus(WidgetTester tester) async { + await tester.pump(); + await tester.pump(); + } + + Widget scene(Widget child) => MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold(body: Center(child: child)), + ); + + group('focus tracking', () { + testWidgets('lights while the wrapped subtree holds focus', (tester) async { + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget( + scene( + BeamFocusRing( + child: SizedBox( + width: 200, + height: 60, + child: TextField(focusNode: node), + ), + ), + ), + ); + expect(ringActive(tester), isFalse); + + node.requestFocus(); + await settleFocus(tester); + expect(ringActive(tester), isTrue); + + node.unfocus(); + await settleFocus(tester); + expect(ringActive(tester), isFalse); + }); + + testWidgets('follows an explicitly given node instead', (tester) async { + final node = FocusNode(); + final other = FocusNode(); + addTearDown(node.dispose); + addTearDown(other.dispose); + + await tester.pumpWidget( + scene( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Focus(focusNode: other, child: const SizedBox(height: 20)), + BeamFocusRing( + focusNode: node, + child: Focus( + focusNode: node, + child: const SizedBox(width: 200, height: 60), + ), + ), + ], + ), + ), + ); + expect(ringActive(tester), isFalse); + + other.requestFocus(); + await settleFocus(tester); + expect(ringActive(tester), isFalse); + + node.requestFocus(); + await settleFocus(tester); + expect(ringActive(tester), isTrue); + }); + + testWidgets('an explicit node adds no Focus of its own', (tester) async { + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget( + scene( + BeamFocusRing( + focusNode: node, + child: const SizedBox(width: 200, height: 60), + ), + ), + ); + + expect( + find.descendant( + of: find.byType(BeamFocusRing), + matching: find.byType(Focus), + ), + findsNothing, + ); + }); + + testWidgets('swapping the node moves the ring with it', (tester) async { + final first = FocusNode(); + final second = FocusNode(); + addTearDown(first.dispose); + addTearDown(second.dispose); + + Widget build(FocusNode node) => scene( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Focus(focusNode: first, child: const SizedBox(height: 20)), + Focus(focusNode: second, child: const SizedBox(height: 20)), + BeamFocusRing( + focusNode: node, + child: const SizedBox(width: 200, height: 60), + ), + ], + ), + ); + + await tester.pumpWidget(build(first)); + first.requestFocus(); + await settleFocus(tester); + expect(ringActive(tester), isTrue); + + await tester.pumpWidget(build(second)); + await settleFocus(tester); + expect(ringActive(tester), isFalse); + + second.requestFocus(); + await settleFocus(tester); + expect(ringActive(tester), isTrue); + }); + + testWidgets('disposal with focus held leaves no ticker behind', ( + tester, + ) async { + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget( + scene( + BeamFocusRing( + child: Focus( + focusNode: node, + child: const SizedBox(width: 200, height: 60), + ), + ), + ), + ); + node.requestFocus(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpWidget(const SizedBox()); + expect(tester.binding.transientCallbackCount, 0); + }); + }); + + group('highlight mode', () { + testWidgets('stays dark under touch highlighting', (tester) async { + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTouch; + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget( + scene( + BeamFocusRing( + child: Focus( + focusNode: node, + child: const SizedBox(width: 200, height: 60), + ), + ), + ), + ); + node.requestFocus(); + await settleFocus(tester); + + expect(ringActive(tester), isFalse); + }); + + testWidgets('alwaysShow lights it under touch highlighting too', ( + tester, + ) async { + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTouch; + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget( + scene( + BeamFocusRing( + alwaysShow: true, + child: Focus( + focusNode: node, + child: const SizedBox(width: 200, height: 60), + ), + ), + ), + ); + node.requestFocus(); + await settleFocus(tester); + + expect(ringActive(tester), isTrue); + }); + + testWidgets('a mode change while focused flips the ring', (tester) async { + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTouch; + final node = FocusNode(); + addTearDown(node.dispose); + + await tester.pumpWidget( + scene( + BeamFocusRing( + child: Focus( + focusNode: node, + child: const SizedBox(width: 200, height: 60), + ), + ), + ), + ); + node.requestFocus(); + await settleFocus(tester); + expect(ringActive(tester), isFalse); + + FocusManager.instance.highlightStrategy = + FocusHighlightStrategy.alwaysTraditional; + await settleFocus(tester); + expect(ringActive(tester), isTrue); + }); + }); + + group('pass-through', () { + testWidgets('hands its style, shape and timing to the beam', ( + tester, + ) async { + await tester.pumpWidget( + scene( + const BeamFocusRing( + variant: BeamVariant.line, + colors: BeamColors.sunset, + borderRadius: 12, + style: BeamStyle(strength: 0.5), + shape: BeamShape(borderWidth: 2), + timing: BeamTiming(speed: 2), + child: SizedBox(width: 200, height: 60), + ), + ), + ); + + final beam = tester.widget(find.byType(BorderBeam)); + expect(beam.variant, BeamVariant.line); + expect(beam.colors, BeamColors.sunset); + expect(beam.borderRadius, 12); + expect(beam.style, const BeamStyle(strength: 0.5)); + expect(beam.shape, const BeamShape(borderWidth: 2)); + expect(beam.timing, const BeamTiming(speed: 2)); + }); + + testWidgets('defaults to the compact ocean ring', (tester) async { + await tester.pumpWidget( + scene(const BeamFocusRing(child: SizedBox(width: 200, height: 60))), + ); + + final beam = tester.widget(find.byType(BorderBeam)); + expect(beam.variant, BeamVariant.small); + expect(beam.colors, BeamColors.ocean); + }); + + testWidgets('describes itself for the inspector', (tester) async { + await tester.pumpWidget( + scene( + const BeamFocusRing( + alwaysShow: true, + child: SizedBox(width: 200, height: 60), + ), + ), + ); + + final description = tester + .widget(find.byType(BeamFocusRing)) + .toDiagnosticsNode() + .toStringDeep(); + expect(description, contains('alwaysShow')); + expect(description, contains('variant: small')); + }); + }); +} diff --git a/test/widgets/beam_hover_test.dart b/test/widgets/beam_hover_test.dart new file mode 100644 index 0000000..b3c3df4 --- /dev/null +++ b/test/widgets/beam_hover_test.dart @@ -0,0 +1,204 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// [BeamHover] turns a [MouseRegion] into two values on the [BorderBeam] it +/// wraps: `active` (lit while hovered, held for `holdAfterExit` after) and +/// `follow` (the cursor in normalized box coordinates). +void main() { + const size = Size(200, 80); + + BorderBeam beamOf(WidgetTester tester) => + tester.widget(find.byType(BorderBeam)); + + Widget scene({ + bool followPointer = true, + Duration holdAfterExit = const Duration(milliseconds: 300), + }) => MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: BeamHover( + followPointer: followPointer, + holdAfterExit: holdAfterExit, + child: SizedBox.fromSize(size: size), + ), + ), + ), + ); + + Future hoverGesture(WidgetTester tester) async { + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + return gesture; + } + + group('hover tracking', () { + testWidgets('entering lights the beam and follows the cursor', ( + tester, + ) async { + await tester.pumpWidget(scene()); + expect(beamOf(tester).active, isFalse); + expect(beamOf(tester).follow, isNull); + + final gesture = await hoverGesture(tester); + await gesture.moveTo(tester.getCenter(find.byType(BeamHover))); + await tester.pump(); + + expect(beamOf(tester).active, isTrue); + expect(beamOf(tester).follow, const Offset(0.5, 0.5)); + }); + + testWidgets('moving inside re-aims the follow point', (tester) async { + await tester.pumpWidget(scene()); + final gesture = await hoverGesture(tester); + final topLeft = tester.getTopLeft(find.byType(BeamHover)); + + await gesture.moveTo(topLeft + const Offset(50, 20)); + await tester.pump(); + expect(beamOf(tester).follow, const Offset(0.25, 0.25)); + + await gesture.moveTo(topLeft + const Offset(150, 60)); + await tester.pump(); + expect(beamOf(tester).follow, const Offset(0.75, 0.75)); + }); + + testWidgets('the follow point stays inside the box at the edges', ( + tester, + ) async { + await tester.pumpWidget(scene()); + final gesture = await hoverGesture(tester); + final topLeft = tester.getTopLeft(find.byType(BeamHover)); + + await gesture.moveTo(topLeft); + await tester.pump(); + final follow = beamOf(tester).follow!; + expect(follow.dx, inInclusiveRange(0, 1)); + expect(follow.dy, inInclusiveRange(0, 1)); + }); + + testWidgets('followPointer false lights without steering', (tester) async { + await tester.pumpWidget(scene(followPointer: false)); + final gesture = await hoverGesture(tester); + await gesture.moveTo(tester.getCenter(find.byType(BeamHover))); + await tester.pump(); + + expect(beamOf(tester).active, isTrue); + expect(beamOf(tester).follow, isNull); + }); + }); + + group('exit', () { + testWidgets('releases the follow at once and fades out after the hold', ( + tester, + ) async { + await tester.pumpWidget(scene()); + final gesture = await hoverGesture(tester); + await gesture.moveTo(tester.getCenter(find.byType(BeamHover))); + await tester.pump(); + + await gesture.moveTo(const Offset(5, 5)); + await tester.pump(); + expect(beamOf(tester).follow, isNull, reason: 'released on exit'); + expect(beamOf(tester).active, isTrue, reason: 'still inside the hold'); + + await tester.pump(const Duration(milliseconds: 250)); + expect(beamOf(tester).active, isTrue); + + await tester.pump(const Duration(milliseconds: 100)); + expect(beamOf(tester).active, isFalse); + }); + + testWidgets('re-entering within the hold keeps the beam lit', ( + tester, + ) async { + await tester.pumpWidget(scene()); + final gesture = await hoverGesture(tester); + final center = tester.getCenter(find.byType(BeamHover)); + await gesture.moveTo(center); + await tester.pump(); + + await gesture.moveTo(const Offset(5, 5)); + await tester.pump(const Duration(milliseconds: 200)); + await gesture.moveTo(center); + await tester.pump(const Duration(milliseconds: 400)); + + expect(beamOf(tester).active, isTrue); + expect(beamOf(tester).follow, const Offset(0.5, 0.5)); + }); + + testWidgets('a zero hold fades out on exit', (tester) async { + await tester.pumpWidget(scene(holdAfterExit: Duration.zero)); + final gesture = await hoverGesture(tester); + await gesture.moveTo(tester.getCenter(find.byType(BeamHover))); + await tester.pump(); + + await gesture.moveTo(const Offset(5, 5)); + await tester.pump(); + expect(beamOf(tester).active, isFalse); + expect(beamOf(tester).follow, isNull); + }); + + testWidgets('unmounting during the hold leaves no timer or ticker', ( + tester, + ) async { + await tester.pumpWidget(scene()); + final gesture = await hoverGesture(tester); + await gesture.moveTo(tester.getCenter(find.byType(BeamHover))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await gesture.moveTo(const Offset(5, 5)); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 1)); + + expect(tester.binding.transientCallbackCount, 0); + }); + }); + + group('pass-through', () { + testWidgets('hands its style, shape and timing to the beam', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: BeamHover( + variant: BeamVariant.small, + colors: BeamColors.sunset, + borderRadius: 18, + style: BeamStyle(strength: 0.7), + shape: BeamShape(borderWidth: 2), + timing: BeamTiming(speed: 2), + child: SizedBox(width: 200, height: 80), + ), + ), + ), + ), + ); + + final beam = beamOf(tester); + expect(beam.variant, BeamVariant.small); + expect(beam.colors, BeamColors.sunset); + expect(beam.borderRadius, 18); + expect(beam.style, const BeamStyle(strength: 0.7)); + expect(beam.shape, const BeamShape(borderWidth: 2)); + expect(beam.timing, const BeamTiming(speed: 2)); + }); + + testWidgets('describes itself for the inspector', (tester) async { + await tester.pumpWidget(scene(followPointer: false)); + final description = tester + .widget(find.byType(BeamHover)) + .toDiagnosticsNode() + .toStringDeep(); + expect(description, contains('does not follow the pointer')); + expect(description, contains('variant: rotate')); + }); + }); +} diff --git a/test/widgets/beam_press_test.dart b/test/widgets/beam_press_test.dart new file mode 100644 index 0000000..c35f567 --- /dev/null +++ b/test/widgets/beam_press_test.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_border_beam/flutter_border_beam.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// [BeamPress] observes raw pointers and only flips `active` on the +/// [BorderBeam] it wraps. The two things it must never get wrong: a tap +/// shorter than [BeamPress.minimumDuration] still shows a full pulse, and the +/// child's own gestures keep working. +void main() { + bool? pressActive(WidgetTester tester) => + tester.widget(find.byType(BorderBeam)).active; + + Widget scene(Widget child) => MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold(body: Center(child: child)), + ); + + Widget press({ + VoidCallback? onTap, + Duration minimumDuration = const Duration(milliseconds: 600), + Widget child = const SizedBox(width: 200, height: 80), + }) => scene( + BeamPress(onTap: onTap, minimumDuration: minimumDuration, child: child), + ); + + group('press lifecycle', () { + testWidgets('lights on pointer down', (tester) async { + await tester.pumpWidget(press()); + expect(pressActive(tester), isFalse); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(BeamPress)), + ); + await tester.pump(); + expect(pressActive(tester), isTrue); + + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('a quick tap still holds the minimum duration', (tester) async { + await tester.pumpWidget(press()); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(BeamPress)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.up(); + await tester.pump(); + expect(pressActive(tester), isTrue, reason: 'released before minimum'); + + await tester.pump(const Duration(milliseconds: 500)); + expect(pressActive(tester), isTrue, reason: 'still inside minimum'); + + await tester.pump(const Duration(milliseconds: 100)); + expect(pressActive(tester), isFalse); + }); + + testWidgets('a long press releases the moment the pointer lifts', ( + tester, + ) async { + await tester.pumpWidget(press()); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(BeamPress)), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + expect(pressActive(tester), isTrue); + + await gesture.up(); + await tester.pump(); + expect(pressActive(tester), isFalse); + }); + + testWidgets('a zero minimum releases on pointer up', (tester) async { + await tester.pumpWidget(press(minimumDuration: Duration.zero)); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(BeamPress)), + ); + await tester.pump(); + expect(pressActive(tester), isTrue); + + await gesture.up(); + await tester.pump(); + expect(pressActive(tester), isFalse); + }); + + testWidgets('a cancel drops the beam without waiting out the minimum', ( + tester, + ) async { + await tester.pumpWidget(press()); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(BeamPress)), + ); + await tester.pump(); + expect(pressActive(tester), isTrue); + + await gesture.cancel(); + await tester.pump(); + expect(pressActive(tester), isFalse); + }); + + testWidgets('a second finger does not restart the hold', (tester) async { + await tester.pumpWidget(press()); + final center = tester.getCenter(find.byType(BeamPress)); + final first = await tester.startGesture(center); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + final second = await tester.startGesture(center + const Offset(10, 10)); + await tester.pump(); + await first.up(); + await tester.pump(const Duration(milliseconds: 300)); + + // The minimum runs from the first press, so 700ms in it has elapsed — + // the second pointer never re-armed it. + expect(pressActive(tester), isFalse); + await second.up(); + await tester.pump(const Duration(seconds: 1)); + }); + + testWidgets('disposal mid-hold leaves no ticker and no pending timer', ( + tester, + ) async { + await tester.pumpWidget(press()); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(BeamPress)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.binding.transientCallbackCount, greaterThan(0)); + + await tester.pumpWidget(const SizedBox()); + await gesture.up(); + expect(tester.binding.transientCallbackCount, 0); + }); + }); + + group('gestures', () { + testWidgets('onTap fires when the pointer lifts inside', (tester) async { + var taps = 0; + await tester.pumpWidget(press(onTap: () => taps++)); + + await tester.tap(find.byType(BeamPress)); + await tester.pump(const Duration(seconds: 1)); + expect(taps, 1); + }); + + testWidgets('onTap does not fire when the pointer lifts outside', ( + tester, + ) async { + var taps = 0; + await tester.pumpWidget(press(onTap: () => taps++)); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(BeamPress)), + ); + await tester.pump(); + await gesture.moveTo(const Offset(5, 5)); + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + + expect(taps, 0); + }); + + testWidgets('the child keeps receiving its own taps', (tester) async { + var childTaps = 0; + var pressTaps = 0; + await tester.pumpWidget( + press( + onTap: () => pressTaps++, + child: GestureDetector( + key: const ValueKey('child'), + behavior: HitTestBehavior.opaque, + onTap: () => childTaps++, + child: const SizedBox(width: 200, height: 80), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('child'))); + await tester.pump(const Duration(seconds: 1)); + + expect(childTaps, 1); + // The Listener never enters the arena, so both fire — which is why a + // child with its own tap handling should leave onTap null. + expect(pressTaps, 1); + }); + + testWidgets('a drag over it still scrolls the list underneath', ( + tester, + ) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + scene( + SizedBox( + height: 300, + child: ListView( + controller: controller, + children: [ + for (var i = 0; i < 12; i++) + const BeamPress(child: SizedBox(height: 80)), + ], + ), + ), + ), + ); + + await tester.drag(find.byType(BeamPress).first, const Offset(0, -200)); + await tester.pumpAndSettle(); + + expect(controller.offset, greaterThan(0)); + }); + + testWidgets('a stolen gesture cancels the press', (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + scene( + SizedBox( + height: 300, + child: ListView( + controller: controller, + children: [ + for (var i = 0; i < 12; i++) + const BeamPress(child: SizedBox(height: 80)), + ], + ), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(BeamPress).first), + ); + await tester.pump(); + expect( + tester.widget(find.byType(BorderBeam).first).active, + isTrue, + ); + + await gesture.moveBy(const Offset(0, -120)); + await tester.pump(); + expect( + tester.widget(find.byType(BorderBeam).first).active, + isFalse, + ); + + await gesture.up(); + await tester.pumpAndSettle(); + }); + }); + + group('pass-through', () { + testWidgets('hands its style, shape and timing to the beam', ( + tester, + ) async { + await tester.pumpWidget( + scene( + const BeamPress( + variant: BeamVariant.rotate, + colors: BeamColors.sunset, + borderRadius: 20, + style: BeamStyle(strength: 0.4), + shape: BeamShape(borderWidth: 2), + timing: BeamTiming(speed: 2), + child: SizedBox(width: 200, height: 80), + ), + ), + ); + + final beam = tester.widget(find.byType(BorderBeam)); + expect(beam.variant, BeamVariant.rotate); + expect(beam.colors, BeamColors.sunset); + expect(beam.borderRadius, 20); + expect(beam.style, const BeamStyle(strength: 0.4)); + expect(beam.shape, const BeamShape(borderWidth: 2)); + expect(beam.timing, const BeamTiming(speed: 2)); + }); + + testWidgets('defaults to the contained breathing glow', (tester) async { + await tester.pumpWidget(press()); + expect( + tester.widget(find.byType(BorderBeam)).variant, + BeamVariant.pulseInside, + ); + }); + + testWidgets('is translucent to hit testing', (tester) async { + await tester.pumpWidget(press()); + final listener = tester.widget( + find + .descendant( + of: find.byType(BeamPress), + matching: find.byType(Listener), + ) + .first, + ); + expect(listener.behavior, HitTestBehavior.translucent); + }); + + testWidgets('describes itself for the inspector', (tester) async { + await tester.pumpWidget(press(onTap: () {})); + final description = tester + .widget(find.byType(BeamPress)) + .toDiagnosticsNode() + .toStringDeep(); + expect(description, contains('tappable')); + expect(description, contains('variant: pulseInside')); + }); + }); +} diff --git a/tool/record_demo.sh b/tool/record_demo.sh index 5fab391..4710bec 100755 --- a/tool/record_demo.sh +++ b/tool/record_demo.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# record_demo.sh — record a clean, high-quality demo video of a border_beam +# record_demo.sh — record a clean, high-quality demo video of a flutter_border_beam # reel running on the iOS simulator, with zero manual ffmpeg/simctl fuss. # # It drives the example app like this: diff --git a/tool/spec/README.md b/tool/spec/README.md new file mode 100644 index 0000000..b684dd7 --- /dev/null +++ b/tool/spec/README.md @@ -0,0 +1,32 @@ +# Upstream spec vendoring + +The React [border-beam](https://github.com/Jakubantalik/Libraries/tree/main/packages/border-beam) +library publishes `spec/beam-spec.json`, a machine-readable dump of every +table its `src/styles.ts` builds its CSS from. This package's +`lib/src/constants/` is a hand transcription of those same tables, so the spec +is the only thing that can prove the transcription is still right. + +- `test/fixtures/beam-spec.json` — the vendored spec. +- `test/fixtures/UPSTREAM` — the commit, versions, and `styles.ts` hash it was + fetched at. +- `test/constants/spec_parity_test.dart` — asserts our constants against it. +- `.github/workflows/upstream_drift.yaml` — weekly alarm when upstream moves. + +## Refreshing + +```bash +tool/spec/refresh.sh # from main +tool/spec/refresh.sh v1.4.0 # from a tag/branch/sha +``` + +Then re-check `lib/src/constants/upstream.dart` (its version strings are hand +maintained) and run: + +```bash +flutter test test/constants/spec_parity_test.dart +``` + +A parity failure after a refresh means upstream changed a value. **Re-audit +the constants against `src/styles.ts` before touching anything** — the hard +rule in `CLAUDE.md` still stands: constants are never tweaked to make a test +pass. diff --git a/tool/spec/refresh.sh b/tool/spec/refresh.sh new file mode 100755 index 0000000..1804e73 --- /dev/null +++ b/tool/spec/refresh.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Re-vendors the upstream border-beam spec into `test/fixtures/`. +# +# The React library ships a machine-readable `spec/beam-spec.json` generated +# from its `src/styles.ts`. `test/constants/spec_parity_test.dart` reads the +# vendored copy and asserts every table in `lib/src/constants/` against it, so +# the fixture is the parity test's ground truth and must be refreshed whenever +# upstream moves. +# +# Writes (idempotent — re-running on an unchanged upstream leaves the tree +# untouched): +# test/fixtures/beam-spec.json the spec, byte for byte as upstream has it +# test/fixtures/UPSTREAM commit=, version=, package_version=, +# spec_version=, styles_sha256= +# +# `styles_sha256` hashes the upstream `src/styles.ts` the spec was generated +# from; `.github/workflows/upstream_drift.yaml` compares against it so a +# styles change that never reached the spec generator still raises the alarm. +# +# Usage: +# tool/spec/refresh.sh # fetch from main +# tool/spec/refresh.sh # fetch from a tag/branch/sha +# +# Needs `curl`, `jq`, and `shasum`. After running, re-read +# `lib/src/constants/upstream.dart` (its version strings are hand-maintained), +# then run: +# flutter test test/constants/spec_parity_test.dart +set -euo pipefail + +REPO="Jakubantalik/Libraries" +PKG="packages/border-beam" +REF="${1:-main}" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fixtures="$repo_root/test/fixtures" +mkdir -p "$fixtures" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# Downloads $2 from the package at commit $1 into $work/$3, preserving the +# bytes exactly — command substitution would eat the trailing newline and +# change the styles.ts hash. +raw() { + curl -fsSL "https://raw.githubusercontent.com/$REPO/$1/$PKG/$2" -o "$work/$3" +} + +echo "Fetching $REPO@$REF …" + +# Resolve the ref to a concrete commit so the fixture records exactly what was +# fetched, even when REF is a moving branch. +commit="$(curl -fsSL \ + -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/$REPO/commits/$REF" | jq -r .sha)" +if [ -z "$commit" ] || [ "$commit" = "null" ]; then + echo "error: could not resolve $REPO@$REF to a commit" >&2 + exit 1 +fi + +raw "$commit" "spec/beam-spec.json" spec.json +raw "$commit" "src/styles.ts" styles.ts +raw "$commit" "package.json" package.json + +jq empty < "$work/spec.json" # reject a truncated or non-JSON download + +version="$(jq -r .sourceLibrary.version < "$work/spec.json")" +spec_version="$(jq -r .specVersion < "$work/spec.json")" +package_version="$(jq -r .version < "$work/package.json")" +styles_sha256="$(shasum -a 256 < "$work/styles.ts" | cut -d' ' -f1)" + +cp "$work/spec.json" "$fixtures/beam-spec.json" + +cat > "$fixtures/UPSTREAM" <